Compare commits

...

36 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
sebseb7
ce6933377a Refactor CLI input handling in cli-ink.js to improve ESC key detection for clean exit. Update InkApp import to .jsx extension and change start:ink script to use tsx for better compatibility. Remove deprecated InkApp.js file to streamline codebase. 2025-08-12 02:56:13 +02:00
sebseb7
edf0d3cffb Update package dependencies to include 'ink' and 'ink-text-input' for enhanced terminal UI capabilities. Add new script 'start:ink' for launching the ink-based CLI interface. Clean up example.js by removing unnecessary whitespace for improved readability. 2025-08-12 01:40:32 +02:00
sebseb7
91d28779d6 Update package dependencies to include node-pty for improved terminal handling. Refactor stat-vit-term.js to utilize node-pty for process management and streamline input simulation. Clean up example.js by removing unnecessary input commands. 2025-08-12 01:36:10 +02:00
sebseb7
2c83d402fc Update example.js to add multiple terminal inputs and extend timeout duration. Modify stat-vit-term.js to adjust shell arguments for improved compatibility. 2025-08-12 00:49:12 +02:00
sebseb7
e1887980d4 Update example.js to replace 'top' command with 'ping' for improved functionality. Minor formatting adjustment in stat-vit-term.js to enhance code readability. 2025-08-12 00:28:18 +02:00
sebseb7
83c912d8e0 Refactor terminal input handling in StatVitTerm to improve signal management and process control. Update example.js to streamline output handling and modify command execution flow for better user experience. Enhance environment variable settings for terminal compatibility. 2025-08-12 00:23:04 +02:00
sebseb7
697cf74cc3 Integrate terminal input handling into CLI tool using terminal-kit. Implement global key handler for CTRL-C and prompt user for input before initiating the LLM loop. Remove outdated test scripts for improved codebase clarity. 2025-08-12 00:16:03 +02:00
sebseb7
5090d2669b u 2025-08-11 23:09:58 +02:00
36 changed files with 7521 additions and 1953 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
.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"
}
]
}

60
cli-ink.js Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env -S node --import tsx
import 'dotenv/config';
import React from 'react';
import { render } from 'ink';
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));
// ESC to exit (only bare ESC, not escape sequences like arrows)
if (process.stdin.isTTY) {
try { process.stdin.setRawMode(true); } catch { }
let escPending = false;
let escTimer = null;
const exitCleanly = () => {
unmount();
try { terminalService.dispose(); } catch { }
try { process.stdin.setRawMode(false); } catch { }
process.exit(0);
};
const onData = (data) => {
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(String(data));
for (const byte of buffer) {
// Ctrl-C (ETX)
if (byte === 0x03) {
return exitCleanly();
}
if (!escPending) {
if (byte === 0x1b) { // ESC
escPending = true;
escTimer = setTimeout(() => {
// No additional byte followed: treat as bare ESC
escPending = false;
escTimer = null;
exitCleanly();
}, 120);
}
// else: ignore other bytes
} else {
// Some byte followed ESC quickly: it's an escape sequence → cancel exit
if (escTimer) { clearTimeout(escTimer); escTimer = null; }
escPending = false;
// Do not process further for exit
}
}
};
process.stdin.on('data', onData);
// Also handle SIGINT in case raw mode changes or comes from elsewhere
const onSigint = () => exitCleanly();
process.on('SIGINT', onSigint);
}

223
cli.js
View File

