Compare commits

...

17 Commits

Author SHA1 Message Date
sebseb7
f43e0af918 Update ModelDialog to accept options for model selection and enhance error handling for invalid models. Modify CLI to use the new model configuration and update interrogation command for improved functionality. 2025-08-22 22:43:27 +00:00
sebseb7
46c9fe9fac Add pricing structure for token usage in CLI and enhance token handling in ModelDialog. Implement cost breakdown per model based on input, cached, and output tokens, and ensure proper initialization of token counts in usage handling. 2025-08-21 13:31:15 +00:00
sebseb7
eb3f58b2e6 Refactor CLI and file handling tools for improved functionality. Update CLI interrogation command for better clarity and adjust logging format. Modify list_files.js to enhance path display logic and update read_file.js schema to allow null values for optional parameters, improving flexibility in file reading operations. 2025-08-21 12:58:14 +00:00
sebseb7
6e8a336143 Enhance message handling in ModelDialog by adding console logging for sent messages and enabling parallel_tool_calls for improved response processing. 2025-08-21 12:50:44 +00:00
sebseb7
839cea7fe6 Update ModelDialog and list_files.js to enhance functionality. Add parallel_tool_calls option in ModelDialog for improved response handling. Modify list_files.js schema to allow null types for path, depth, and includeHidden properties, and remove required fields for greater flexibility. 2025-08-21 12:41:31 +00:00
sebseb7
131a45e305 Update CLI interrogation command for improved file handling and output clarity. Enhance error logging in patch_files.js by integrating chalk for better visibility of patch errors and refining path resolution logic for file updates. 2025-08-21 08:33:00 +00:00
sebseb7
7fb261a3b7 u 2025-08-21 08:21:15 +00:00
sebseb7
7ad5d10378 Update CLI and ModelDialog to enhance functionality and user experience. Modify interrogation command in CLI for improved output generation, adjust model settings in ModelDialog for better reasoning effort, and introduce a new plugin structure in plan.md for LLM integration in Roundcube. Add spinner functionality in InkApp for loading states and improve error handling in read_file.js to ensure proper line breaks in file content output. 2025-08-21 08:20:38 +00:00
sebseb7
3c6bf7184c Refactor path handling in patch_files.js to use user-specified paths for action keys, improving consistency and error messaging. Update CLI to replace commented-out test cases with a new interrogation command in German for business website creation, enhancing functionality. 2025-08-14 11:01:55 +00:00
sebseb7
9974a78394 Refactor path handling in list_files.js and patch_files.js for improved clarity and consistency. Update error messages to reflect 'root' terminology instead of 'chroot' and enhance path resolution logic for better handling of absolute and relative paths. 2025-08-14 10:22:27 +00:00
sebseb7
421b47355b Refactor logging in CLI and ModelDialog to improve clarity by commenting out verbose console outputs. Update function call result logging to include limited arguments and JSON stringification for better readability. Enhance error handling in read_file.js to check for file existence before processing. 2025-08-14 10:09:24 +00:00
sebseb7
657b6af993 Enhance output logging in CLI and ModelDialog by integrating chalk for better readability. Update output handling to include detailed reasoning and token information. Refactor message management in ModelDialog to improve response processing and add support for function call arguments. Adjust chroot paths in various tools for consistency. 2025-08-14 09:41:17 +00:00
sebseb7
df85e5e603 Enhance terminal functionality in InkApp by implementing a new input handling system. Introduce a command history feature for improved user navigation and streamline the process for submitting commands. Update state management to ensure accurate tracking of user inputs and command history. 2025-08-13 04:56:39 +00:00
sebseb7
b49c798fc7 Implement terminal restart functionality and enhance model settings menu in InkApp. Add methods for restarting the terminal and adjusting model settings, improving user interaction and menu navigation. Update state management for better handling of model settings adjustments. 2025-08-13 01:57:11 +00:00
sebseb7
83ac8709b7 Refactor CLI and InkApp components for improved functionality and user experience. Update model settings in InkApp, enhance terminal input handling, and streamline dependency management in package.json. Remove unused dependencies and update dotenv version for better environment variable management. 2025-08-13 00:59:20 +00:00
sebseb7
58d8c352f3 Add Array.fromAsync polyfill in cli.js for better async iterable support. This enhancement improves compatibility with async data processing in the CLI environment. 2025-08-12 22:07:48 +00:00
sebseb7
14305859de Update devcontainer configuration and enhance terminal handling in InkApp. Change base image to 'devpit:latest' and implement a blinking cursor feature in the terminal display, improving user experience and visual feedback during input. 2025-08-12 22:02:01 +00:00
19 changed files with 1389 additions and 722 deletions

View File

@@ -1,3 +1,3 @@
{
"image": "mcr.microsoft.com/devcontainers/javascript-node"
"image": "devpit:latest"
}

3
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules
.env
tmp
tmp
root

17
cli.js
View File

