Compare commits

..

28 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
sebseb7
0815d64802 Add ESLint configuration files and VSCode settings for improved code quality and development experience 2025-08-12 20:54:46 +00:00
sebseb7
b515275407 Enhance CLI terminal integration and output handling in InkApp. Introduce terminalService for managing PTY backend, including resizing and updating terminal output. Implement ANSI stripping and tab expansion for accurate line rendering. Improve state management for terminal, logs, and LLM output, ensuring responsive UI updates and error handling during input submission. 2025-08-12 18:34:07 +00:00
sebseb7
ac09e4ed08 Update CLI scripts to enhance functionality and error handling. Change shebang in cli-ink.js for improved compatibility with TypeScript. Modify websearch.js to utilize EXA_API_KEY from environment variables, adding error handling for missing keys. Refactor wget.js to use ES module syntax and improve filename generation for downloaded content. 2025-08-12 05:44:17 +00:00
sebseb7
62e9754ab0 Refactor CLI output formatting in cli.js to enhance readability by adding line breaks. Comment out verbose logging for OpenAI calls and error handling to reduce console clutter. Update patch_files.js description for clarity on functionality. 2025-08-12 04:36:15 +00:00
sebseb7
ff3accdc76 Refactor file listing in list_files.js to return structured objects for easier machine parsing and consistent output. Update sorting mechanism to ensure files are sorted by path before returning. 2025-08-12 04:19:25 +00:00
sebseb7
60e288454c Refactor CLI input handling in cli.js to streamline the input structure and enhance the OpenAI call process. Introduce a new variable for previous response tracking and update file listing in list_files.js to return sorted file entries directly, improving output consistency. 2025-08-12 04:14:42 +00:00
sebseb7
324aea5775 Update chroot path in multiple tools to use '/workspaces/aiTools' for consistent file handling across the codebase. 2025-08-12 03:50:16 +00:00
sebseb7
182ccd34ca Adjust text verbosity and reasoning effort in CLI configuration for improved output clarity and performance. 2025-08-12 03:48:30 +00:00
sebseb7
7a6c2488da Remove obsolete systemprompt.txt file to streamline the codebase and eliminate unnecessary components. 2025-08-12 03:42:35 +00:00
sebseb7
073d027880 Enhance CLI assistant instructions in cli.js to include detailed tool usage guidelines for improved user interaction. Update input structure to better facilitate user commands and clarify available functionalities. 2025-08-12 03:42:09 +00:00
sebseb7
70fe6fccdb Refactor CLI input handling to ensure userText is passed correctly and improve output formatting in cli.js. Update package.json and package-lock.json to include 'exa-js' dependency for enhanced functionality. Revise systemprompt.txt to clarify tool usage and streamline instructions. 2025-08-12 03:38:23 +00:00
29 changed files with 6576 additions and 714 deletions

3
.devcontainer.json Normal file
View File

@@ -0,0 +1,3 @@
{
"image": "devpit:latest"
}

32
.eslintrc.json Normal file
View File

@@ -0,0 +1,32 @@
{
"root": true,
"env": {
"es2022": true,
"node": true,
"browser": true
},
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"plugins": [],
"extends": [
"eslint:recommended"
],
"ignorePatterns": [
"node_modules/",
"out1",
"dist/",
"build/"
],
"overrides": [
{
"files": ["**/*.jsx"],
"rules": {}
}
]
}

3
.gitignore vendored
View File

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

14
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"command": "npm start",
"name": "Run npm start",
"request": "launch",
"type": "node-terminal"
}
]
}

9
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"eslint.experimental.useFlatConfig": true,
"eslint.workingDirectories": [
{
"mode": "auto"
}
]
}

15
cli-ink.js Normal file → Executable file
View File