@@ -1,21 +1,30 @@
#!/usr/bin/env node
import 'dotenv/config';
import OpenAI from 'openai';
import terminalKit from 'terminal-kit';
//npm install tiktoken
//csk-8jftdte6r6vf8fdvp9xkyek5t3jnc6jfhh93d3ewfcwxxvh9
import { promises as fs } from "node:fs";
import { promises as fs, unwatchFile } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { resourceUsage } from 'node:process';
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;
const outputToken = usage.output_tokens;
console.log(' Cost', inputTokens, cacheTokens, outputToken);
console.log('\nCost', inputTokens, cacheTokens, outputToken);
}
function printIndented(indentNum, ...args) {
@@ -34,6 +43,25 @@ function printIndented(indentNum, ...args) {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const term = terminalKit.terminal;
// Global key handler so CTRL-C works everywhere (input fields, loops, etc.).
// Disable mouse tracking so terminal mouse wheel keeps controlling scrollback.
term.grabInput({ mouse: false });
term.on('key', (name) => {
if (name === 'CTRL_C') {
term.grabInput(false);
term.processExit(0);
}
});
async function askUserForInput() {
term.cyan("Enter your request: ");
const input = await term.inputField({ mouse: false }).promise;
console.log('\n');
return input;
}
async function loadTools() {
const toolsDir = path.join(__dirname, "tools");
const dirents = await fs.readdir(toolsDir, { withFileTypes: true });
@@ -48,101 +76,116 @@ async function loadTools() {
);
return Object.fromEntries(toolEntries);
}
streamOnce(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }), 'Erstelle eine React Project für eine Abovverwaltung. Mui, Sqllite, Express, Nodejs, KEIN Typescript, Aber ESM import. webpack, kein vite. HRM, nodemon');
let counter = 0;
async function streamOnce(openai, userText) {
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 call = {
model: 'gpt-5-mini',
input: input,
text: { format: { type: 'text' }, verbosity: 'high' },
reasoning: { effort: 'medium', summary: 'detailed' },
tools: Object.values(toolsByFile).map(t => t.def),
store: true,
}
if(previousResponseId) call.previous_response_id = previousResponseId;
console.log("\n\n\n\n\n------NEW OPENAI CALL-"+input.length+"-------------"
,"\n",counter++,"\n",'----INPUT-----------------'
,"\n",call.input.map(i => JSON.stringify(i)),"\n",
'--------CALL-------------',"\n");
const stream = await openai.responses.stream(call);
stream.on('response.created', (event) => {
previousResponseId = event.response.id;
});
stream.on('response.reasoning_summary_text.delta', (event) => {
process.stdout.write('o')
});
stream.on('response.reasoning_summary_text.done', () => {
process.stdout.write('\n');
//clear on next delta
});
stream.on('response.output_text.delta', (event) => {
process.stdout.write('.')
});
let previousResponseId;
while(true){
stream.on('response.output_item.added', (event) => {
if(event.item && event.item.type === 'function_call'){
//console.log('function call:', event.item);
// 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();
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 = {
model: 'gpt-4.1-nano',
input: counter == 0 ? [systemprompt,...structuredClone(input)] : structuredClone(input),
text: { format: { type: 'text' }/*, verbosity: 'low' */},
//reasoning: { effort: 'minimal', summary: 'detailed' },
tools: Object.values(toolsByFile).map(t => t.def),
store: true,
}
});
stream.on('response.function_call_arguments.delta', (event) => {
process.stdout.write('x');
});
if(previousResponseId) call.previous_response_id = previousResponseId;
const functionCalls = [];
//console.log("\n\n\n\n\n------NEW OPENAI CALL-"+input.length+"-------------"
// ,"\n",counter++,"\n",'----INPUT-----------------'
// ,"\n",call.input.map(i => JSON.stringify(i)),"\n",
// '--------CALL-------------',call,"\n");
const stream = await openai.responses.stream(call);
stream.on('response.created', (event) => {
previousResponseId = event.response.id;
});
stream.on('response.reasoning_summary_text.delta', (event) => {
process.stdout.write('o')
});
stream.on('response.reasoning_summary_text.done', () => {
process.stdout.write('\n');
//clear on next delta
});
stream.on('response.output_item.done', async (event) => {
if(event.item && event.item.type === 'function_call'){
const id = event.item.call_id;
const name = event.item.name;
let args = {};
try {
args = JSON.parse(event.item.arguments);
} catch (e){
console.error('Error parsing arguments:', e, event.item.arguments);
stream.on('response.output_text.delta', (event) => {
process.stdout.write('.')
});
stream.on('response.output_item.added', (event) => {
if(event.item && event.item.type === 'function_call'){
//console.log('function call:', event.item);
}
console.log(' function call:', id, name);
functionCalls.push({ id, name, args, promise: toolsByFile[name].run(args) });
});
stream.on('response.function_call_arguments.delta', (event) => {
process.stdout.write('x');
});
const functionCalls = [];
stream.on('response.output_item.done', async (event) => {
if(event.item && event.item.type === 'function_call'){
const id = event.item.call_id;
const name = event.item.name;
let args = {};
try {
args = JSON.parse(event.item.arguments);
} catch (e){
// console.error('Error parsing arguments:', e, event.item.arguments);
}
//console.log(' function call:', id, name);
functionCalls.push({ id, name, args, promise: toolsByFile[name].run(args) });
}
});
stream.on('response.completed', async (event) => {
printIndented(10,renderUsage(event.response.usage));
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);
input.length = 0;
for (const call of functionCalls) {
//try {
const result = await call.promise;
input.push({
type: "function_call_output",
call_id: call.id,
output: JSON.stringify(result),
})
printIndented(10,'function call result:',result);
//} catch (err) {
// console.error('Error in function call:', call.name, err);
//}
}
});
}while(input.length > 0)
stream.on('response.completed', async (event) => {
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));
});
await Array.fromAsync(stream);
input=[];
for (const call of functionCalls) {
//try {
const result = await call.promise;
input.push({
type: "function_call_output",
call_id: call.id,
output: JSON.stringify(result),
})
printIndented(10,'function call result:',result);
//} catch (err) {
// console.error('Error in function call:', call.name, err);
//}
}
//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

1337
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,52 +3,9 @@
"version": "1.0.0",
"type": "module",
"main": "cli.js",
"dependencies": {
"dotenv": "^16.4.5",
"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",
"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",
"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",
"openai": "^4.104.0",
"resolve-pkg-maps": "^1.0.0",
"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",
"test": "node tests/run-all.js",
"test:patch": "node tests/run-tests.js",
"test:readfile": "node tests/run-readfile-tests.js",
@@ -58,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> "

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('./'));

37
src/example.js Normal file
View File

@@ -0,0 +1,37 @@
// example.js
import StatVitTerm from './stat-vit-term.js';
const termSession = new StatVitTerm();
termSession.on('std', (data) => {
process.stdout.write(data);
});
termSession.on('err', (data) => {
process.stderr.write(data);
});
termSession.on('close', (code) => {
console.log(`\nTerminal closed with code: ${code}`);
});
termSession.on('ready', () => {
console.log('Terminal ready\n');
// Ping starten
console.log('--- Ping starten ---');
termSession.input('ping 127.0.0.1\n');
// Nach 3 Sekunden abbrechen
setTimeout(() => {
console.log('\n--- Ping abbrechen mit ^C ---');
termSession.input('^C');
}, 3000);
// Nach 4 Sekunden beenden
setTimeout(() => {
console.log('\n--- Terminal beenden ---');
termSession.kill();
}, 40000000);
});

45
src/stat-vit-term.js Normal file
View File

@@ -0,0 +1,45 @@
// src/stat-vit-term.js
import pty from 'node-pty';
const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash';
const ptyProcess = pty.spawn(shell, [], {
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: process.cwd(),
env: {
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
LANG: 'en_US.UTF-8',
PWD: process.cwd(),
},
});
// Handle output
ptyProcess.onData((data) => {
process.stdout.write(data); // Preserve formatting
});
// Handle exit
ptyProcess.onExit(({ exitCode, signal }) => {
console.log(`\n\n✨ Shell exited with code ${exitCode}${signal ? ` (via signal ${signal})` : ''}`);
});
// Simulate input
setTimeout(() => {
console.log('\n[INPUT] Starting ping...');
ptyProcess.write('ping 127.0.0.1\r');
}, 1000);
setTimeout(() => {
console.log('\n[INPUT] Sending Ctrl+C...');
ptyProcess.write('\x03'); // Ctrl+C
}, 5000);
setTimeout(() => {
console.log('\n[INPUT] Exiting shell...');
ptyProcess.write('exit\r');
}, 7000);

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;

468
src/ui/InkApp.jsx Normal file
View File

@@ -0,0 +1,468 @@
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 '';
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() {
const { title, lines, maxWidth } = this.props;
return (
<Box flexDirection="column" borderStyle="round" paddingX={1} paddingY={0} flexGrow={1} flexShrink={1} minWidth={0}>
<Text color="cyan">{title}</Text>
<Box flexDirection="column" width="100%" flexShrink={1} minWidth={0}>
{(lines && lines.length > 0)
? 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>
</Box>
);
}
}
export default class InkApp extends React.Component {
constructor(props) {
super(props);
this.state = {
input: '',
logs: [],
terminal: [],
chainOfThought: [],
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);
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() {
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) {
this.setState({ input: value });
}
async handleSubmit() {
const { input } = this.state;
if (!input) return;
this.setState((state) => ({
logs: [...state.logs, `> ${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() {
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 (
<Box flexDirection="column" height="100%">
<Box flexGrow={1} flexDirection="row" minWidth={0}>
<Box flexGrow={1} flexDirection="column" minWidth={0}>
<Pane title="LLM Output" lines={llmOutputView} maxWidth={paneContentWidth} />
<Pane title="Chain of Thought" lines={chainOfThoughtView} maxWidth={paneContentWidth} />
</Box>
<Box flexGrow={1} flexDirection="column" minWidth={0}>
<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>
{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;

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.

View File

@@ -1,32 +0,0 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import path from 'node:path';
const tests = [
'tests/run-tests.js',
'tests/run-readfile-tests.js',
'tests/run-listfiles-tests.js',
'tests/run-ripgrep-tests.js',
];
function runOne(scriptPath) {
return new Promise((resolve) => {
const abs = path.resolve(process.cwd(), scriptPath);
const child = spawn(process.execPath, [abs], { stdio: 'inherit' });
child.on('close', (code) => resolve({ script: scriptPath, code }));
child.on('error', (err) => resolve({ script: scriptPath, code: 1, error: err }));
});
}
async function main() {
let anyFailed = false;
for (const t of tests) {
const res = await runOne(t);
if (res.code !== 0) anyFailed = true;
}
process.exit(anyFailed ? 1 : 0);
}
main().catch((err) => { console.error('Fatal error in run-all:', err); process.exit(1); });

View File

@@ -1,200 +0,0 @@
#!/usr/bin/env node
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { run as runListFiles } from '../tools/list_files.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const chrootRoot = '/home/seb/src/aiTools/tmp';
const sandboxRoot = path.resolve(chrootRoot, 'listfiles-tests');
async function rimraf(dir) {
await fs.rm(dir, { recursive: true, force: true });
}
async function ensureDir(dir) {
await fs.mkdir(dir, { recursive: true });
}
async function writeFiles(baseDir, filesMap) {
for (const [rel, content] of Object.entries(filesMap || {})) {
const filePath = path.resolve(baseDir, rel);
await ensureDir(path.dirname(filePath));
await fs.writeFile(filePath, content, 'utf8');
}
}
function slugify(name) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
function expectEqual(actual, expected, label) {
const toStr = (v) => typeof v === 'string' ? v : JSON.stringify(v);
if (toStr(actual) !== toStr(expected)) {
const ellipsize = (s) => (s && s.length > 400 ? s.slice(0, 400) + '…' : (s ?? '<<undefined>>'));
throw new Error(`${label} mismatch.\nExpected:\n${ellipsize(toStr(expected))}\nActual:\n${ellipsize(toStr(actual))}`);
}
}
async function runCase(index, testCase) {
const idx = String(index + 1).padStart(2, '0');
const caseDir = path.resolve(sandboxRoot, `${idx}-${slugify(testCase.name)}`);
await ensureDir(chrootRoot);
await rimraf(caseDir);
await ensureDir(caseDir);
await writeFiles(caseDir, typeof testCase.before === 'function' ? await testCase.before({ dir: caseDir }) : (testCase.before || {}));
const args = await testCase.args({ dir: caseDir });
const result = await runListFiles(args);
if (testCase.expect?.errorRegex) {
if (!testCase.expect.errorRegex.test(result.err || '')) {
throw new Error(`Error regex mismatch. Got: ${result.err}`);
}
} else {
// Expect cwd and files
let expectedCwd;
if (testCase.expect.cwdFromArgs === true) {
expectedCwd = (args.path === '' || args.path === '/') ? '.' : args.path;
} else if (testCase.expect.cwdFromArgs === 'file') {
expectedCwd = path.dirname(args.path || '.') || '.';
} else {
expectedCwd = testCase.expect.cwd;
}
expectEqual(result.cwd, expectedCwd, 'cwd');
expectEqual(result.files, JSON.stringify(testCase.expect.files), 'files');
}
}
function cases() {
const list = [];
// 1. List empty dir depth 0
list.push({
name: 'list empty dir depth 0',
before: {},
args: async ({ dir }) => ({ path: path.relative(chrootRoot, dir) || '/', depth: 0, includeHidden: false }),
expect: { cwdFromArgs: true, files: [] }
});
// 2. List single file
list.push({
name: 'list single file',
before: { 'a.txt': 'A' },
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'a.txt')), depth: 1, includeHidden: false }),
expect: { cwdFromArgs: 'file', files: [['a.txt', 'f', 1]] }
});
// 3. Directory with nested structure depth 1
list.push({
name: 'nested depth 1',
before: { 'sub/x.txt': 'X', 'sub/inner/y.txt': 'Y', 'z.txt': 'Z' },
args: async ({ dir }) => ({ path: path.relative(chrootRoot, dir) || '/', depth: 1, includeHidden: false }),
expect: { cwdFromArgs: true, files: [['sub', 'd', null], ['sub/inner', 'd', null], ['sub/x.txt', 'f', 1], ['z.txt', 'f', 1]] }
});
// 4. Depth unlimited (-1)
list.push({
name: 'depth unlimited',
before: { 'sub/x.txt': 'X', 'sub/inner/y.txt': 'Y', 'z.txt': 'Z' },
args: async ({ dir }) => ({ path: path.relative(chrootRoot, dir) || '/', depth: -1, includeHidden: false }),
expect: { cwdFromArgs: true, files: [
['sub', 'd', null],
['sub/inner', 'd', null],
['sub/inner/y.txt', 'f', 1],
['sub/x.txt', 'f', 1],
['z.txt', 'f', 1],
] }
});
// 5. Include hidden
list.push({
name: 'include hidden',
before: { '.hidden': 'h', 'v.txt': 'v' },
args: async ({ dir }) => ({ path: path.relative(chrootRoot, dir) || '/', depth: 1, includeHidden: true }),
expect: { cwdFromArgs: true, files: [['.hidden', 'f', 1], ['v.txt', 'f', 1]] }
});
// 6. Non-existent path -> error
list.push({
name: 'nonexistent path error',
before: {},
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'nope')), depth: 1, includeHidden: false }),
expect: { errorRegex: /Path does not exist:/ }
});
// 7. Hidden excluded when includeHidden=false
list.push({
name: 'hidden excluded by default',
before: { '.hidden': 'h', 'shown.txt': 's' },
args: async ({ dir }) => ({ path: path.relative(chrootRoot, dir) || '/', depth: 1, includeHidden: false }),
expect: { cwdFromArgs: true, files: [['shown.txt', 'f', 1]] }
});
// 8. Depth 0 shows only top-level entries
list.push({
name: 'depth 0 top-level only',
before: { 'a.txt': 'A', 'sub/b.txt': 'B' },
args: async ({ dir }) => ({ path: path.relative(chrootRoot, dir) || '/', depth: 0, includeHidden: false }),
expect: { cwdFromArgs: true, files: [['a.txt', 'f', 1], ['sub', 'd', null]] }
});
// 9. Pass hidden file path with includeHidden=false (excluded)
list.push({
name: 'hidden file path excluded when flag false',
before: { '.only.txt': 'x' },
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, '.only.txt')), depth: 1, includeHidden: false }),
expect: { cwdFromArgs: 'file', files: [] }
});
// 10. Pass hidden file path with includeHidden=true (included)
list.push({
name: 'hidden file path included when flag true',
before: { '.only.txt': 'x' },
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, '.only.txt')), depth: 1, includeHidden: true }),
expect: { cwdFromArgs: 'file', files: [['.only.txt', 'f', 1]] }
});
// 11. Path normalization outside chroot -> error
list.push({
name: 'outside chroot error',
before: {},
args: async () => ({ path: '../../etc', depth: 1, includeHidden: false }),
expect: { errorRegex: /Path escapes chroot boundary/ }
});
return list;
}
async function main() {
const all = cases();
await ensureDir(sandboxRoot);
let passed = 0;
let failed = 0;
for (let i = 0; i < all.length; i++) {
const tc = all[i];
const label = `${String(i + 1).padStart(2, '0')} ${tc.name}`;
try {
await runCase(i, tc);
console.log(`${label}`);
passed++;
} catch (err) {
console.error(`${label}`);
console.error(String(err?.stack || err));
failed++;
}
}
console.log('');
console.log(`Passed: ${passed}, Failed: ${failed}, Total: ${all.length}`);
if (failed > 0) process.exit(1);
}
main().catch((err) => {
console.error('Fatal error in list_files test runner:', err);
process.exit(1);
});

View File

@@ -1,182 +0,0 @@
#!/usr/bin/env node
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { run as runReadFile } from '../tools/read_file.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const chrootRoot = '/home/seb/src/aiTools/tmp';
const sandboxRoot = path.resolve(chrootRoot, 'readfile-tests');
async function rimraf(dir) {
await fs.rm(dir, { recursive: true, force: true });
}
async function ensureDir(dir) {
await fs.mkdir(dir, { recursive: true });
}
async function writeFiles(baseDir, filesMap) {
for (const [rel, content] of Object.entries(filesMap || {})) {
const filePath = path.resolve(baseDir, rel);
await ensureDir(path.dirname(filePath));
await fs.writeFile(filePath, content, 'utf8');
}
}
function slugify(name) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
function expectEqual(actual, expected, label) {
if (actual !== expected) {
const ellipsize = (s) => (s && s.length > 400 ? s.slice(0, 400) + '…' : (s ?? '<<undefined>>'));
throw new Error(`${label} mismatch.\nExpected:\n${ellipsize(expected)}\nActual:\n${ellipsize(actual)}`);
}
}
function expectRegex(actual, re, label) {
if (!re.test(actual)) {
throw new Error(`${label} mismatch. Expected to match ${re}, Actual: ${actual}`);
}
}
async function runCase(index, testCase) {
const idx = String(index + 1).padStart(2, '0');
const caseDir = path.resolve(sandboxRoot, `${idx}-${slugify(testCase.name)}`);
await ensureDir(chrootRoot);
await rimraf(caseDir);
await ensureDir(caseDir);
await writeFiles(caseDir, typeof testCase.before === 'function' ? await testCase.before({ dir: caseDir }) : (testCase.before || {}));
const args = await testCase.args({ dir: caseDir });
const result = await runReadFile(args);
if (testCase.expect?.equals !== undefined) {
expectEqual(result, testCase.expect.equals, 'Tool result');
}
if (testCase.expect?.errorRegex) {
expectRegex(result, testCase.expect.errorRegex, 'Error');
}
}
function cases() {
const list = [];
// 1. Read entire small file
list.push({
name: 'read entire file',
before: { 'a.txt': 'A\nB\nC' },
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'a.txt')), linesToSkip: 0, linesToRead: 400 }),
expect: { equals: 'A\nB\nC' }
});
// 2. Skip first line, read next 1
list.push({
name: 'skip and read one',
before: { 'a.txt': 'A\nB\nC' },
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'a.txt')), linesToSkip: 1, linesToRead: 1 }),
expect: { equals: 'B' }
});
// 3. linesToRead 0 defaults to 400
list.push({
name: 'linesToRead zero defaults',
before: { 'a.txt': 'L1\nL2\nL3' },
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'a.txt')), linesToSkip: 0, linesToRead: 0 }),
expect: { equals: 'L1\nL2\nL3' }
});
// 4. Missing file -> error string
list.push({
name: 'missing file error',
before: {},
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'nope.txt')), linesToSkip: 0, linesToRead: 100 }),
expect: { errorRegex: /read_file error:/ }
});
// 5. Path outside chroot -> error
list.push({
name: 'path outside chroot error',
before: {},
args: async () => ({ path: '../../etc/passwd', linesToSkip: 0, linesToRead: 100 }),
expect: { errorRegex: /read_file error: Path outside of allowed directory/ }
});
// 6. Large file truncated to 400 lines
list.push({
name: 'truncate to 400 lines',
before: async () => {
const many = Array.from({ length: 450 }, (_, i) => `L${i + 1}`).join('\n');
return { 'big.txt': many };
},
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'big.txt')), linesToSkip: 0, linesToRead: 99999 }),
expect: { equals: Array.from({ length: 400 }, (_, i) => `L${i + 1}`).join('\n') }
});
// 7. Skip beyond file length -> empty
list.push({
name: 'skip beyond length returns empty',
before: { 's.txt': 'A\nB' },
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 's.txt')), linesToSkip: 10, linesToRead: 5 }),
expect: { equals: '' }
});
// 8. Skip to last line and read one
list.push({
name: 'skip to last line and read one',
before: { 't.txt': 'L1\nL2\nL3' },
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 't.txt')), linesToSkip: 2, linesToRead: 1 }),
expect: { equals: 'L3' }
});
// 9. Read exactly N lines from middle
list.push({
name: 'read middle two lines',
before: { 'u.txt': 'A\nB\nC\nD' },
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'u.txt')), linesToSkip: 1, linesToRead: 2 }),
expect: { equals: 'B\nC' }
});
// 10. Empty file read -> empty string
list.push({
name: 'empty file read',
before: { 'empty.txt': '' },
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'empty.txt')), linesToSkip: 0, linesToRead: 100 }),
expect: { equals: '' }
});
return list;
}
async function main() {
const all = cases();
await ensureDir(sandboxRoot);
let passed = 0;
let failed = 0;
for (let i = 0; i < all.length; i++) {
const tc = all[i];
const label = `${String(i + 1).padStart(2, '0')} ${tc.name}`;
try {
await runCase(i, tc);
console.log(`${label}`);
passed++;
} catch (err) {
console.error(`${label}`);
console.error(String(err?.stack || err));
failed++;
}
}
console.log('');
console.log(`Passed: ${passed}, Failed: ${failed}, Total: ${all.length}`);
if (failed > 0) process.exit(1);
}
main().catch((err) => {
console.error('Fatal error in read_file test runner:', err);
process.exit(1);
});

View File

@@ -1,238 +0,0 @@
#!/usr/bin/env node
import { promises as fs } from 'node:fs';
import fsSync from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { run as runRipgrep } from '../tools/ripgrep.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const chrootRoot = '/home/seb/src/aiTools/tmp';
const sandboxRoot = path.resolve(chrootRoot, 'rg-tests');
async function rimraf(dir) {
await fs.rm(dir, { recursive: true, force: true });
}
async function ensureDir(dir) {
await fs.mkdir(dir, { recursive: true });
}
async function writeFiles(baseDir, filesMap) {
for (const [rel, content] of Object.entries(filesMap || {})) {
const filePath = path.resolve(baseDir, rel);
await ensureDir(path.dirname(filePath));
await fs.writeFile(filePath, content, 'utf8');
}
}
function slugify(name) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
function expectEqual(actual, expected, label) {
if (actual !== expected) {
const ellipsize = (s) => (s && s.length > 400 ? s.slice(0, 400) + '…' : (s ?? '<<undefined>>'));
throw new Error(`${label} mismatch.\nExpected:\n${ellipsize(expected)}\nActual:\n${ellipsize(actual)}`);
}
}
function expectRegex(actual, re, label) {
if (!re.test(actual)) {
throw new Error(`${label} mismatch. Expected to match ${re}, Actual: ${actual}`);
}
}
async function runCase(index, testCase) {
const idx = String(index + 1).padStart(2, '0');
const caseDir = path.resolve(sandboxRoot, `${idx}-${slugify(testCase.name)}`);
await ensureDir(chrootRoot);
await rimraf(caseDir);
await ensureDir(caseDir);
// Setup initial files
await writeFiles(caseDir, typeof testCase.before === 'function' ? await testCase.before({ dir: caseDir }) : (testCase.before || {}));
const args = await testCase.args({ dir: caseDir });
let threw = false;
let output = '';
try {
output = await runRipgrep(args);
} catch (err) {
threw = true;
output = err?.message || String(err);
}
if (testCase.expect?.error) {
if (!threw && typeof output === 'string') {
// We expect error formatting to be returned as string starting with 'ripgrep error:'
expectRegex(output, testCase.expect.error, 'Error string');
} else if (threw) {
expectRegex(output, testCase.expect.error, 'Thrown error');
}
} else {
if (typeof testCase.expect?.equals === 'string') {
expectEqual(output, testCase.expect.equals, 'Tool result');
}
if (typeof testCase.expect?.lineCount === 'number') {
const lines = output ? output.split('\n').filter(Boolean) : [];
expectEqual(lines.length, testCase.expect.lineCount, 'Line count');
}
}
}
function cases() {
const list = [];
// 1. Simple case-sensitive match
list.push({
name: 'simple case-sensitive match',
before: { 'a.txt': 'Hello\nWorld\nhello again' },
args: async ({ dir }) => ({ pattern: 'Hello', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: false }),
expect: { equals: `${path.relative(chrootRoot, path.join(sandboxRoot, '01-simple-case-sensitive-match/a.txt'))}:1:Hello` }
});
// 2. Case-insensitive matches
list.push({
name: 'case-insensitive matches',
before: { 'a.txt': 'Hello\nWorld\nhello again' },
args: async ({ dir }) => ({ pattern: 'hello', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: true }),
expect: { equals: [
`${path.relative(chrootRoot, path.join(sandboxRoot, '02-case-insensitive-matches/a.txt'))}:1:Hello`,
`${path.relative(chrootRoot, path.join(sandboxRoot, '02-case-insensitive-matches/a.txt'))}:3:hello again`
].join('\n') }
});
// 3. filePattern filter to subdir and extension
list.push({
name: 'filePattern filter',
before: { 'sub/b.md': 'Alpha\nbeta\nGamma' },
args: async ({ dir }) => ({ pattern: 'Alpha', filePattern: path.relative(chrootRoot, path.join(dir, 'sub/*.md')), n_flag: true, i_flag: false }),
expect: { equals: `${path.relative(chrootRoot, path.join(sandboxRoot, '03-filepattern-filter/sub/b.md'))}:1:Alpha` }
});
// 4. No matches -> empty string
list.push({
name: 'no matches returns empty',
before: { 'a.txt': 'x\ny' },
args: async ({ dir }) => ({ pattern: 'nomatch', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: false }),
expect: { equals: '' }
});
// 5. Output limited to 200 lines
list.push({
name: 'limit to 200 lines',
before: async () => {
const many = Array.from({ length: 250 }, (_, i) => `line${i + 1}`).join('\n');
return { 'long.txt': many };
},
args: async ({ dir }) => ({ pattern: 'line', filePattern: path.relative(chrootRoot, path.join(dir, 'long.txt')), n_flag: true, i_flag: false }),
expect: { lineCount: 200 }
});
// 6. Invalid regex pattern -> error line
list.push({
name: 'invalid regex pattern',
before: { 'a.txt': 'text' },
args: async ({ dir }) => ({ pattern: '[', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: false }),
expect: { error: /ripgrep error:/ }
});
// 7. No line numbers (n_flag false)
list.push({
name: 'no line numbers',
before: { 'a.txt': 'foo\nbar\nfoo' },
args: async ({ dir }) => ({ pattern: 'foo', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: false, i_flag: false }),
expect: { equals: [
`${path.relative(chrootRoot, path.join(sandboxRoot, '07-no-line-numbers/a.txt'))}:foo`,
`${path.relative(chrootRoot, path.join(sandboxRoot, '07-no-line-numbers/a.txt'))}:foo`
].join('\n') }
});
// 8. filePattern include-only to exclude .md (tool supports single -g, so include *.txt)
list.push({
name: 'filePattern include-only excludes md',
before: { 'a.txt': 'hit', 'b.md': 'hit' },
args: async ({ dir }) => ({ pattern: 'hit', filePattern: path.relative(chrootRoot, path.join(dir, '**/*.txt')), n_flag: true, i_flag: false }),
expect: { equals: `${path.relative(chrootRoot, path.join(sandboxRoot, '08-filepattern-negation-excludes-md/a.txt'))}:1:hit` }
});
// 9. Empty filePattern searches all (we'll scope to the case dir by pattern and path shape)
list.push({
name: 'empty filePattern searches all',
before: { 'x.js': 'Hello', 'y.txt': 'Hello' },
args: async ({ dir }) => ({ pattern: 'Hello', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: false }),
expect: { equals: [
`${path.relative(chrootRoot, path.join(sandboxRoot, '09-empty-filepattern-searches-all/x.js'))}:1:Hello`,
`${path.relative(chrootRoot, path.join(sandboxRoot, '09-empty-filepattern-searches-all/y.txt'))}:1:Hello`
].join('\n') }
});
// 10. Anchored regex
list.push({
name: 'anchored regex',
before: { 'a.txt': 'Hello\nHello world\nHello' },
args: async ({ dir }) => ({ pattern: '^Hello$', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: false }),
expect: { equals: [
`${path.relative(chrootRoot, path.join(sandboxRoot, '10-anchored-regex/a.txt'))}:1:Hello`,
`${path.relative(chrootRoot, path.join(sandboxRoot, '10-anchored-regex/a.txt'))}:3:Hello`
].join('\n') }
});
// 11. Special regex characters
list.push({
name: 'special regex characters',
before: { 'a.txt': 'a+b?c\\d and a+b?c\\d' },
args: async ({ dir }) => ({ pattern: 'a\\+b\\?c\\\\d', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: false }),
expect: { equals: `${path.relative(chrootRoot, path.join(sandboxRoot, '11-special-regex-characters/a.txt'))}:1:a+b?c\\d and a+b?c\\d` }
});
// 12. Multiple files across dirs deterministic order
list.push({
name: 'multi dirs deterministic',
before: { 'b/b.txt': 'X', 'a/a.txt': 'X' },
args: async ({ dir }) => ({ pattern: '^X$', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: false }),
expect: { equals: [
`${path.relative(chrootRoot, path.join(sandboxRoot, '12-multi-dirs-deterministic/a/a.txt'))}:1:X`,
`${path.relative(chrootRoot, path.join(sandboxRoot, '12-multi-dirs-deterministic/b/b.txt'))}:1:X`
].join('\n') }
});
return list;
}
async function main() {
const all = cases();
await ensureDir(sandboxRoot);
const results = [];
let passed = 0;
let failed = 0;
for (let i = 0; i < all.length; i++) {
const tc = all[i];
const label = `${String(i + 1).padStart(2, '0')} ${tc.name}`;
try {
await runCase(i, tc);
console.log(`${label}`);
results.push({ name: tc.name, ok: true });
passed++;
} catch (err) {
console.error(`${label}`);
console.error(String(err?.stack || err));
results.push({ name: tc.name, ok: false, error: String(err?.message || err) });
failed++;
}
}
console.log('');
console.log(`Passed: ${passed}, Failed: ${failed}, Total: ${all.length}`);
if (failed > 0) process.exit(1);
}
main().catch((err) => {
console.error('Fatal error in ripgrep test runner:', err);
process.exit(1);
});

View File

@@ -1,626 +0,0 @@
#!/usr/bin/env node
import { promises as fs } from 'node:fs';
import fsSync from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { run as runPatch } from '../tools/patch_files.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const sandboxRoot = path.resolve(repoRoot, 'tmp', 'patch-tests');
async function rimraf(dir) {
await fs.rm(dir, { recursive: true, force: true });
}
async function ensureDir(dir) {
await fs.mkdir(dir, { recursive: true });
}
async function writeFiles(baseDir, filesMap) {
for (const [rel, content] of Object.entries(filesMap || {})) {
const filePath = path.resolve(baseDir, rel);
await ensureDir(path.dirname(filePath));
await fs.writeFile(filePath, content, 'utf8');
}
}
async function readFileIfExists(filePath) {
try {
return await fs.readFile(filePath, 'utf8');
} catch {
return undefined;
}
}
function buildPatch(lines, useCRLF = false) {
const eol = useCRLF ? '\r\n' : '\n';
return lines.join(eol);
}
function begin() { return '*** Begin Patch'; }
function end() { return '*** End Patch'; }
function addFile(p) { return `*** Add File: ${p}`; }
function updateFile(p) { return `*** Update File: ${p}`; }
function moveTo(p) { return `*** Move to: ${p}`; }
function endOfFile() { return '*** End of File'; }
function k(line) { return ` ${line}`; }
function d(line) { return `-${line}`; }
function a(line) { return `+${line}`; }
function at(atLine = '') { return atLine ? `@@ ${atLine}` : '@@'; }
function slugify(name) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
function expectEqual(actual, expected, label) {
if (actual !== expected) {
const ellipsize = (s) => (s.length > 400 ? s.slice(0, 400) + '…' : s);
throw new Error(`${label} mismatch.\nExpected:\n${ellipsize(expected ?? '<<undefined>>')}\nActual:\n${ellipsize(actual ?? '<<undefined>>')}`);
}
}
async function runCase(index, testCase) {
const idx = String(index + 1).padStart(2, '0');
const caseDir = path.resolve(sandboxRoot, `${idx}-${slugify(testCase.name)}`);
await rimraf(caseDir);
await ensureDir(caseDir);
// Setup initial files
await writeFiles(caseDir, typeof testCase.before === 'function' ? await testCase.before({ dir: caseDir }) : (testCase.before || {}));
const patchText = await testCase.patch({ dir: caseDir });
let threw = false;
let errorMessage = '';
try {
const result = await runPatch({ patch: patchText });
if (testCase.expect?.resultEquals) {
expectEqual(result, testCase.expect.resultEquals, 'Tool result');
}
} catch (err) {
threw = true;
errorMessage = err?.message || String(err);
}
if (testCase.expect?.error) {
if (!threw) {
throw new Error(`Expected error matching ${testCase.expect.error} but call succeeded`);
}
const re = typeof testCase.expect.error === 'string' ? new RegExp(testCase.expect.error.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) : testCase.expect.error;
if (!re.test(errorMessage)) {
throw new Error(`Error did not match.\nExpected: ${re}\nActual: ${errorMessage}`);
}
} else if (threw) {
throw new Error(`Unexpected error: ${errorMessage}`);
}
if (testCase.expect?.files) {
for (const [rel, expectedContent] of Object.entries(testCase.expect.files)) {
const filePath = path.resolve(caseDir, rel);
const content = await readFileIfExists(filePath);
if (content === undefined) {
throw new Error(`Expected file missing: ${rel}`);
}
expectEqual(content, expectedContent, `Content of ${rel}`);
}
}
if (testCase.expect?.exists) {
for (const rel of testCase.expect.exists) {
const filePath = path.resolve(caseDir, rel);
if (!fsSync.existsSync(filePath)) {
throw new Error(`Expected path to exist: ${rel}`);
}
}
}
if (testCase.expect?.notExists) {
for (const rel of testCase.expect.notExists) {
const filePath = path.resolve(caseDir, rel);
if (fsSync.existsSync(filePath)) {
throw new Error(`Expected path NOT to exist: ${rel}`);
}
}
}
}
function cases() {
const list = [];
// 1. Add simple file
list.push({
name: 'add simple file',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'file1.txt')),
a('hello'),
a('world'),
end(),
]),
expect: {
files: { 'file1.txt': 'hello\nworld' }
}
});
// 2. Add nested directories
list.push({
name: 'add nested directories',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'a/b/c.txt')),
a('alpha'),
a('beta'),
end(),
]),
expect: {
files: { 'a/b/c.txt': 'alpha\nbeta' }
}
});
// 3. Update simple replacement
list.push({
name: 'update simple replacement',
before: { 'greet.txt': 'line1\nreplace me\nline3' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'greet.txt')),
k('line1'),
d('replace me'),
a('replaced'),
k('line3'),
end(),
]),
expect: {
files: { 'greet.txt': 'line1\nreplaced\nline3' }
}
});
// 4. Update multiple chunks
list.push({
name: 'update multiple chunks',
before: { 'multi.txt': 'l1\nl2\nl3\nl4\nl5\nl6\nl7' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'multi.txt')),
k('l1'),
k('l2'),
d('l3'),
a('L3'),
k('l4'),
k('l5'),
d('l6'),
a('L6'),
k('l7'),
end(),
]),
expect: {
files: { 'multi.txt': 'l1\nl2\nL3\nl4\nl5\nL6\nl7' }
}
});
// 5. Insert at beginning
list.push({
name: 'insert at beginning',
before: { 'begin.txt': 'B1\nB2' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'begin.txt')),
a('A0'),
k('B1'),
k('B2'),
end(),
]),
expect: { files: { 'begin.txt': 'A0\nB1\nB2' } }
});
// 6. Insert at end with EOF marker
list.push({
name: 'insert at end with EOF',
before: { 'end.txt': 'E1\nE2' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'end.txt')),
k('E1'),
k('E2'),
a('E3'),
endOfFile(),
end(),
]),
expect: { files: { 'end.txt': 'E1\nE2\nE3' } }
});
// 7. Delete a line
list.push({
name: 'delete a line',
before: { 'delete.txt': 'X\nY\nZ' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'delete.txt')),
k('X'),
d('Y'),
k('Z'),
end(),
]),
expect: { files: { 'delete.txt': 'X\nZ' } }
});
// 8. Whitespace rstrip match
list.push({
name: 'rstrip whitespace match',
before: { 'ws.txt': 'foo \nbar' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'ws.txt')),
k('foo'), // matches 'foo ' via rstrip
k('bar'),
end(),
]),
expect: { files: { 'ws.txt': 'foo \nbar' } }
});
// 9. Trim match
list.push({
name: 'trim whitespace match',
before: { 'trim.txt': ' alpha \n beta' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'trim.txt')),
k('alpha'), // matches ' alpha ' via trim
k(' beta'),
end(),
]),
expect: { files: { 'trim.txt': ' alpha \n beta' } }
});
// 10. Use def_str to anchor (do not duplicate the anchor line in context)
list.push({
name: 'def_str anchor',
before: { 'code.js': 'function a() {}\nfunction greet() {\n return 1;\n}\nfunction z() {}' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'code.js')),
at('function greet() {'),
// context starts at the next line after the def_str match
d(' return 1;'),
a(' return 2;'),
k('}'),
end(),
]),
expect: { files: { 'code.js': 'function a() {}\nfunction greet() {\n return 2;\n}\nfunction z() {}' } }
});
// 11. Bare @@ marker allowed
list.push({
name: 'bare @@ marker',
before: { 'marker.txt': 'L1\nL2\nL3' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'marker.txt')),
at(),
k('L1'),
d('L2'),
a('X2'),
k('L3'),
end(),
]),
expect: { files: { 'marker.txt': 'L1\nX2\nL3' } }
});
// 12. Move/rename file with content change
list.push({
name: 'move and change',
before: { 'mv.txt': 'A\nB' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'mv.txt')),
moveTo(path.resolve(dir, 'moved/mv.txt')),
k('A'),
d('B'),
a('C'),
end(),
]),
expect: { files: { 'moved/mv.txt': 'A\nC' }, notExists: ['mv.txt'] }
});
// 13. Delete file
list.push({
name: 'delete file',
before: { 'del/me.txt': 'bye' },
patch: async ({ dir }) => buildPatch([
begin(),
`*** Delete File: ${path.resolve(dir, 'del/me.txt')}`,
end(),
]),
expect: { notExists: ['del/me.txt'] }
});
// 14. Combined add/update/delete
list.push({
name: 'combined operations',
before: { 'combo/u.txt': 'X\nY', 'combo/d.txt': 'gone' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'combo/u.txt')),
k('X'),
a('Z'),
k('Y'),
`*** Delete File: ${path.resolve(dir, 'combo/d.txt')}`,
addFile(path.resolve(dir, 'combo/a.txt')),
a('new'),
end(),
]),
expect: { files: { 'combo/u.txt': 'X\nZ\nY', 'combo/a.txt': 'new' }, notExists: ['combo/d.txt'] }
});
// 15. Add with CRLF patch
list.push({
name: 'add with CRLF patch',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'crlf/add.txt')),
a('one'),
a('two'),
end(),
], true),
expect: { files: { 'crlf/add.txt': 'one\ntwo' } }
});
// 16. Update with CRLF patch
list.push({
name: 'update with CRLF patch',
before: { 'crlf/up.txt': 'A\nB' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'crlf/up.txt')),
k('A'),
d('B'),
a('C'),
end(),
], true),
expect: { files: { 'crlf/up.txt': 'A\nC' } }
});
// 17. Ambiguous content resolved by def_str
list.push({
name: 'ambiguous resolved by def_str',
before: { 'amb.js': 'function target() {\n let x = 1;\n}\nfunction target() {\n let x = 2;\n}' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'amb.js')),
at('function target() {'),
k('function target() {'),
d(' let x = 2;'),
a(' let x = 42;'),
k('}'),
end(),
]),
expect: { files: { 'amb.js': 'function target() {\n let x = 1;\n}\nfunction target() {\n let x = 42;\n}' } }
});
// 18. Update missing file -> error
list.push({
name: 'update missing file error',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'missing.txt')),
k('x'),
end(),
]),
expect: { error: /Patch error: Update File Error - missing file:/ }
});
// 19. Delete missing file -> error
list.push({
name: 'delete missing file error',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
`*** Delete File: ${path.resolve(dir, 'nope.txt')}`,
end(),
]),
expect: { error: /Patch error: Delete File Error - missing file:/ }
});
// 20. Add existing file -> error
list.push({
name: 'add existing file error',
before: { 'exists.txt': 'already' },
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'exists.txt')),
a('new'),
end(),
]),
expect: { error: /Patch error: Add File Error - file already exists:/ }
});
// 21. Duplicate update -> error
list.push({
name: 'duplicate update error',
before: { 'dup.txt': 'X' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'dup.txt')),
k('X'),
updateFile(path.resolve(dir, 'dup.txt')),
k('X'),
end(),
]),
expect: { error: /Patch error: Duplicate update for file:/ }
});
// 22. Invalid line in update section -> error
list.push({
name: 'invalid line in section error',
before: { 'bad.txt': 'A\nB' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'bad.txt')),
k('A'),
'?weird',
k('B'),
end(),
]),
expect: { error: /Patch error: Invalid Line:/ }
});
// 23. Missing end sentinel -> error
list.push({
name: 'missing end sentinel error',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'x.txt')),
a('x'),
// Intentionally no end sentinel here; we'll strip it below
]).replace(/\*\*\* End Patch$/, ''),
expect: { error: /Patch error: Invalid patch text - missing sentinels/ }
});
// 24. Unknown line while parsing -> error
list.push({
name: 'unknown line while parsing error',
before: {},
patch: async () => buildPatch([
begin(),
'some random line',
end(),
]),
expect: { error: /Patch error: Unknown line while parsing:/ }
});
// 25. Add empty file (no + lines)
list.push({
name: 'add empty file',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'empty.txt')),
end(),
]),
expect: { files: { 'empty.txt': '' } }
});
// 26. Replace whole file contents
list.push({
name: 'replace whole file',
before: { 'whole.txt': 'a\nb\nc' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'whole.txt')),
d('a'),
d('b'),
d('c'),
a('x'),
a('y'),
end(),
]),
expect: { files: { 'whole.txt': 'x\ny' } }
});
// 27. Multiple file updates
list.push({
name: 'multi-file updates',
before: { 'm1.txt': '1', 'm2.txt': 'A' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'm1.txt')),
d('1'),
a('2'),
updateFile(path.resolve(dir, 'm2.txt')),
d('A'),
a('B'),
end(),
]),
expect: { files: { 'm1.txt': '2', 'm2.txt': 'B' } }
});
// 28. Rename only (no content changes)
list.push({
name: 'rename only',
before: { 'r/from.txt': 'same' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'r/from.txt')),
moveTo(path.resolve(dir, 'r/to.txt')),
end(),
]),
expect: { files: { 'r/to.txt': 'same' }, notExists: ['r/from.txt'] }
});
// 29. EOF context matching at end
list.push({
name: 'EOF context matching',
before: { 'eof.txt': 'first\nsecond\nthird' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'eof.txt')),
k('second'),
a('inserted'),
k('third'),
endOfFile(),
end(),
]),
expect: { files: { 'eof.txt': 'first\nsecond\ninserted\nthird' } }
});
// 30. Add multiple files in single patch
list.push({
name: 'add multiple files',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'multi/a.txt')),
a('A'),
addFile(path.resolve(dir, 'multi/b.txt')),
a('B'),
addFile(path.resolve(dir, 'multi/c.txt')),
a('C'),
end(),
]),
expect: { files: { 'multi/a.txt': 'A', 'multi/b.txt': 'B', 'multi/c.txt': 'C' } }
});
return list;
}
async function main() {
const all = cases();
await ensureDir(sandboxRoot);
const results = [];
let passed = 0;
let failed = 0;
for (let i = 0; i < all.length; i++) {
const tc = all[i];
const label = `${String(i + 1).padStart(2, '0')} ${tc.name}`;
try {
await runCase(i, tc);
console.log(`${label}`);
results.push({ name: tc.name, ok: true });
passed++;
} catch (err) {
console.error(`${label}`);
console.error(String(err?.stack || err));
results.push({ name: tc.name, ok: false, error: String(err?.message || err) });
failed++;
}
}
console.log('');
console.log(`Passed: ${passed}, Failed: ${failed}, Total: ${all.length}`);
if (failed > 0) process.exit(1);
}
main().catch((err) => {
console.error('Fatal error in test runner:', err);
process.exit(1);
});

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 = '/home/seb/src/aiTools/tmp';
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()) {
@@ -160,22 +170,27 @@ export async function run(args) {
if (!includeHidden && fileName.startsWith(".")) {
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
if (!stat.isDirectory()) {
return { err: `Not a file or directory: ${resolvedBase}` };
return { err: `Not a file or directory${inputPath ? `: ${inputPath}` : ""}` };
}
// Handle directory case
try {
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 {
cwd,
files: JSON.stringify(files.sort((a, b) => a[0].localeCompare(b[0]))), // Sort for consistent output
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);
}
}
}
@@ -786,7 +801,7 @@ function remove_file(filepath) {
export default {
type: "function",
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: {
type: "object",
properties: {
@@ -808,10 +823,11 @@ export async function run(args) {
open_file,
write_file,
remove_file,
'/home/seb/src/aiTools/tmp'
'/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 = '/home/seb/src/aiTools/tmp';
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 = '/home/seb/src/aiTools/tmp';
const virtual_chroot = '/workspaces/aiTools/root';
export default {
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.' }
}
}
};