@@ -10,6 +10,16 @@ import { promises as fs, unwatchFile } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
if (!Array.fromAsync) {
Array.fromAsync = async function fromAsync(asyncIterable) {
const array = [];
for await (const item of asyncIterable) {
array.push(item);
}
return array;
};
}
function renderUsage(usage) {
const inputTokens = usage.input_tokens - usage.input_tokens_details.cached_tokens;
const cacheTokens = usage.input_tokens_details.cached_tokens;
@@ -74,6 +84,7 @@ while(true){
// Block for user input before kicking off the LLM loop
const userText = await askUserForInput();
await streamOnce(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }), userText );
//await streamOnce(new OpenAI({ baseURL: "https://api.cerebras.ai/v1",apiKey: "csk-8jftdte6r6vf8fdvp9xkyek5t3jnc6jfhh93d3ewfcwxxvh9" }), userText );
async function streamOnce(openai, userText) {
const toolsByFile = await loadTools();
@@ -96,10 +107,10 @@ websearch - eine Google Suche machen mit Schlüsselwörtern
do{
const call = {
model: 'gpt-5-mini',
model: 'gpt-4.1-nano',
input: counter == 0 ? [systemprompt,...structuredClone(input)] : structuredClone(input),
text: { format: { type: 'text' }, verbosity: 'low' },
reasoning: { effort: 'minimal', summary: 'detailed' },
text: { format: { type: 'text' }/*, verbosity: 'low' */},
//reasoning: { effort: 'minimal', summary: 'detailed' },
tools: Object.values(toolsByFile).map(t => t.def),
store: true,
}

109
cli2.js Normal file
View File

@@ -0,0 +1,109 @@
import ModelDialog from './modelDialog.js';
import chalk from 'chalk';
const modelDialog = new ModelDialog({model: 'gpt-5-mini'});
modelDialog.on('outputUpdate', (output) => {
//console.log(chalk.blue('output event'),output);
});
modelDialog.on('reasoningUpdate', (output) => {
//console.log(chalk.blue('reasoning event'),output);
});
// $ / 1million tokens
const price = {
'gpt-5-2025-08-07': {
input: 1.25,
cached: 0.125,
output: 10
},
'gpt-5-mini-2025-08-07': {
input: 0.25,
cached: 0.025,
output: 2
},
'gpt-5-nano-2025-08-07': {
input: 0.05,
cached: 0.005,
output: 0.4
},
'gpt-4.1-2025-04-14': {
input: 2,
cached: 0.5,
output: 8
},
'gpt-4.1-mini-2025-04-14': {
input: 0.4,
cached: 0.1,
output: 1.6
},
};
(async ()=>{
//const output = await modelDialog.interrogate('Can you remember "seven" ?');
//console.log(output.output,JSON.stringify(output.reasoning,null,2));
//const output2 = await modelDialog.interrogate('read a file that is what you remebered plus 1 as a word with txt ending, check that file.');
const output2 = await modelDialog.interrogate('schau dich mal um und wenn du html dateien findest, dann invertiere den gradient.');
console.log('final output:',output2.output);
console.log('reasoning:',output2.reasoning);
//Ti: { 'gpt-5-2025-08-07': 3019 } Tc: { 'gpt-5-2025-08-07': 0 } To: { 'gpt-5-2025-08-07': 751 }
console.log('Ti:',output2.inputTokens,'Tc:',output2.cachedTokens,'To:',output2.outputTokens);
// cost breakdown per model and totals (prices are per 1M tokens)
const perMillion = 1_000_000;
const models = new Set([
...Object.keys(output2.inputTokens || {}),
...Object.keys(output2.cachedTokens || {}),
...Object.keys(output2.outputTokens || {})
]);
let grandTotal = 0;
for (const model of models) {
const inputT = (output2.inputTokens || {})[model];
const cachedT = (output2.cachedTokens || {})[model];
const outputT = (output2.outputTokens || {})[model];
const p = price[model];
const inputCost = (typeof inputT === 'number' && p) ? (inputT / perMillion) * p.input : undefined;
const cachedCost = (typeof cachedT === 'number' && p) ? (cachedT / perMillion) * p.cached : undefined;
const outputCost = (typeof outputT === 'number' && p) ? (outputT / perMillion) * p.output : undefined;
const subtotal = [inputCost, cachedCost, outputCost].every(v => typeof v === 'number')
? (inputCost + cachedCost + outputCost)
: undefined;
if (typeof subtotal === 'number') grandTotal += subtotal;
console.log('cost for', model, {
inputCost: parseFloat(inputCost.toFixed(6)),
cachedCost: parseFloat(cachedCost.toFixed(6)),
outputCost: parseFloat(outputCost.toFixed(6)),
subtotal: parseFloat(subtotal.toFixed(4))
});
}
//console.log('total cost:', grandTotal);
})()

132
index.html Normal file
View File

@@ -0,0 +1,132 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pelikan fährt Rad SVG</title>
<style>
html, body { height: 100%; margin: 0; background:#0e1320; color:#e7ecf3; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; }
.wrap { display:grid; place-items:center; height:100%; }
.cap { position: fixed; bottom: 0.75rem; left: 0; right: 0; text-align:center; font-size: 0.9rem; opacity: .8; }
svg { width: min(92vw, 960px); height: auto; }
</style>
</head>
<body>
<div class="wrap">
<!-- Rein vektoriell gezeichneter Pelikan auf einem Fahrrad. -->
<svg viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d">
<title id="t">Pelikan fährt Fahrrad</title>
<desc id="d">Ein stilisierter Pelikan mit großem Schnabel fährt ein Fahrrad mit zwei Rädern; einfache Formen, klare Farben.</desc>
<!-- Hintergrund-Deko -->
<defs>
<radialGradient id="sky" cx="50%" cy="40%" r="70%">
<stop offset="0%" stop-color="#1b2a4a" />
<stop offset="100%" stop-color="#0e1320" />
</radialGradient>
<linearGradient id="beakGrad" x1="0" x2="1" y1="0" y2="0">
<stop offset="0%" stop-color="#ffb347"/>
<stop offset="100%" stop-color="#ff7f11"/>
</linearGradient>
<linearGradient id="frameGrad" x1="0" x2="1" y1="0" y2="0">
<stop offset="0%" stop-color="#4bd5ff"/>
<stop offset="100%" stop-color="#6a5cff"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity="0.35"/>
</filter>
</defs>
<rect width="100%" height="100%" fill="url(#sky)"/>
<!-- Bodenlinie -->
<g opacity="0.35">
<ellipse cx="400" cy="520" rx="300" ry="40" fill="#000" />
</g>
<!-- Fahrrad -->
<g id="bike" transform="translate(0,0)" filter="url(#shadow)">
<!-- Räder -->
<g id="wheels" stroke="#cfe7ff" stroke-width="6" fill="none">
<circle cx="260" cy="460" r="90" />
<circle cx="540" cy="460" r="90" />
<!-- Speichen -->
<g stroke="#9fd0ff" stroke-width="3" opacity="0.6">
<line x1="260" y1="370" x2="260" y2="550" />
<line x1="170" y1="460" x2="350" y2="460" />
<line x1="200" y1="400" x2="320" y2="520" />
<line x1="200" y1="520" x2="320" y2="400" />
<line x1="540" y1="370" x2="540" y2="550" />
<line x1="450" y1="460" x2="630" y2="460" />
<line x1="480" y1="400" x2="600" y2="520" />
<line x1="480" y1="520" x2="600" y2="400" />
</g>
</g>
<!-- Rahmen -->
<g id="frame" stroke="url(#frameGrad)" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" fill="none">
<polyline points="260,460 360,420 450,460 540,460" />
<line x1="360" y1="420" x2="410" y2="360" />
<line x1="410" y1="360" x2="520" y2="360" />
<line x1="520" y1="360" x2="540" y2="460" />
</g>
<!-- Kurbel und Pedale -->
<g id="crank" transform="translate(405,440)">
<circle r="16" fill="#b7d8ff" />
<g stroke="#b7d8ff" stroke-width="6" stroke-linecap="round">
<line x1="0" y1="0" x2="28" y2="-28" />
</g>
<circle cx="28" cy="-28" r="8" fill="#e5f3ff" />
</g>
<!-- Lenker und Sattel -->
<g stroke="#cfe7ff" stroke-width="10" stroke-linecap="round" fill="none">
<path d="M520 360 C 555 335, 585 345, 600 360"/>
</g>
<rect x="325" y="330" width="70" height="16" rx="8" fill="#cfe7ff" />
</g>
<!-- Pelikan -->
<g id="pelican" transform="translate(0,0)">
<!-- Körper -->
<ellipse cx="360" cy="360" rx="90" ry="70" fill="#f2f6fb" stroke="#e1e8f5" stroke-width="4" />
<!-- Flügel -->
<path d="M330 345 C 275 350, 250 395, 270 430 C 295 470, 355 470, 390 440 C 410 420, 400 380, 370 360 Z" fill="#eaf2fb" stroke="#d7e4f6" stroke-width="3" />
<!-- Hals -->
<path d="M400 335 C 445 320, 470 300, 500 295 C 520 292, 540 305, 540 325 C 540 345, 520 360, 500 360 C 470 360, 445 350, 415 355" fill="none" stroke="#f2f6fb" stroke-width="20" stroke-linecap="round" />
<!-- Kopf -->
<circle cx="520" cy="325" r="28" fill="#f7fbff" stroke="#e1e8f5" stroke-width="3" />
<!-- Auge -->
<circle cx="528" cy="322" r="5" fill="#21314f" />
<circle cx="526" cy="320" r="2" fill="#ffffff" />
<!-- Schnabel -->
<path d="M535 330 C 585 330, 630 338, 675 352 C 640 360, 600 368, 555 368 C 545 360, 538 348, 535 330 Z" fill="url(#beakGrad)" stroke="#ff9a2a" stroke-width="3" />
<!-- Kehlsack angedeutet -->
<path d="M540 338 C 585 345, 590 352, 552 365" fill="none" stroke="#ffb347" stroke-width="4" opacity="0.7" />
<!-- Beine -->
<g stroke="#ffb347" stroke-width="10" stroke-linecap="round">
<path d="M340 420 L 320 455" />
<path d="M380 420 L 390 450" />
</g>
<!-- Füße auf Pedalen -->
<g fill="#ffb347">
<circle cx="320" cy="455" r="8" />
<circle cx="390" cy="450" r="8" />
</g>
<!-- Schwanz -->
<path d="M280 365 C 255 360, 240 350, 230 340 C 248 368, 260 390, 290 392" fill="#eaf2fb" stroke="#d7e4f6" stroke-width="3" />
</g>
<!-- Sterne / Deko -->
<g fill="#9fd0ff" opacity="0.8">
<circle cx="90" cy="80" r="2"/>
<circle cx="160" cy="120" r="1.5"/>
<circle cx="720" cy="90" r="2.2"/>
<circle cx="650" cy="160" r="1.8"/>
<circle cx="120" cy="200" r="1.6"/>
</g>
</svg>
</div>
<div class="cap">SVG Illustration: Pelikan auf Fahrrad. Datei lokal öffnen oder auf einen Webserver legen.</div>
</body>
</html>

198
modelDialog.js Normal file
View File

@@ -0,0 +1,198 @@
import OpenAI from 'openai';
import 'dotenv/config';
import EventEmitter from 'events';
import path from 'path';
import fs from 'fs/promises';
import { fileURLToPath } from 'node:url';
import chalk from 'chalk';
async function loadTools() {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const toolsDir = path.join(__dirname, "tools");
const dirents = await fs.readdir(toolsDir, { withFileTypes: true });
const toolEntries = await Promise.all(
dirents
.filter((dirent) => dirent.isFile() && dirent.name.endsWith(".js"))
.map(async (dirent) => {
const fileName = dirent.name.replace(/\.js$/, "");
const module = await import(`file://${path.join(toolsDir, dirent.name)}`);
return [fileName, { def: module.default, run: module.run }];
})
);
return Object.fromEntries(toolEntries);
}
const toolsByFile = await loadTools();
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const systemprompt = {"role": "developer", "content": [ { "type": "input_text","text":
`You are a helpful assistant.`
}]};
if (!Array.fromAsync) {
Array.fromAsync = async function fromAsync(asyncIterable) {
const array = [];
for await (const item of asyncIterable) {
array.push(item);
}
return array;
};
}
class ModelDialog {
constructor(options) {
this.options = options;
this.messages = [systemprompt];
this.messagesSent = [];
this.isActive = false;
this.currentStream = null;
this.previousResponseId = null;
this.emitter = new EventEmitter();
this.inputTokens = {};
this.outputTokens = {};
this.cachedTokens = {};
this.lastDebouncedUpdate = 0;
};
handleUsage = (usage, model) => {
if (typeof this.inputTokens[model] !== 'number') this.inputTokens[model] = 0;
if (typeof this.outputTokens[model] !== 'number') this.outputTokens[model] = 0;
if (typeof this.cachedTokens[model] !== 'number') this.cachedTokens[model] = 0;
this.inputTokens[model] += usage.input_tokens - usage.input_tokens_details.cached_tokens;
this.outputTokens[model] += usage.output_tokens;
this.cachedTokens[model] += usage.input_tokens_details.cached_tokens;
}
on = (event, callback) => {
const debounceTime = 1000; // 1 second
const debouncedCallback = (...args) => {
const now = Date.now();
if (now - this.lastDebouncedUpdate >= debounceTime) {
this.lastDebouncedUpdate = now;
callback(...args);
}
};
this.emitter.on(event, debouncedCallback);
}
interrogate = async (prompt) => {
if(this.isActive) return;
this.isActive = true;
this.messages.push({"role": "user", "content": [ {"type": "input_text","text": prompt }]});
const outputs = [];
do{
const messagesToSend = this.messages.splice(0);
console.log(chalk.blue('sending messages:'),messagesToSend.length);
//console.log(chalk.blue('messages:'),JSON.stringify(messagesToSend,null,2));
this.messagesSent.push(...messagesToSend);
const model = this.options.model || 'gpt-5-mini';
if(!['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-4.1', 'gpt-4.1-mini'].includes(model)){
throw new Error('Invalid model: ' + model);
}
const call = {
model: model,
input: messagesToSend,
text: { format: { type: 'text' } },
tools: Object.values(toolsByFile).map(t => t.def),
store: true,
previous_response_id: this.previousResponseId,
parallel_tool_calls: true,
include: ['reasoning.encrypted_content']
}
if(model.startsWith('gpt-5')){
call.reasoning = { effort: 'low', summary: 'detailed' };
//call.text.format.verbosity = 'low';
}
this.currentStream = openai.responses.stream(call);
this.currentStream.on('response.created', (event) => {
this.previousResponseId = event.response.id;
});
const deltas = [];
this.currentStream.on('response.output_text.delta', (event) => {
deltas.push(event.delta);
this.emitter.emit('outputUpdate', deltas.join(''));
});
const reasoningDeltas = [];
this.currentStream.on('response.reasoning_summary_text.delta', (event) => {
if(!reasoningDeltas[event.summary_index]) reasoningDeltas[event.summary_index] = [];
reasoningDeltas[event.summary_index].push(event.delta);
this.emitter.emit('reasoningUpdate', reasoningDeltas[event.summary_index].join(''));
});
this.currentStream.on('response.reasoning_summary_text.done', (event) => {
//console.log(event);
});
this.currentStream.on('response.function_call_arguments.delta', (event) => {
process.stdout.write(chalk.yellow(event.delta));
});
this.currentStream.on('response.function_call_arguments.done', (event) => {
process.stdout.write("\n");
});
this.currentStream.on('response.completed', async (event) => {
//console.log(chalk.blue('response completed:'),event.response.usage);
this.handleUsage(event.response.usage, event.response.model);
outputs.push(...event.response.output);
for(const toolCall of event.response.output.filter(i => i.type === 'function_call')){
// Limit the 'arguments' field to 400 characters for logging
const limitedArgs = typeof toolCall.arguments === 'string'
? (toolCall.arguments.length > 400 ? toolCall.arguments.slice(0, 400) + '...[truncated]' : toolCall.arguments)
: toolCall.arguments;
const tool = toolsByFile[toolCall.name];
let args;
try{
args = JSON.parse(toolCall.arguments);
} catch(e){
console.error(chalk.red('Error parsing arguments:'), e, toolCall.arguments);
this.messages.push({
type: "function_call_output",
call_id: toolCall.call_id,
output: {error: 'Exception in parsing arguments', exception: e},
});
continue;
}
const result = await tool.run(args);
console.log(chalk.green('function call result:'),'<toolCall.name>',toolCall.name,'</toolCall.name>\n','<args>',limitedArgs,'</args>\n','<result>',JSON.stringify(result).slice(0,100),'...</result>');
this.messages.push({
type: "function_call_output",
call_id: toolCall.call_id,
output: JSON.stringify(result),
});
}
});
await Array.fromAsync(this.currentStream);
console.log(chalk.green('Tico'),[Object.values(this.inputTokens),Object.values(this.cachedTokens),Object.values(this.outputTokens)]);
console.log(chalk.green('Do we need to loop? messages in array = '),this.messages.length)
} while(this.messages.length > 0);
this.isActive = false;
this.lastDebouncedUpdate = 0;
return {
output: outputs.filter(i => i.type === 'message').map(i => i.content[0].text) ,
reasoning: outputs.filter(i => i.type === 'reasoning').map(i => i.summary.map(j => j.text).join('\n')),
inputTokens: this.inputTokens, outputTokens: this.outputTokens, cachedTokens: this.cachedTokens
};
}
}
export default ModelDialog;

718
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,55 +3,6 @@
"version": "1.0.0",
"type": "module",
"main": "cli.js",
"dependencies": {
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.6.0",
"asynckit": "^0.4.0",
"call-bind-apply-helpers": "^1.0.2",
"chalk": "^5.5.0",
"combined-stream": "^1.0.8",
"delayed-stream": "^1.0.0",
"dotenv": "^16.4.5",
"dunder-proto": "^1.0.1",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"es-set-tostringtag": "^2.1.0",
"esbuild": "^0.25.8",
"event-target-shim": "^5.0.1",
"exa-js": "^1.8.27",
"form-data": "^4.0.4",
"form-data-encoder": "^1.7.2",
"formdata-node": "^4.4.1",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.3.0",
"get-proto": "^1.0.1",
"get-tsconfig": "^4.10.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2",
"humanize-ms": "^1.2.1",
"ink": "^6.1.0",
"ink-text-input": "^6.0.0",
"math-intrinsics": "^1.1.0",
"mime-db": "^1.52.0",
"mime-types": "^2.1.35",
"ms": "^2.1.3",
"node-domexception": "^1.0.0",
"node-fetch": "^2.7.0",
"node-pty": "^1.0.0",
"openai": "^4.104.0",
"resolve-pkg-maps": "^1.0.0",
"terminal-kit": "^3.1.2",
"tr46": "^0.0.3",
"tsx": "^4.20.3",
"typescript": "^5.9.2",
"undici-types": "^5.26.5",
"web-streams-polyfill": "^4.0.0-beta.3",
"webidl-conversions": "^3.0.1",
"whatwg-url": "^5.0.0"
},
"scripts": {
"start": "node cli.js",
"start:ink": "tsx cli-ink.js",
@@ -64,5 +15,18 @@
"keywords": [],
"author": "",
"license": "ISC",
"description": ""
"description": "",
"dependencies": {
"chalk": "^5.5.0",
"dotenv": "^17.2.1",
"exa-js": "^1.8.27",
"ink": "^6.1.0",
"ink-text-input": "^6.0.0",
"node-pty": "^1.0.0",
"react": "^19.1.1",
"terminal-kit": "^3.1.2"
},
"devDependencies": {
"tsx": "^4.20.4"
}
}

359
plan.md Normal file
View File

@@ -0,0 +1,359 @@
You're on the right track! You want to create a **Roundcube plugin** that allows users to **enhance or rewrite the email body using an LLM**, by sending the current message content to a backend API (your LLM service), then replacing the textarea content with the response.
Heres a complete working example of such a plugin, called `llm_compose_helper`. It adds a button to the compose screen, sends the current message text to a configured URL via AJAX, and replaces the message body with the LLM-generated result.
---
## ✅ Goal
- Add a "Rewrite with AI" button in the compose window.
- On click: open a popup asking the user for a rewrite prompt/instructions.
- Submit both the current message body and the user prompt to the configured LLM API endpoint.
- Replace the `<textarea>` content with the returned text.
---
## 📁 Plugin Structure
```
plugins/
llm_compose_helper/
llm_compose_helper.php <-- Main plugin class
config.inc.php <-- Configuration file
localization/en_US.inc <-- Language labels
js/llm_compose_helper.js <-- Client-side JavaScript
```
---
## 1. `llm_compose_helper.php` Plugin Class
```php
<?php
/**
* LLM Compose Helper Plugin
*
* Allows rewriting the message body using an external LLM service
*
* @version 1.0
* @author You
* @license GPL
*/
class llm_compose_helper extends rcube_plugin
{
public $task = 'mail';
function init()
{
// Load configuration
$this->load_config();
// Load localization texts (available in JS if second param = true)
$this->add_texts('localization/', true);
// Only run in compose view
$rcmail = rcmail::get_instance();
if ($rcmail->action == 'compose') {
// Include JS and CSS
$this->include_script('js/llm_compose_helper.js');
// Register UI placeholder handler
$this->register_handler('plugin.llm_button', array($this, 'llm_button'));
}
}
/**
* Generate the button HTML to be inserted into the toolbar
*/
function llm_button()
{
$rcmail = rcmail::get_instance();
return html::tag('a', array(
'href' => '#',
'id' => 'llm_rewrite_button',
'class' => 'button',
'title' => $this->gettext('rewrite_with_llm'),
'onclick' => 'return false;'
), $this->gettext('rewrite_label'));
}
}
```
---
## 2. `config.inc.php` Plugin Configuration
```php
<?php
// URL of your LLM API endpoint
$config['llm_api_url'] = 'https://your-llm-api.example.com/rewrite';
// Optional: headers (e.g., auth tokens)
$config['llm_api_headers'] = array(
'Authorization: Bearer your-secret-token',
'Content-Type: application/json'
);
```
> 💡 You can later override this in `config/config.inc.php` globally.
---
## 3. `localization/en_US.inc`
```php
<?php
$labels = array(
'rewrite_with_llm' => 'Rewrite message with AI',
'rewrite_label' => 'AI Rewrite',
'enter_prompt' => 'Enter rewrite prompt',
'rewrite_submit' => 'Rewrite',
'rewrite_cancel' => 'Cancel',
);
$messages = array(
'prompt_placeholder' => 'e.g., Make it shorter and more formal',
);
```
> Add other language files like `fr_FR.inc`, `de_DE.inc`, etc., if needed.
---
## 4. `js/llm_compose_helper.js` Client-Side Logic
```javascript
// Wait for Roundcube to initialize
rcmail.addEventListener('init', function() {
const button = document.getElementById('llm_rewrite_button');
if (!button) return;
// Insert button into compose toolbar
const toolbar = rcmail.gui_objects.toolbar;
if (toolbar) {
toolbar.appendChild(button);
}
// Attach click event
$(button).click(function(e) {
e.preventDefault();
const textarea = rcmail.env.html_editor ? rcmail.editor : document.getElementById('composebody');
let messageText;
// Get current message body
if (rcmail.env.html_editor && rcmail.editor && rcmail.editor.getData) {
messageText = rcmail.editor.getData(); // CKEditor
} else {
messageText = $('#composebody').val();
}
if (!messageText || messageText.trim() === '') {
alert(rcmail.gettext('non_empty', 'llm_compose_helper'));
return;
}
// Build prompt dialog content
var promptId = 'llm_prompt_input_' + Date.now();
var dialogHtml = '<div style="padding:8px 0">' +
'<label for="' + promptId + '">' + rcmail.gettext('enter_prompt', 'llm_compose_helper') + '</label>' +
'<textarea id="' + promptId + '" style="width:100%;height:120px;box-sizing:border-box;margin-top:6px" placeholder="' + (rcmail.gettext('prompt_placeholder', 'llm_compose_helper') || '') + '"></textarea>' +
'</div>';
var buttons = [
{
text: rcmail.gettext('rewrite_submit', 'llm_compose_helper'),
classes: 'mainaction',
click: function(e, ref) {
var promptValue = document.getElementById(promptId).value || '';
// Show loading
rcmail.set_busy(true, 'loading');
// Send to LLM API with message and prompt
rcmail.http_post('plugin.llm_rewrite', {
message: messageText,
prompt: promptValue
}, function() {
rcmail.set_busy(false);
});
if (ref && ref.hide) ref.hide();
}
},
{
text: rcmail.gettext('rewrite_cancel', 'llm_compose_helper'),
click: function(e, ref) { if (ref && ref.hide) ref.hide(); }
}
];
// Open Roundcube dialog
rcmail.show_popup_dialog(dialogHtml, rcmail.gettext('rewrite_with_llm', 'llm_compose_helper'), buttons, {modal: true, width: 520});
});
});
// Handle response from server
rcmail.addEventListener('plugin.llm_rewrite_response', function(response) {
if (response.status === 'success' && response.text) {
const newText = response.text;
if (rcmail.env.html_editor && rcmail.editor && rcmail.editor.setData) {
rcmail.editor.setData(newText); // For CKEditor
} else {
$('#composebody').val(newText);
}
rcmail.showMessage(rcmail.gettext('rewrite_success', 'llm_compose_helper'), 'confirmation');
} else {
var errorMsg = response && response.message ? String(response.message) : rcmail.gettext('rewrite_error', 'llm_compose_helper');
rcmail.showMessage(errorMsg, 'error');
}
});
```
---
## 5. Extend `llm_compose_helper.php` Add Server-Side Action
Update the `llm_compose_helper.php` file to register the AJAX action and handle the request:
```php
function init()
{
$this->load_config();
$this->add_texts('localization/', true);
$rcmail = rcmail::get_instance();
if ($rcmail->action == 'compose') {
$this->include_script('js/llm_compose_helper.js');
$this->register_handler('plugin.llm_button', array($this, 'llm_button'));
// Register custom action for AJAX
$this->register_action('plugin.llm_rewrite', array($this, 'action_handler'));
}
}
function action_handler()
{
$rcmail = rcmail::get_instance();
// Get input
$message = rcube_utils::get_input_value('message', rcube_utils::INPUT_POST);
$prompt = rcube_utils::get_input_value('prompt', rcube_utils::INPUT_POST);
if (empty($message)) {
$rcmail->output->command('plugin.llm_rewrite_response', [
'status' => 'error',
'message' => 'No message provided'
]);
return;
}
// Get config
$api_url = $rcmail->config->get('llm_api_url');
if (!$api_url) {
$rcmail->output->command('plugin.llm_rewrite_response', [
'status' => 'error',
'message' => 'LLM API URL not configured'
]);
return;
}
$headers = $rcmail->config->get('llm_api_headers', ['Content-Type: application/json']);
// Prepare request
$data = json_encode(['text' => $message, 'prompt' => $prompt]);
// Use file_get_contents or cURL
$options = [
'http' => [
'header' => $headers,
'method' => 'POST',
'content' => $data,
'timeout' => 30
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
'cafile' => '/etc/ssl/certs/ca-certificates.crt', // Adjust as needed
]
];
$context = stream_context_create($options);
$result = file_get_contents($api_url, false, $context);
if ($result === false) {
$rcmail->output->command('plugin.llm_rewrite_response', [
'status' => 'error',
'message' => 'Request failed'
]);
return;
}
$response = json_decode($result, true);
$rewritten = isset($response['text']) && $response['text'] !== null ? $response['text'] : ($response['message'] ?? $result);
// Return success
$rcmail->output->command('plugin.llm_rewrite_response', [
'status' => 'success',
'text' => $rewritten
]);
}
```
---
## 6. Example LLM API Response Format (Expected)
Your external LLM endpoint should accept POST JSON:
```json
{ "text": "Hello, I am writing to ask about...", "prompt": "Make it shorter and more formal" }
```
And return:
```json
{ "text": "Hi, I hope you're doing well. I'm reaching out to inquire about..." }
```
Ensure CORS is allowed if hosted separately.
---
## 7. Optional: Add More Labels
In `localization/en_US.inc`, add:
```php
$messages = array(
'rewrite_success' => 'Message rewritten successfully!',
'rewrite_error' => 'Failed to rewrite message.',
'non_empty' => 'Please write something before using AI rewrite.'
);
```
---
## 8. Enable the Plugin
In `config/config.inc.php`:
```php
$config['plugins'] = array(
// ... other plugins
'llm_compose_helper'
);
```
---
## ✅ Final Result
When composing or replying:
- A new button labeled "AI Rewrite" appears in the toolbar.
- Clicking it opens a dialog asking for a rewrite prompt.
- Submitting sends the current message and the prompt to your LLM API.
- The response replaces the message body.
- Works with plain text and HTML (via CKEditor if enabled).

1
rc Normal file
View File

@@ -0,0 +1 @@
export PS1="bash> "

View File

@@ -30,24 +30,31 @@ class TerminalService extends EventEmitter {
env: {
...process.env,
TERM: 'xterm-256color',
PS1: '> '
PS1: 'bash> '
},
});
this.ptyProcess.onData((data) => {
// Normalize line endings
const normalized = String(data).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const parts = normalized.split('\n');
// First part joins the existing partial
if (parts.length > 0) {
this.partial += parts[0];
}
// For each subsequent part before the last, we have completed lines
for (let i = 1; i < parts.length; i += 1) {
this.lines.push(this.partial);
this.partial = parts[i];
const str = String(data);
// Normalize CRLF to LF to avoid double-handling \r and \n
const normalized = str.replace(/\r\n/g, '\n');
for (let i = 0; i < normalized.length; i += 1) {
const ch = normalized[i];
if (ch === '\n') {
// Line feed completes the current line
this.lines.push(this.partial);
this.partial = '';
} else if (ch === '\r') {
// Standalone carriage return: simulate return to start of line (overwrite)
this.partial = '';
} else if (ch === '\b' || ch === '\x7f') {
// Backspace or DEL: remove last char if present
if (this.partial.length > 0) {
this.partial = this.partial.slice(0, -1);
}
} else {
this.partial += ch;
}
}
// Enforce max lines buffer
@@ -111,10 +118,16 @@ class TerminalService extends EventEmitter {
this.ptyProcess.kill();
this.ptyProcess = null;
}
this.started = false;
} catch {
// ignore
}
}
restart() {
try { this.dispose(); } catch {}
try { this.start(); } catch {}
}
}
const terminalService = new TerminalService();

View File

@@ -1,9 +1,34 @@
import React from 'react';
import { Box, Text } from 'ink';
import uiService from './uiService.js';
import TextInput from 'ink-text-input';
import terminalService from '../terminalService.js';
import ModelDialog from '../../modelDialog.js';
const sharedModelDialog = new ModelDialog();
const npmSpinnerFrames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
class Pane extends React.Component {
constructor(props) {
super(props);
this.state = {
cursorVisible: true,
};
this._cursorTimer = null;
}
componentDidMount() {
if (this.props.showCursor) {
this._cursorTimer = setInterval(() => {
this.setState((s) => ({ cursorVisible: !s.cursorVisible }));
}, typeof this.props.cursorBlinkMs === 'number' && this.props.cursorBlinkMs > 0 ? this.props.cursorBlinkMs : 500);
}
}
componentWillUnmount() {
if (this._cursorTimer) {
clearInterval(this._cursorTimer);
this._cursorTimer = null;
}
}
// Strip ANSI escape sequences so width measurement/truncation is accurate
stripAnsi(input) {
if (input == null) return '';
@@ -35,6 +60,24 @@ class Pane extends React.Component {
}
return out;
}
// Apply a blinking cursor to the given line according to width constraints
withCursor(line, maxWidth) {
const cursorChar = typeof this.props.cursorChar === 'string' && this.props.cursorChar.length > 0 ? this.props.cursorChar[0] : '█';
const visible = !!this.state.cursorVisible;
if (typeof maxWidth !== 'number' || maxWidth <= 0) {
return line;
}
// Place cursor at the logical end of content, clamped to last column
const width = maxWidth;
const base = (line || '').slice(0, width);
const cursorIndex = Math.min(base.length, width - 1);
const targetLen = Math.min(width, cursorIndex + 1);
const padLen = Math.max(0, targetLen - base.length);
const padded = padLen > 0 ? `${base}${' '.repeat(padLen)}` : base.slice(0, targetLen);
const chars = padded.split('');
chars[cursorIndex] = visible ? cursorChar : ' ';
return chars.join('');
}
render() {
const { title, lines, maxWidth } = this.props;
return (
@@ -42,20 +85,17 @@ class Pane extends React.Component {
<Text color="cyan">{title}</Text>
<Box flexDirection="column" width="100%" flexShrink={1} minWidth={0}>
{(lines && lines.length > 0)
? lines.map((line, index) => (
<Text key={index}>{
(() => {
const clean = this.stripAnsi(line);
const width = typeof maxWidth === 'number' && maxWidth > 0 ? maxWidth : undefined;
// Expand tabs before slicing to visual width
const expanded = this.expandTabs(clean, 8, width);
if (width && expanded.length > width) {
return expanded.slice(0, width);
}
return expanded;
})()
}</Text>
))
? lines.map((line, index) => {
const isLast = index === lines.length - 1;
const width = typeof maxWidth === 'number' && maxWidth > 0 ? maxWidth : undefined;
const clean = this.stripAnsi(line);
const expanded = this.expandTabs(clean, 8, width);
const baseLine = (width && expanded.length > width) ? expanded.slice(0, width) : expanded;
const finalLine = (this.props.showCursor && isLast) ? this.withCursor(baseLine, width) : baseLine;
return (
<Text key={index}>{finalLine}</Text>
);
})
: <Text dimColor></Text>
}
</Box>
@@ -72,7 +112,14 @@ export default class InkApp extends React.Component {
logs: [],
terminal: [],
chainOfThought: [],
llmOutput: []
llmOutput: [],
menuOpen: false,
menuIndex: 0,
model: 'gpt-5',
reasoningEffort: 'minimal',
outputVerbosity: 'low',
isLoading: false,
spinnerIndex: 0
};
this.handleSubmit = this.handleSubmit.bind(this);
this.handleChange = this.handleChange.bind(this);
@@ -80,6 +127,11 @@ export default class InkApp extends React.Component {
this.setChainOfThought = this.setChainOfThought.bind(this);
this.setTerminal = this.setTerminal.bind(this);
this.setLogs = this.setLogs.bind(this);
this.toggleMenu = this.toggleMenu.bind(this);
this.onKeypress = this.onKeypress.bind(this);
this.menuAction = this.menuAction.bind(this);
this.getModelSettingsItems = this.getModelSettingsItems.bind(this);
this.handleModelSettingAdjust = this.handleModelSettingAdjust.bind(this);
}
componentDidMount() {
@@ -104,6 +156,18 @@ export default class InkApp extends React.Component {
process.stdout.on('resize', this.onResize);
}
this.onResize();
// Keyboard handling for menu
if (process.stdin && process.stdin.on) {
try { process.stdin.setRawMode(true); } catch {}
process.stdin.on('data', this.onKeypress);
}
// spinner timer
this._spinnerTimer = setInterval(() => {
if (this.state.isLoading) {
this.setState((s) => ({ spinnerIndex: (s.spinnerIndex + 1) % npmSpinnerFrames.length }));
}
}, 80);
}
componentWillUnmount() {
if (this.terminalUnsub) {
@@ -114,6 +178,13 @@ export default class InkApp extends React.Component {
process.stdout.off('resize', this.onResize);
this.onResize = null;
}
if (process.stdin && process.stdin.off) {
process.stdin.off('data', this.onKeypress);
}
if (this._spinnerTimer) {
clearInterval(this._spinnerTimer);
this._spinnerTimer = null;
}
}
setPaneLines(stateKey, lines) {
@@ -150,21 +221,168 @@ export default class InkApp extends React.Component {
this.setState({ input: value });
}
handleSubmit() {
async handleSubmit() {
const { input } = this.state;
if (!input) return;
try {
terminalService.write(`${input}\r`);
} catch (e) {
// do not hide errors; show in logs
this.setState((state) => ({
logs: [...state.logs, `! write error: ${String(e && e.message ? e.message : e)}`],
}));
}
this.setState((state) => ({
logs: [...state.logs, `> ${input}`],
input: ''
input: '',
isLoading: true
}));
try {
const result = await sharedModelDialog.interrogate(input);
const finalOutput = Array.isArray(result && result.output) ? result.output : [String(result && result.output ? result.output : '')];
const finalReasoning = Array.isArray(result && result.reasoning) ? result.reasoning : (result && result.reasoning ? [String(result.reasoning)] : []);
// Append to LLM output with a separator, overwrite chain of thought
this.setState((state) => ({
llmOutput: [
...state.llmOutput,
...(state.llmOutput.length ? ['----------'] : []),
...finalOutput
]
}));
this.setChainOfThought(finalReasoning);
this.setState((state) => ({
logs: [
...state.logs,
`tokens input: ${JSON.stringify(result && result.inputTokens)}`,
`tokens cached: ${JSON.stringify(result && result.cachedTokens)}`,
`tokens output: ${JSON.stringify(result && result.outputTokens)}`
]
}));
} catch (e) {
this.setState((state) => ({
logs: [...state.logs, `! interrogate error: ${String(e && e.message ? e.message : e)}`]
}));
} finally {
this.setState({ isLoading: false });
}
}
toggleMenu(open) {
this.setState((s) => ({ menuOpen: typeof open === 'boolean' ? open : !s.menuOpen }));
}
onKeypress(buf) {
const data = Buffer.isBuffer(buf) ? buf : Buffer.from(String(buf));
// ESC [ A => Up arrow
const isUp = data.length >= 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x41;
const isDown = data.length >= 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x42;
const isEnter = data.length === 1 && data[0] === 0x0d;
const isLeft = data.length >= 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x44;
const isRight = data.length >= 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x43;
const isCtrlC = data.length === 1 && data[0] === 0x03;
if (!this.state.menuOpen) {
if (isUp) {
this.toggleMenu(true);
return;
}
// let Ink TextInput handle normal typing; we don't intercept here
if (isCtrlC) {
// Pass through to exit behavior handled in cli-ink.js
return;
}
return;
}
// Submenu: Model settings adjustments
if (this.state.menuOpen && this.state.menuMode === 'model') {
const items = this.getModelSettingsItems();
if (isUp) {
this.setState((s) => ({ menuIndex: (s.menuIndex - 1 + items.length) % items.length }));
return;
}
if (isDown) {
this.setState((s) => ({ menuIndex: (s.menuIndex + 1) % items.length }));
return;
}
if (isLeft || isRight) {
const idx = this.state.menuIndex;
const dir = isRight ? 1 : -1;
this.handleModelSettingAdjust(items[idx].key, dir);
return;
}
if (isEnter) {
// Enter exits model submenu back to main menu
this.setState({ menuMode: undefined, menuIndex: 0 });
return;
}
return;
}
// Menu navigation (main menu)
const items = this.getMenuItems();
if (isUp) {
this.setState((s) => ({ menuIndex: (s.menuIndex - 1 + items.length) % items.length }));
return;
}
if (isDown) {
this.setState((s) => ({ menuIndex: (s.menuIndex + 1) % items.length }));
return;
}
if (isEnter) {
const idx = this.state.menuIndex;
this.menuAction(items[idx]);
return;
}
}
getMenuItems() {
return [
'Send CTRL-C to terminal',
'Restart Terminal',
'Model settings',
'Exit the app',
'Close menu'
];
}
menuAction(label) {
switch (label) {
case 'Send CTRL-C to terminal':
try { terminalService.write('\x03'); } catch {}
break;
case 'Restart Terminal':
try { terminalService.restart(); } catch {}
break;
case 'Model settings':
// Toggle a sub-menu state
this.setState({ menuMode: 'model', menuIndex: 0 });
break;
case 'Exit the app':
try { process.exit(0); } catch {}
break;
case 'Close menu':
this.toggleMenu(false);
break;
default:
break;
}
}
getModelSettingsItems() {
return [
{ key: 'model', label: 'Model', options: ['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano'] },
{ key: 'reasoningEffort', label: 'Reasoning effort', options: ['minimal', 'low', 'medium', 'high'] },
{ key: 'outputVerbosity', label: 'Output verbosity', options: ['low', 'medium', 'high'] },
{ key: 'back', label: 'Back to main menu' }
];
}
handleModelSettingAdjust(key, dir) {
if (key === 'back') {
this.setState({ menuMode: undefined, menuIndex: 0 });
return;
}
const items = this.getModelSettingsItems();
const item = items.find((i) => i.key === key);
if (!item || !item.options) return;
const currentValue = this.state[key];
const idx = item.options.indexOf(currentValue);
const nextIdx = ((idx === -1 ? 0 : idx) + dir + item.options.length) % item.options.length;
const nextValue = item.options[nextIdx];
this.setState({ [key]: nextValue });
}
render() {
@@ -193,6 +411,9 @@ export default class InkApp extends React.Component {
const chainOfThoughtView = sliceLast(chainOfThought);
const terminalView = sliceLast(terminal);
const logsView = sliceLast(logs);
const menuItems = this.getMenuItems();
const selected = this.state.menuIndex;
return (
<Box flexDirection="column" height="100%">
<Box flexGrow={1} flexDirection="row" minWidth={0}>
@@ -201,18 +422,43 @@ export default class InkApp extends React.Component {
<Pane title="Chain of Thought" lines={chainOfThoughtView} maxWidth={paneContentWidth} />
</Box>
<Box flexGrow={1} flexDirection="column" minWidth={0}>
<Pane title="Terminal" lines={terminalView} maxWidth={paneContentWidth} />
<Pane title="Terminal" lines={terminalView} maxWidth={paneContentWidth} showCursor cursorBlinkMs={600} />
<Pane title="Logging" lines={logsView} maxWidth={paneContentWidth} />
</Box>
</Box>
{this.state.menuOpen && this.state.menuMode !== 'model' && (
<Box borderStyle="round" paddingX={1} paddingY={0} marginTop={1} flexDirection="column">
<Text color="yellow">Main Menu (Up/Down to navigate, Enter to select)</Text>
{menuItems.map((label, i) => (
<Text key={label} color={i === selected ? 'cyan' : undefined}>
{i === selected ? ' ' : ' '}{label}
</Text>
))}
</Box>
)}
{this.state.menuOpen && this.state.menuMode === 'model' && (
<Box borderStyle="round" paddingX={1} paddingY={0} marginTop={1} flexDirection="column">
<Text color="yellow">Model Settings (Up/Down select, Left/Right change, Enter back)</Text>
{this.getModelSettingsItems().map((item, i) => (
<Text key={item.key || item.label} color={i === selected ? 'cyan' : undefined}>
{i === selected ? ' ' : ' '}
{item.label}{item.options ? `: ${this.state[item.key]}` : ''}
</Text>
))}
</Box>
)}
<Box marginTop={1}>
<Text>Input: </Text>
<TextInput
value={input}
onChange={this.handleChange}
onSubmit={this.handleSubmit}
placeholder="Type and press Enter..."
/>
{this.state.isLoading ? (
<Text color="yellow">{npmSpinnerFrames[this.state.spinnerIndex]} Processing...</Text>
) : (
<TextInput
value={input}
onChange={this.handleChange}
onSubmit={this.handleSubmit}
placeholder="Type and press Enter..."
/>
)}
</Box>
</Box>
);

9
src/ui/uiService.js Normal file
View File

@@ -0,0 +1,9 @@
import EventEmitter from 'events';
class UIService extends EventEmitter {}
const uiService = new UIService();
export default uiService;

2
todo.md Normal file
View File

@@ -0,0 +1,2 @@
return the function call result via event.
display function call evenst in logging

View File

@@ -1,7 +1,7 @@
import { promises as fs } from "node:fs";
import path from "node:path";
// Utility to normalize and validate paths within chroot
// Utility to normalize and validate paths within a contained root directory
const normalizePath = (inputPath, chrootDir) => {
// Resolve chroot directory
const chrootResolved = path.resolve(chrootDir);
@@ -17,12 +17,19 @@ const normalizePath = (inputPath, chrootDir) => {
// Ensure the path is within chrootDir
if (!normalized.startsWith(chrootResolved)) {
throw new Error(`Path escapes chroot boundary: ${inputPath}`);
throw new Error(`Path escapes root boundary: ${inputPath}`);
}
return normalized;
};
// Convert an absolute path under the contained root to a user-display path (root-relative)
const toDisplayPath = (absPath, chrootDir) => {
const rel = path.relative(path.resolve(chrootDir), absPath);
if (!rel || rel === "") return "/";
return `/${rel}`;
};
// Main recursive directory listing function
async function listEntriesRecursive(startDir, chrootDir, maxDepth, includeHidden = false) {
const results = [];
@@ -35,7 +42,7 @@ async function listEntriesRecursive(startDir, chrootDir, maxDepth, includeHidden
try {
dirents = await fs.readdir(currentDir, { withFileTypes: true });
} catch (err) {
throw new Error(`Failed to read directory: ${currentDir} (${err?.message || String(err)})`);
throw new Error(`Failed to read directory: ${toDisplayPath(currentDir, chrootDir)}`);
}
for (const dirent of dirents) {
@@ -91,39 +98,39 @@ async function listEntriesRecursive(startDir, chrootDir, maxDepth, includeHidden
export default {
type: "function",
name: "list_files",
description: "List files and directories recursively within a chroot directory with customizable options",
description: "List files and directories recursively from the root with customizable options",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Directory or file path to list relative to chroot. Use '/' for the chroot root. Defaults to chroot root if not specified.",
type: ["string", "null"],
description: "Directory or file path relative to the root. Use '/' for the root. Defaults to root if not specified.",
},
depth: {
type: "integer",
type: ["integer", "null"],
description: "Maximum subdirectory levels to traverse. Use -1 for unlimited depth. Defaults to 1.",
minimum: -1,
},
includeHidden: {
type: "boolean",
type: ["boolean", "null"],
description: "Whether to include hidden files and directories (starting with '.'). Defaults to false.",
default: false,
}
},
required: ["path", "depth", "includeHidden"],
required: [],
additionalProperties: false,
},
strict: true,
strict: false,
};
export async function run(args) {
const inputPath = args?.path || "";
const depth = Number.isInteger(args?.depth) ? args.depth : 1;
const includeHidden = args?.includeHidden ?? false;
const chrootPath = '/workspaces/aiTools';
const chrootPath = '/workspaces/aiTools/root';
if (!chrootPath) {
return { err: "Chroot path is required" };
return { err: "Root path is required" };
}
if (depth < -1) {
return { err: `Depth must be >= -1, received ${args?.depth}` };
@@ -135,7 +142,7 @@ export async function run(args) {
// Auto-create the chroot base directory if it does not exist
await fs.mkdir(chrootResolved, { recursive: true });
} catch (err) {
return { err: `Failed to prepare chroot path: ${chrootPath} (${err?.message || String(err)})` };
return { err: "Failed to initialize root directory" };
}
let resolvedBase;
@@ -149,10 +156,13 @@ export async function run(args) {
try {
stat = await fs.lstat(resolvedBase);
} catch (err) {
return { err: `Path does not exist: ${resolvedBase} (${err?.message || String(err)})` };
return { err: `Path does not exist${inputPath ? `: ${inputPath}` : ""}` };
}
const cwd = path.relative(chrootResolved, stat.isFile() ? path.dirname(resolvedBase) : resolvedBase) || ".";
const cwd = toDisplayPath(
stat.isFile() ? path.dirname(resolvedBase) : resolvedBase,
chrootResolved
);
// Handle single file case
if (stat.isFile()) {
@@ -166,7 +176,7 @@ export async function run(args) {
// Handle non-directory case
if (!stat.isDirectory()) {
return { err: `Not a file or directory: ${resolvedBase}` };
return { err: `Not a file or directory${inputPath ? `: ${inputPath}` : ""}` };
}
// Handle directory case
@@ -181,6 +191,6 @@ export async function run(args) {
files: mapped,
};
} catch (err) {
return { err: `Failed to list files: ${err?.message || String(err)}` };
return { err: "Failed to list files" };
}
}

View File

@@ -1,12 +1,14 @@
#!/usr/bin/env node
import chalk from 'chalk';
const desc = `
This is a custom utility that makes it more convenient to add, remove, move, or edit code files. 'apply_patch' effectively allows you to execute a diff/patch against a file,
but the format of the diff specification is unique to this task, so pay careful attention to these instructions.
To use the 'apply_patch' command, you should pass a message of the following structure as "input":
*** Begin Patch
[YOUR_PATH]
[YOUR_PATCH]
*** End Patch
Where [YOUR_PATCH] is the actual content of your patch, specified in the following V4A diff format.
@@ -36,8 +38,17 @@ For instructions on [context_before] and [context_after]:
+ [new_code]
[3 lines of post-context]
Note, then, that we do not use line numbers in this diff format, as the context is enough to uniquely identify code. An example of a message that you might pass as "input" to this function, in order to apply a patch, is shown below.
`;
Note, then, that we do not use line numbers in this diff format, as the context is enough to uniquely identify code.
Complese Example:
*** Begin Patch
*** Add File: /test.js
+ function method() {
+ console.log("Hello, world!");
+ }
*** End Patch
`;
// --------------------------------------------------------------------------- //
// Domain objects
// --------------------------------------------------------------------------- //
@@ -117,10 +128,10 @@ function resolvePath(chroot, filepath) {
const root = normalizePath(chroot);
// If file is absolute, use it as-is (after normalization).
// We assume the caller ensures it is inside the chroot.
// If file is absolute, resolve it under the chroot rather than using host FS root
if (file.startsWith('/')) {
return file;
const resolvedAbs = joinPaths(root, file);
return resolvedAbs.startsWith('/') ? resolvedAbs : '/' + resolvedAbs;
}
// If file is relative, join with chroot
@@ -229,9 +240,9 @@ class Parser {
if (path) {
// Resolve path with chroot
const resolvedPath = resolvePath(this.chroot, path);
if (resolvedPath in this.patch.actions) {
throw new DiffError(`Duplicate update for file: ${resolvedPath}`);
// Use user-specified path as the key to avoid double-resolving later
if (path in this.patch.actions) {
throw new DiffError(`Duplicate update for file: ${path}`);
}
const move_to = this.read_str("*** Move to: ");
if (!(resolvedPath in this.current_files)) {
@@ -239,8 +250,8 @@ class Parser {
}
const text = this.current_files[resolvedPath];
const action = this._parse_update_file(text);
action.move_path = move_to ? resolvePath(this.chroot, move_to) : null;
this.patch.actions[resolvedPath] = action;
action.move_path = move_to ? move_to : null;
this.patch.actions[path] = action;
continue;
}
@@ -249,14 +260,13 @@ class Parser {
if (path) {
// Resolve path with chroot
const resolvedPath = resolvePath(this.chroot, path);
if (resolvedPath in this.patch.actions) {
throw new DiffError(`Duplicate delete for file: ${resolvedPath}`);
if (path in this.patch.actions) {
throw new DiffError(`Duplicate delete for file: ${path}`);
}
if (!(resolvedPath in this.current_files)) {
throw new DiffError(`Delete File Error - missing file: ${resolvedPath}`);
throw new DiffError(`Delete File Error - missing file: ${path}`);
}
this.patch.actions[resolvedPath] = new PatchAction(ActionType.DELETE);
this.patch.actions[path] = new PatchAction(ActionType.DELETE);
continue;
}
@@ -265,14 +275,13 @@ class Parser {
if (path) {
// Resolve path with chroot
const resolvedPath = resolvePath(this.chroot, path);
if (resolvedPath in this.patch.actions) {
throw new DiffError(`Duplicate add for file: ${resolvedPath}`);
if (path in this.patch.actions) {
throw new DiffError(`Duplicate add for file: ${path}`);
}
if (resolvedPath in this.current_files) {
throw new DiffError(`Add File Error - file already exists: ${resolvedPath}`);
throw new DiffError(`Add File Error - file already exists: ${path}`);
}
this.patch.actions[resolvedPath] = this._parse_add_file();
this.patch.actions[path] = this._parse_add_file();
continue;
}
@@ -361,7 +370,9 @@ class Parser {
if (!s.startsWith("+")) {
throw new DiffError(`Invalid Add File line (missing '+'): ${s}`);
}
lines.push(s.substring(1)); // strip leading '+'
// Strip leading '+' and ignore a single optional space immediately after '+'
const content = s.substring(1).replace(/^ /, "");
lines.push(content);
}
return new PatchAction(ActionType.ADD, lines.join("\n"));
}
@@ -603,10 +614,11 @@ function _get_updated_file(text, action, path) {
function patch_to_commit(patch, orig, chroot = null) {
const commit = new Commit();
for (const [path, action] of Object.entries(patch.actions)) {
const resolvedPath = resolvePath(chroot, path);
if (action.type === ActionType.DELETE) {
commit.changes[path] = new FileChange(
ActionType.DELETE,
orig[path],
orig[resolvedPath],
null,
null
);
@@ -621,11 +633,11 @@ function patch_to_commit(patch, orig, chroot = null) {
null
);
} else if (action.type === ActionType.UPDATE) {
const new_content = _get_updated_file(orig[path], action, path);
const new_content = _get_updated_file(orig[resolvedPath], action, path);
const move_path = action.move_path ? unresolvePath(chroot, action.move_path) : null;
commit.changes[path] = new FileChange(
ActionType.UPDATE,
orig[path],
orig[resolvedPath],
new_content,
move_path
);
@@ -721,20 +733,23 @@ function load_files(paths, open_fn) {
function apply_commit(commit, write_fn, remove_fn, chroot = null) {
for (const [path, change] of Object.entries(commit.changes)) {
if (change.type === ActionType.DELETE) {
remove_fn(path);
const target = resolvePath(chroot, path);
remove_fn(target);
} else if (change.type === ActionType.ADD) {
if (change.new_content === null) {
throw new DiffError(`ADD change for ${path} has no content`);
}
write_fn(path, change.new_content);
const target = resolvePath(chroot, path);
write_fn(target, change.new_content);
} else if (change.type === ActionType.UPDATE) {
if (change.new_content === null) {
throw new DiffError(`UPDATE change for ${path} has no new content`);
}
const target = change.move_path ? resolvePath(chroot, change.move_path) : path;
const target = change.move_path ? resolvePath(chroot, change.move_path) : resolvePath(chroot, path);
write_fn(target, change.new_content);
if (change.move_path) {
remove_fn(path);
const source = resolvePath(chroot, path);
remove_fn(source);
}
}
}
@@ -808,10 +823,11 @@ export async function run(args) {
open_file,
write_file,
remove_file,
'/workspaces/aiTools'
'/workspaces/aiTools/root'
);
return result;
} catch (error) {
console.log(chalk.red('Patch error:'),error);
return `Patch error: ${error.message}`
}
}

View File

@@ -1,19 +1,19 @@
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
import fs from "node:fs";
import path from "node:path";
const virtual_chroot = '/workspaces/aiTools';
const virtual_chroot = '/workspaces/aiTools/root';
// Ensures reads are confined to `virtual_chroot`.
export default {
type: "function", name: "read_file", description: "read a file", strict: true,
type: "function", name: "read_file", description: "read a file", strict: false,
parameters: {
type: "object", required: ["path","linesToSkip","linesToRead"], additionalProperties: false, properties: {
type: "object", required: ["path"], additionalProperties: false, properties: {
path: { type: "string", description: "The path to the file to read.", },
linesToSkip: { type: "integer", description: "The number of lines to skip. Use 0 to read from the beginning.", minimum: 0 },
linesToRead: { type: "integer", description: "1-400 The number of lines to read. Use 0 or more than 400 to read 400 lines.", minimum: 0 }
linesToSkip: { type: ["integer", "null"], description: "The number of lines to skip. Use 0 to read from the beginning, which is the default.", minimum: 0 },
linesToRead: { type: ["integer", "null"], description: "1-400 The number of lines to read. 400 is the default.", minimum: 1, maximum: 400 }
}
}
};
@@ -29,6 +29,11 @@ export async function run(args) {
// Normalize linesToRead (1-400, with 0 or >400 meaning 400)
const maxLines = (linesToRead <= 0 || linesToRead > 400) ? 400 : linesToRead;
// check if the file exists
if (!fs.existsSync(fullPath)) {
return `read_file error: File does not exist`;
}
try {
@@ -51,7 +56,7 @@ export async function run(args) {
}
}
return lines.join('\n');
return 'Filecontent: ´´´'+lines.join('\n')+'´´´';
} catch (error) {
return `read_file error: ${error.message}`;
}

View File

@@ -1,6 +1,6 @@
import { spawnSync } from "node:child_process";
const virtual_chroot = '/workspaces/aiTools';
const virtual_chroot = '/workspaces/aiTools/root';
export default {
type: "function", name: "ripgrep", strict: true,

View File

@@ -7,7 +7,10 @@ export async function run(args){
const buffer = await res.buffer();
const filename = new Date().getTime() + '.' + url.split('.').pop();
const content = buffer.slice(0, 500).toString('utf8');
return { filename, content };
// save the file to the chroot
const filePath = `/workspaces/aiTools/root/${filename}`;
fs.writeFileSync(filePath, content);
return { 'Downloaded to:': filename };
};
// metadata for the tool runner