@@ -1,8 +1,12 @@
#!/usr/bin/env node #!/usr/bin/env -S node --import tsx
import 'dotenv/config'; import 'dotenv/config';
import React from 'react'; import React from 'react';
import { render } from 'ink'; import { render } from 'ink';
import InkApp from './src/ui/InkApp.jsx'; import InkApp from './src/ui/InkApp.jsx';
import terminalService from './src/terminalService.js';
// Start the PTY backend independent from UI lifecycle
terminalService.start();
const { unmount } = render(React.createElement(InkApp)); const { unmount } = render(React.createElement(InkApp));
@@ -14,6 +18,7 @@ if (process.stdin.isTTY) {
const exitCleanly = () => { const exitCleanly = () => {
unmount(); unmount();
try { terminalService.dispose(); } catch { }
try { process.stdin.setRawMode(false); } catch { } try { process.stdin.setRawMode(false); } catch { }
process.exit(0); process.exit(0);
}; };
@@ -21,6 +26,10 @@ if (process.stdin.isTTY) {
const onData = (data) => { const onData = (data) => {
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(String(data)); const buffer = Buffer.isBuffer(data) ? data : Buffer.from(String(data));
for (const byte of buffer) { for (const byte of buffer) {
// Ctrl-C (ETX)
if (byte === 0x03) {
return exitCleanly();
}
if (!escPending) { if (!escPending) {
if (byte === 0x1b) { // ESC if (byte === 0x1b) { // ESC
escPending = true; escPending = true;
@@ -42,6 +51,10 @@ if (process.stdin.isTTY) {
}; };
process.stdin.on('data', onData); process.stdin.on('data', onData);
// Also handle SIGINT in case raw mode changes or comes from elsewhere
const onSigint = () => exitCleanly();
process.on('SIGINT', onSigint);
} }

73
cli.js
View File

@@ -6,15 +6,25 @@ import terminalKit from 'terminal-kit';
//npm install tiktoken //npm install tiktoken
//csk-8jftdte6r6vf8fdvp9xkyek5t3jnc6jfhh93d3ewfcwxxvh9 //csk-8jftdte6r6vf8fdvp9xkyek5t3jnc6jfhh93d3ewfcwxxvh9
import { promises as fs } from "node:fs"; import { promises as fs, unwatchFile } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import path from "node:path"; 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) { function renderUsage(usage) {
const inputTokens = usage.input_tokens - usage.input_tokens_details.cached_tokens; const inputTokens = usage.input_tokens - usage.input_tokens_details.cached_tokens;
const cacheTokens = usage.input_tokens_details.cached_tokens; const cacheTokens = usage.input_tokens_details.cached_tokens;
const outputToken = usage.output_tokens; const outputToken = usage.output_tokens;
console.log(' Cost', inputTokens, cacheTokens, outputToken); console.log('\nCost', inputTokens, cacheTokens, outputToken);
} }
function printIndented(indentNum, ...args) { function printIndented(indentNum, ...args) {
@@ -48,6 +58,7 @@ term.on('key', (name) => {
async function askUserForInput() { async function askUserForInput() {
term.cyan("Enter your request: "); term.cyan("Enter your request: ");
const input = await term.inputField({ mouse: false }).promise; const input = await term.inputField({ mouse: false }).promise;
console.log('\n');
return input; return input;
} }
@@ -65,38 +76,50 @@ async function loadTools() {
); );
return Object.fromEntries(toolEntries); return Object.fromEntries(toolEntries);
} }
let counter = 0;
let previousResponseId;
while(true){ while(true){
let counter = 0;
// Block for user input before kicking off the LLM loop // Block for user input before kicking off the LLM loop
const userText = await askUserForInput(); const userText = await askUserForInput();
await streamOnce(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }), userText || ''); 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) { async function streamOnce(openai, userText) {
const toolsByFile = await loadTools(); const toolsByFile = await loadTools();
let previousResponseId;
let input = [
{"role": "developer", "content": [ {"type": "input_text","text": `You are an interactive CLI AI assistant. Follow the user's instructions.` }] },
{"role": "user", "content": [ {"type": "input_text","text": userText } ]},
]
while(input.length > 0){ const systemprompt = {"role": "developer", "content": [ {
"type": "input_text","text": `You are an interactive CLI AI assistant. Follow the user's instructions.
If a tool is available and relevant, plan to use it.
Tools:
list_files - (no/empty path means root)
patch_files - (zum anlegen, ändern und löschen von Dateien)
read_file - (nach zeilen)
ripgrep - suchmusater und dateimuster
websearch - eine Google Suche machen mit Schlüsselwörtern
`
}]};
const input = [{"role": "user", "content": [ {"type": "input_text","text": userText } ]}];
do{
const call = { const call = {
model: 'gpt-5-mini', model: 'gpt-4.1-nano',
input: input, input: counter == 0 ? [systemprompt,...structuredClone(input)] : structuredClone(input),
text: { format: { type: 'text' }, verbosity: 'high' }, text: { format: { type: 'text' }/*, verbosity: 'low' */},
reasoning: { effort: 'medium', summary: 'detailed' }, //reasoning: { effort: 'minimal', summary: 'detailed' },
tools: Object.values(toolsByFile).map(t => t.def), tools: Object.values(toolsByFile).map(t => t.def),
store: true, store: true,
} }
if(previousResponseId) call.previous_response_id = previousResponseId; if(previousResponseId) call.previous_response_id = previousResponseId;
console.log("\n\n\n\n\n------NEW OPENAI CALL-"+input.length+"-------------" //console.log("\n\n\n\n\n------NEW OPENAI CALL-"+input.length+"-------------"
,"\n",counter++,"\n",'----INPUT-----------------' // ,"\n",counter++,"\n",'----INPUT-----------------'
,"\n",call.input.map(i => JSON.stringify(i)),"\n", // ,"\n",call.input.map(i => JSON.stringify(i)),"\n",
'--------CALL-------------',"\n"); // '--------CALL-------------',call,"\n");
const stream = await openai.responses.stream(call); const stream = await openai.responses.stream(call);
stream.on('response.created', (event) => { stream.on('response.created', (event) => {
previousResponseId = event.response.id; previousResponseId = event.response.id;
@@ -133,20 +156,20 @@ while(true){
try { try {
args = JSON.parse(event.item.arguments); args = JSON.parse(event.item.arguments);
} catch (e){ } catch (e){
console.error('Error parsing arguments:', e, event.item.arguments); // console.error('Error parsing arguments:', e, event.item.arguments);
} }
console.log(' function call:', id, name); //console.log(' function call:', id, name);
functionCalls.push({ id, name, args, promise: toolsByFile[name].run(args) }); functionCalls.push({ id, name, args, promise: toolsByFile[name].run(args) });
} }
}); });
stream.on('response.completed', async (event) => { stream.on('response.completed', async (event) => {
printIndented(10,renderUsage(event.response.usage)); printIndented(10,renderUsage(event.response.usage));
if (event.response.output.filter(i => i.type === 'message').length > 0) printIndented(10, "Textresult:",event.response.output.filter(i => i.type === 'message').map(i => i.content[0].text)); if (event.response.output.filter(i => i.type === 'message').length > 0) printIndented(10,event.response.output.filter(i => i.type === 'message').map(i => i.content[0].text).join('\n'));
}); });
await Array.fromAsync(stream); await Array.fromAsync(stream);
input=[]; input.length = 0;
for (const call of functionCalls) { for (const call of functionCalls) {
//try { //try {
@@ -161,8 +184,8 @@ while(true){
// console.error('Error in function call:', call.name, err); // console.error('Error in function call:', call.name, err);
//} //}
} }
} }while(input.length > 0)
//console.log('OPENAI STREAM FINISHED'); //console.log('OPENAI STREAM FINISHED');
} }
} }

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);
})()

28
eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
export default [
{
ignores: [
'node_modules',
'out1',
'dist',
'build'
]
},
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
browser: true,
node: true
}
},
rules: {
// baseline rules; extend as needed
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'no-undef': 'error',
'no-console': 'off'
}
}
];

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;

4646
out1 Normal file

File diff suppressed because it is too large Load Diff

736
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,54 +3,6 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"main": "cli.js", "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",
"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": { "scripts": {
"start": "node cli.js", "start": "node cli.js",
"start:ink": "tsx cli-ink.js", "start:ink": "tsx cli-ink.js",
@@ -63,5 +15,18 @@
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "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> "

43
scripts/jsx-loader.mjs Normal file
View File

@@ -0,0 +1,43 @@
// ESM loader to transpile .jsx files on the fly using esbuild
// Usage: node --loader ./scripts/jsx-loader.mjs cli-ink.js
import { readFile } from 'node:fs/promises';
import { pathToFileURL, fileURLToPath } from 'node:url';
import { transform } from 'esbuild';
/** @typedef {import('node:module').LoadHook} LoadHook */
/** @typedef {import('node:module').ResolveHook} ResolveHook */
/** @type {ResolveHook} */
export async function resolve(specifier, context, nextResolve) {
// Defer to Node's default resolver for most cases
return nextResolve(specifier, context, nextResolve);
}
/** @type {LoadHook} */
export async function load(url, context, nextLoad) {
// Handle .jsx sources
if (url.endsWith('.jsx')) {
const filename = fileURLToPath(url);
const source = await readFile(filename, 'utf8');
const result = await transform(source, {
loader: 'jsx',
format: 'esm',
jsx: 'automatic',
sourcefile: filename,
});
return {
format: 'module',
source: result.code,
shortCircuit: true,
};
}
// Fallback to default loader for everything else
return nextLoad(url, context, nextLoad);
}

View File

@@ -0,0 +1,9 @@
// Registers our custom JSX ESM loader using Node's stable register() API
import { register } from 'node:module';
import { pathToFileURL } from 'node:url';
// Resolve relative to project root cwd
register('./scripts/jsx-loader.mjs', pathToFileURL('./'));

136
src/terminalService.js Normal file
View File

@@ -0,0 +1,136 @@
import EventEmitter from 'events';
import pty from 'node-pty';
class TerminalService extends EventEmitter {
constructor() {
super();
this.lines = [];
this.partial = '';
this.ptyProcess = null;
this.started = false;
this.maxLines = 1000;
}
start() {
if (this.started) return;
const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash';
const cols = process.stdout && process.stdout.columns ? process.stdout.columns : 120;
const rows = process.stdout && process.stdout.rows ? process.stdout.rows : 30;
const isWindows = process.platform === 'win32';
const userShell = process.env.SHELL && !isWindows ? process.env.SHELL : null;
const shellPath = userShell || (isWindows ? 'powershell.exe' : '/bin/bash');
const args = ['--rcfile','rc'];
this.ptyProcess = pty.spawn(shellPath, args, {
name: 'xterm-256color',
cols,
rows,
cwd: process.cwd(),
env: {
...process.env,
TERM: 'xterm-256color',
PS1: 'bash> '
},
});
this.ptyProcess.onData((data) => {
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
if (this.lines.length > this.maxLines) {
this.lines.splice(0, this.lines.length - this.maxLines);
}
// Emit lines including current partial to ensure prompts (no trailing newline) are visible
const display = this.partial ? [...this.lines, this.partial] : this.lines.slice();
this.emit('update', display);
});
// Resize with the host TTY
const onResize = () => {
try {
const newCols = process.stdout.columns || cols;
const newRows = process.stdout.rows || rows;
this.ptyProcess.resize(newCols, newRows);
} catch {
// ignore
}
};
if (process.stdout && process.stdout.on) {
process.stdout.on('resize', onResize);
}
this.ptyProcess.onExit(({ exitCode, signal }) => {
this.emit('exit', { exitCode, signal });
});
this.started = true;
}
getLines() {
return this.lines.slice();
}
write(input) {
if (!this.ptyProcess) return;
this.ptyProcess.write(input);
}
resize(columns, rows) {
if (!this.ptyProcess) return;
try {
const cols = Math.max(1, Number(columns) || 1);
const r = rows ? Math.max(1, Number(rows) || 1) : undefined;
if (r !== undefined) {
this.ptyProcess.resize(cols, r);
} else {
this.ptyProcess.resize(cols, this.ptyProcess.rows || 24);
}
} catch {
// ignore
}
}
dispose() {
try {
if (this.ptyProcess) {
this.ptyProcess.kill();
this.ptyProcess = null;
}
this.started = false;
} catch {
// ignore
}
}
restart() {
try { this.dispose(); } catch {}
try { this.start(); } catch {}
}
}
const terminalService = new TerminalService();
export default terminalService;

View File

@@ -1,18 +1,101 @@
import React from 'react'; import React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import uiService from './uiService.js';
import TextInput from 'ink-text-input'; 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 { 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 '';
const str = String(input);
const ansiPattern = /\u001B\[[0-9;?]*[ -\/]*[@-~]/g; // ESC[ ... cmd
return str.replace(ansiPattern, '');
}
// Expand tab stops to spaces using 8-column tabs (like a typical terminal)
expandTabs(input, tabWidth, limit) {
const width = typeof tabWidth === 'number' && tabWidth > 0 ? tabWidth : 8;
const max = typeof limit === 'number' && limit > 0 ? limit : Infinity;
let col = 0;
let out = '';
for (let i = 0; i < input.length; i += 1) {
const ch = input[i];
if (ch === '\t') {
const spaces = width - (col % width);
// If adding spaces exceeds max, clamp
const add = Math.min(spaces, Math.max(0, max - col));
out += ' '.repeat(add);
col += add;
if (col >= max) break;
} else {
// Treat as width 1 for simplicity (does not account for wide unicode)
if (col + 1 > max) break;
out += ch;
col += 1;
}
}
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() { render() {
const { title, lines } = this.props; const { title, lines, maxWidth } = this.props;
return ( return (
<Box flexDirection="column" borderStyle="round" paddingX={1} paddingY={0} flexGrow={1}> <Box flexDirection="column" borderStyle="round" paddingX={1} paddingY={0} flexGrow={1} flexShrink={1} minWidth={0}>
<Text color="cyan">{title}</Text> <Text color="cyan">{title}</Text>
<Box flexDirection="column"> <Box flexDirection="column" width="100%" flexShrink={1} minWidth={0}>
{(lines && lines.length > 0) {(lines && lines.length > 0)
? lines.map((line, index) => ( ? lines.map((line, index) => {
<Text key={index}>{line}</Text> 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> : <Text dimColor></Text>
} }
</Box> </Box>
@@ -29,53 +112,353 @@ export default class InkApp extends React.Component {
logs: [], logs: [],
terminal: [], terminal: [],
chainOfThought: [], 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.handleSubmit = this.handleSubmit.bind(this);
this.handleChange = this.handleChange.bind(this); this.handleChange = this.handleChange.bind(this);
this.setLLMOutput = this.setLLMOutput.bind(this);
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() { } componentDidMount() {
componentWillUnmount() { } this.terminalUnsub = (lines) => {
this.setTerminal(lines);
};
terminalService.on('update', this.terminalUnsub);
// initialize with current buffered output if any
const initial = terminalService.getLines();
if (initial && initial.length) {
this.setTerminal(initial);
}
// Resize PTY columns to match the Terminal pane width on start and on TTY resize
this.onResize = () => {
const totalCols = (process && process.stdout && process.stdout.columns) ? process.stdout.columns : 80;
const columnWidth = Math.max(1, Math.floor(totalCols / 2));
const paneContentWidth = Math.max(1, columnWidth - 4); // borders + padding
terminalService.resize(paneContentWidth);
};
if (process.stdout && process.stdout.on) {
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) {
terminalService.off('update', this.terminalUnsub);
this.terminalUnsub = null;
}
if (this.onResize && process.stdout && process.stdout.off) {
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) {
if (typeof stateKey !== 'string' || !(stateKey in this.state)) {
throw new Error(`Invalid state key: ${String(stateKey)}`);
}
if (lines === undefined) {
this.setState({ [stateKey]: [] });
return;
}
if (!Array.isArray(lines)) {
throw new TypeError(`Expected an array of lines or undefined for ${stateKey}`);
}
this.setState({ [stateKey]: lines });
}
setLLMOutput(lines) {
this.setPaneLines('llmOutput', lines);
}
setChainOfThought(lines) {
this.setPaneLines('chainOfThought', lines);
}
setTerminal(lines) {
this.setPaneLines('terminal', lines);
}
setLogs(lines) {
this.setPaneLines('logs', lines);
}
handleChange(value) { handleChange(value) {
this.setState({ input: value }); this.setState({ input: value });
} }
handleSubmit() { async handleSubmit() {
const { input } = this.state; const { input } = this.state;
if (!input) return; if (!input) return;
this.setState((state) => ({ this.setState((state) => ({
logs: [...state.logs, `> ${input}`], logs: [...state.logs, `> ${input}`],
terminal: [...state.terminal, `echo ${input}`], input: '',
chainOfThought: [...state.chainOfThought, `(internal) Thought about: ${input}`], isLoading: true
llmOutput: [...state.llmOutput, `Model says: ${input}`],
input: ''
})); }));
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() { render() {
const { input, logs, terminal, chainOfThought, llmOutput } = this.state; const { input, logs, terminal, chainOfThought, llmOutput } = this.state;
const totalCols = (process && process.stdout && process.stdout.columns) ? process.stdout.columns : 80;
const totalRows = (process && process.stdout && process.stdout.rows) ? process.stdout.rows : 24;
const columnWidth = Math.max(1, Math.floor(totalCols / 2));
const paneContentWidth = Math.max(1, columnWidth - 4); // borders (2) + paddingX=1 on both sides (2)
// Compute how many lines fit vertically per pane
// Reserve ~2 rows for the input area (label + margin) at bottom
const reservedFooterRows = 2;
const panesAreaRows = Math.max(4, totalRows - reservedFooterRows);
const paneOuterHeight = Math.max(4, Math.floor(panesAreaRows / 2));
// Remove top/bottom border (2) and title line (1)
const paneContentHeight = Math.max(0, paneOuterHeight - 3);
const sliceLast = (arr) => {
if (!Array.isArray(arr)) return [];
if (paneContentHeight <= 0) return [];
if (arr.length <= paneContentHeight) return arr;
return arr.slice(arr.length - paneContentHeight);
};
const llmOutputView = sliceLast(llmOutput);
const chainOfThoughtView = sliceLast(chainOfThought);
const terminalView = sliceLast(terminal);
const logsView = sliceLast(logs);
const menuItems = this.getMenuItems();
const selected = this.state.menuIndex;
return ( return (
<Box flexDirection="column" height="100%"> <Box flexDirection="column" height="100%">
<Box flexGrow={1} flexDirection="row"> <Box flexGrow={1} flexDirection="row" minWidth={0}>
<Box flexGrow={1} flexDirection="column"> <Box flexGrow={1} flexDirection="column" minWidth={0}>
<Pane title="LLM Output" lines={llmOutput} /> <Pane title="LLM Output" lines={llmOutputView} maxWidth={paneContentWidth} />
<Pane title="Chain of Thought" lines={chainOfThought} /> <Pane title="Chain of Thought" lines={chainOfThoughtView} maxWidth={paneContentWidth} />
</Box> </Box>
<Box flexGrow={1} flexDirection="column"> <Box flexGrow={1} flexDirection="column" minWidth={0}>
<Pane title="Terminal" lines={terminal} /> <Pane title="Terminal" lines={terminalView} maxWidth={paneContentWidth} showCursor cursorBlinkMs={600} />
<Pane title="Logging" lines={logs} /> <Pane title="Logging" lines={logsView} maxWidth={paneContentWidth} />
</Box> </Box>
</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}> <Box marginTop={1}>
<Text>Input: </Text> <Text>Input: </Text>
<TextInput {this.state.isLoading ? (
value={input} <Text color="yellow">{npmSpinnerFrames[this.state.spinnerIndex]} Processing...</Text>
onChange={this.handleChange} ) : (
onSubmit={this.handleSubmit} <TextInput
placeholder="Type and press Enter..." value={input}
/> onChange={this.handleChange}
onSubmit={this.handleSubmit}
placeholder="Type and press Enter..."
/>
)}
</Box> </Box>
</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;

View File

@@ -1,16 +0,0 @@
You are an interactive CLI AI assistant. Follow the user's instructions.
If a tool is available and relevant, plan to use it.
Be explicit when information is undefined.
Do not silently fall back: surface errors.
Prefer concise answers.
Developer rules:
- Null tells the truth. If data is missing/undefined, say so; do not invent values.
- In development, never hide errors; include warnings if using fallbacks.
Behavior:
- Answer succinctly.
- Ask for clarification when the user input is ambiguous.
- Output plain text suitable for a terminal.

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 { promises as fs } from "node:fs";
import path from "node:path"; 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) => { const normalizePath = (inputPath, chrootDir) => {
// Resolve chroot directory // Resolve chroot directory
const chrootResolved = path.resolve(chrootDir); const chrootResolved = path.resolve(chrootDir);
@@ -17,12 +17,19 @@ const normalizePath = (inputPath, chrootDir) => {
// Ensure the path is within chrootDir // Ensure the path is within chrootDir
if (!normalized.startsWith(chrootResolved)) { if (!normalized.startsWith(chrootResolved)) {
throw new Error(`Path escapes chroot boundary: ${inputPath}`); throw new Error(`Path escapes root boundary: ${inputPath}`);
} }
return normalized; 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 // Main recursive directory listing function
async function listEntriesRecursive(startDir, chrootDir, maxDepth, includeHidden = false) { async function listEntriesRecursive(startDir, chrootDir, maxDepth, includeHidden = false) {
const results = []; const results = [];
@@ -35,7 +42,7 @@ async function listEntriesRecursive(startDir, chrootDir, maxDepth, includeHidden
try { try {
dirents = await fs.readdir(currentDir, { withFileTypes: true }); dirents = await fs.readdir(currentDir, { withFileTypes: true });
} catch (err) { } 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) { for (const dirent of dirents) {
@@ -91,39 +98,39 @@ async function listEntriesRecursive(startDir, chrootDir, maxDepth, includeHidden
export default { export default {
type: "function", type: "function",
name: "list_files", 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: { parameters: {
type: "object", type: "object",
properties: { properties: {
path: { path: {
type: "string", type: ["string", "null"],
description: "Directory or file path to list relative to chroot. Use '/' for the chroot root. Defaults to chroot root if not specified.", description: "Directory or file path relative to the root. Use '/' for the root. Defaults to root if not specified.",
}, },
depth: { depth: {
type: "integer", type: ["integer", "null"],
description: "Maximum subdirectory levels to traverse. Use -1 for unlimited depth. Defaults to 1.", description: "Maximum subdirectory levels to traverse. Use -1 for unlimited depth. Defaults to 1.",
minimum: -1, minimum: -1,
}, },
includeHidden: { includeHidden: {
type: "boolean", type: ["boolean", "null"],
description: "Whether to include hidden files and directories (starting with '.'). Defaults to false.", description: "Whether to include hidden files and directories (starting with '.'). Defaults to false.",
default: false, default: false,
} }
}, },
required: ["path", "depth", "includeHidden"], required: [],
additionalProperties: false, additionalProperties: false,
}, },
strict: true, strict: false,
}; };
export async function run(args) { export async function run(args) {
const inputPath = args?.path || ""; const inputPath = args?.path || "";
const depth = Number.isInteger(args?.depth) ? args.depth : 1; const depth = Number.isInteger(args?.depth) ? args.depth : 1;
const includeHidden = args?.includeHidden ?? false; const includeHidden = args?.includeHidden ?? false;
const chrootPath = '/home/seb/src/aiTools/tmp'; const chrootPath = '/workspaces/aiTools/root';
if (!chrootPath) { if (!chrootPath) {
return { err: "Chroot path is required" }; return { err: "Root path is required" };
} }
if (depth < -1) { if (depth < -1) {
return { err: `Depth must be >= -1, received ${args?.depth}` }; 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 // Auto-create the chroot base directory if it does not exist
await fs.mkdir(chrootResolved, { recursive: true }); await fs.mkdir(chrootResolved, { recursive: true });
} catch (err) { } catch (err) {
return { err: `Failed to prepare chroot path: ${chrootPath} (${err?.message || String(err)})` }; return { err: "Failed to initialize root directory" };
} }
let resolvedBase; let resolvedBase;
@@ -149,10 +156,13 @@ export async function run(args) {
try { try {
stat = await fs.lstat(resolvedBase); stat = await fs.lstat(resolvedBase);
} catch (err) { } 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 // Handle single file case
if (stat.isFile()) { if (stat.isFile()) {
@@ -160,22 +170,27 @@ export async function run(args) {
if (!includeHidden && fileName.startsWith(".")) { if (!includeHidden && fileName.startsWith(".")) {
return { cwd, files: [] }; return { cwd, files: [] };
} }
return { cwd, files: JSON.stringify([[fileName, 'f', stat.size]]) }; // Return structured object for easier machine parsing
return { cwd, files: [{ path: fileName, type: 'f', size: stat.size }] };
} }
// Handle non-directory case // Handle non-directory case
if (!stat.isDirectory()) { if (!stat.isDirectory()) {
return { err: `Not a file or directory: ${resolvedBase}` }; return { err: `Not a file or directory${inputPath ? `: ${inputPath}` : ""}` };
} }
// Handle directory case // Handle directory case
try { try {
const files = await listEntriesRecursive(resolvedBase, chrootResolved, depth === -1 ? Infinity : depth, includeHidden); const files = await listEntriesRecursive(resolvedBase, chrootResolved, depth === -1 ? Infinity : depth, includeHidden);
// Map to structured objects and sort by path for consistent output
const mapped = files
.map(([p, t, s]) => ({ path: p, type: t, size: s }))
.sort((a, b) => a.path.localeCompare(b.path));
return { return {
cwd, cwd,
files: JSON.stringify(files.sort((a, b) => a[0].localeCompare(b[0]))), // Sort for consistent output files: mapped,
}; };
} catch (err) { } 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 #!/usr/bin/env node
import chalk from 'chalk';
const desc = ` 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, 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. 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": To use the 'apply_patch' command, you should pass a message of the following structure as "input":
*** Begin Patch *** Begin Patch
[YOUR_PATH] [YOUR_PATCH]
*** End Patch *** End Patch
Where [YOUR_PATCH] is the actual content of your patch, specified in the following V4A diff format. 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] + [new_code]
[3 lines of post-context] [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 // Domain objects
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
@@ -117,10 +128,10 @@ function resolvePath(chroot, filepath) {
const root = normalizePath(chroot); const root = normalizePath(chroot);
// If file is absolute, use it as-is (after normalization). // If file is absolute, resolve it under the chroot rather than using host FS root
// We assume the caller ensures it is inside the chroot.
if (file.startsWith('/')) { if (file.startsWith('/')) {
return file; const resolvedAbs = joinPaths(root, file);
return resolvedAbs.startsWith('/') ? resolvedAbs : '/' + resolvedAbs;
} }
// If file is relative, join with chroot // If file is relative, join with chroot
@@ -229,9 +240,9 @@ class Parser {
if (path) { if (path) {
// Resolve path with chroot // Resolve path with chroot
const resolvedPath = resolvePath(this.chroot, path); const resolvedPath = resolvePath(this.chroot, path);
// Use user-specified path as the key to avoid double-resolving later
if (resolvedPath in this.patch.actions) { if (path in this.patch.actions) {
throw new DiffError(`Duplicate update for file: ${resolvedPath}`); throw new DiffError(`Duplicate update for file: ${path}`);
} }
const move_to = this.read_str("*** Move to: "); const move_to = this.read_str("*** Move to: ");
if (!(resolvedPath in this.current_files)) { if (!(resolvedPath in this.current_files)) {
@@ -239,8 +250,8 @@ class Parser {
} }
const text = this.current_files[resolvedPath]; const text = this.current_files[resolvedPath];
const action = this._parse_update_file(text); const action = this._parse_update_file(text);
action.move_path = move_to ? resolvePath(this.chroot, move_to) : null; action.move_path = move_to ? move_to : null;
this.patch.actions[resolvedPath] = action; this.patch.actions[path] = action;
continue; continue;
} }
@@ -249,14 +260,13 @@ class Parser {
if (path) { if (path) {
// Resolve path with chroot // Resolve path with chroot
const resolvedPath = resolvePath(this.chroot, path); const resolvedPath = resolvePath(this.chroot, path);
if (path in this.patch.actions) {
if (resolvedPath in this.patch.actions) { throw new DiffError(`Duplicate delete for file: ${path}`);
throw new DiffError(`Duplicate delete for file: ${resolvedPath}`);
} }
if (!(resolvedPath in this.current_files)) { 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; continue;
} }
@@ -265,14 +275,13 @@ class Parser {
if (path) { if (path) {
// Resolve path with chroot // Resolve path with chroot
const resolvedPath = resolvePath(this.chroot, path); const resolvedPath = resolvePath(this.chroot, path);
if (path in this.patch.actions) {
if (resolvedPath in this.patch.actions) { throw new DiffError(`Duplicate add for file: ${path}`);
throw new DiffError(`Duplicate add for file: ${resolvedPath}`);
} }
if (resolvedPath in this.current_files) { 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; continue;
} }
@@ -361,7 +370,9 @@ class Parser {
if (!s.startsWith("+")) { if (!s.startsWith("+")) {
throw new DiffError(`Invalid Add File line (missing '+'): ${s}`); 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")); 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) { function patch_to_commit(patch, orig, chroot = null) {
const commit = new Commit(); const commit = new Commit();
for (const [path, action] of Object.entries(patch.actions)) { for (const [path, action] of Object.entries(patch.actions)) {
const resolvedPath = resolvePath(chroot, path);
if (action.type === ActionType.DELETE) { if (action.type === ActionType.DELETE) {
commit.changes[path] = new FileChange( commit.changes[path] = new FileChange(
ActionType.DELETE, ActionType.DELETE,
orig[path], orig[resolvedPath],
null, null,
null null
); );
@@ -621,11 +633,11 @@ function patch_to_commit(patch, orig, chroot = null) {
null null
); );
} else if (action.type === ActionType.UPDATE) { } 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; const move_path = action.move_path ? unresolvePath(chroot, action.move_path) : null;
commit.changes[path] = new FileChange( commit.changes[path] = new FileChange(
ActionType.UPDATE, ActionType.UPDATE,
orig[path], orig[resolvedPath],
new_content, new_content,
move_path move_path
); );
@@ -721,20 +733,23 @@ function load_files(paths, open_fn) {
function apply_commit(commit, write_fn, remove_fn, chroot = null) { function apply_commit(commit, write_fn, remove_fn, chroot = null) {
for (const [path, change] of Object.entries(commit.changes)) { for (const [path, change] of Object.entries(commit.changes)) {
if (change.type === ActionType.DELETE) { if (change.type === ActionType.DELETE) {
remove_fn(path); const target = resolvePath(chroot, path);
remove_fn(target);
} else if (change.type === ActionType.ADD) { } else if (change.type === ActionType.ADD) {
if (change.new_content === null) { if (change.new_content === null) {
throw new DiffError(`ADD change for ${path} has no content`); 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) { } else if (change.type === ActionType.UPDATE) {
if (change.new_content === null) { if (change.new_content === null) {
throw new DiffError(`UPDATE change for ${path} has no new content`); 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); write_fn(target, change.new_content);
if (change.move_path) { if (change.move_path) {
remove_fn(path); const source = resolvePath(chroot, path);
remove_fn(source);
} }
} }
} }
@@ -786,7 +801,7 @@ function remove_file(filepath) {
export default { export default {
type: "function", type: "function",
name: "patch_files", name: "patch_files",
description: "Apply a unified diff patch " + desc, description: "Generic Text File Editor create,edit,delete - Apply a unified diff patch " + desc,
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
@@ -808,10 +823,11 @@ export async function run(args) {
open_file, open_file,
write_file, write_file,
remove_file, remove_file,
'/home/seb/src/aiTools/tmp' '/workspaces/aiTools/root'
); );
return result; return result;
} catch (error) { } catch (error) {
console.log(chalk.red('Patch error:'),error);
return `Patch error: ${error.message}` return `Patch error: ${error.message}`
} }
} }

View File

@@ -1,19 +1,19 @@
import { createReadStream } from "node:fs"; import { createReadStream } from "node:fs";
import { createInterface } from "node:readline"; import { createInterface } from "node:readline";
import fs from "node:fs";
import path from "node:path"; import path from "node:path";
const virtual_chroot = '/home/seb/src/aiTools/tmp'; const virtual_chroot = '/workspaces/aiTools/root';
// Ensures reads are confined to `virtual_chroot`. // Ensures reads are confined to `virtual_chroot`.
export default { 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: { 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.", }, 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 }, 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", description: "1-400 The number of lines to read. Use 0 or more than 400 to read 400 lines.", 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) // Normalize linesToRead (1-400, with 0 or >400 meaning 400)
const maxLines = (linesToRead <= 0 || linesToRead > 400) ? 400 : linesToRead; 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 { try {
@@ -51,7 +56,7 @@ export async function run(args) {
} }
} }
return lines.join('\n'); return 'Filecontent: ´´´'+lines.join('\n')+'´´´';
} catch (error) { } catch (error) {
return `read_file error: ${error.message}`; return `read_file error: ${error.message}`;
} }

View File

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

35
tools/websearch.js Normal file
View File

@@ -0,0 +1,35 @@
import 'dotenv/config';
import Exa from "exa-js";
const exaApiKey = process.env.EXA_API_KEY;
if (!exaApiKey) {
throw new Error("Missing EXA_API_KEY environment variable for websearch");
}
const exa = new Exa(exaApiKey);
export default {
type: 'function',
name: 'websearch',
description: 'Perform a google web search.',
strict: true,
parameters: {
type: 'object',
required: ['query'],
additionalProperties: false,
properties: {
query: { type: 'string', description: 'The search query.' }
}
}
};
export async function run(args) {
try
{
console.log('Google search: ', args.query);
const result = await exa.search( args.query,{ type: "auto", userLocation: "DE", numResults: 20} );
console.log('Google search result: ', result.results[0]);
return result;
} catch (error) {
return `websearch error: ${error?.message || String(error)}`;
}
}

30
tools/wget.js Normal file
View File

@@ -0,0 +1,30 @@
export async function run(args){
const { url } = params;
if (!url) throw new Error('missing url');
const res = await fetch(url);
const buffer = await res.buffer();
const filename = new Date().getTime() + '.' + url.split('.').pop();
const content = buffer.slice(0, 500).toString('utf8');
// 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
export default {
type: 'function',
name: 'wget',
description: 'Download URL to filesystem',
strict: true,
parameters: {
type: 'object',
required: ['url'],
additionalProperties: false,
properties: {
url: { type: 'string', description: 'The url to get.' }
}
}
};