tsunami-code 2.8.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +88 -9
- package/lib/loop.js +50 -2
- package/lib/prompt.js +25 -1
- package/lib/tools.js +325 -1
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -4,7 +4,8 @@ import chalk from 'chalk';
|
|
|
4
4
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
5
5
|
import { join } from 'path';
|
|
6
6
|
import os from 'os';
|
|
7
|
-
import { agentLoop, quickCompletion, setModel, getModel } from './lib/loop.js';
|
|
7
|
+
import { agentLoop, quickCompletion, setModel, getModel, tokenStats } from './lib/loop.js';
|
|
8
|
+
import { injectServerContext } from './lib/tools.js';
|
|
8
9
|
import { buildSystemPrompt } from './lib/prompt.js';
|
|
9
10
|
import { runPreflight, checkServer } from './lib/preflight.js';
|
|
10
11
|
import { setSession, undo, undoStackSize } from './lib/tools.js';
|
|
@@ -20,7 +21,7 @@ import {
|
|
|
20
21
|
getSessionContext
|
|
21
22
|
} from './lib/memory.js';
|
|
22
23
|
|
|
23
|
-
const VERSION = '
|
|
24
|
+
const VERSION = '3.0.0';
|
|
24
25
|
const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
|
|
25
26
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
26
27
|
const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
|
|
@@ -131,7 +132,7 @@ if (setServerIdx !== -1 && argv[setServerIdx + 1]) {
|
|
|
131
132
|
|
|
132
133
|
// ── Confirm Callback (dangerous command prompt) ─────────────────────────────
|
|
133
134
|
function makeConfirmCallback(rl) {
|
|
134
|
-
|
|
135
|
+
const cb = async (cmd) => {
|
|
135
136
|
return new Promise((resolve) => {
|
|
136
137
|
rl.pause();
|
|
137
138
|
process.stdout.write(`\n ${yellow('⚠ Dangerous:')} ${dim(cmd.slice(0, 120))}\n`);
|
|
@@ -145,6 +146,20 @@ function makeConfirmCallback(rl) {
|
|
|
145
146
|
process.stdin.once('data', handler);
|
|
146
147
|
});
|
|
147
148
|
};
|
|
149
|
+
|
|
150
|
+
cb._askUser = (question, resolve) => {
|
|
151
|
+
rl.pause();
|
|
152
|
+
process.stdout.write(`\n ${cyan('?')} ${question}\n ${dim('> ')}`);
|
|
153
|
+
const handler = (data) => {
|
|
154
|
+
process.stdin.removeListener('data', handler);
|
|
155
|
+
rl.resume();
|
|
156
|
+
process.stdout.write('\n');
|
|
157
|
+
resolve(data.toString().trim());
|
|
158
|
+
};
|
|
159
|
+
process.stdin.once('data', handler);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return cb;
|
|
148
163
|
}
|
|
149
164
|
|
|
150
165
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
@@ -158,8 +173,31 @@ async function run() {
|
|
|
158
173
|
initProjectMemory(cwd);
|
|
159
174
|
setSession({ sessionDir, cwd });
|
|
160
175
|
|
|
176
|
+
// Inject server context into AgentTool
|
|
177
|
+
injectServerContext(serverUrl, buildSystemPrompt());
|
|
178
|
+
|
|
161
179
|
printBanner(serverUrl);
|
|
162
180
|
|
|
181
|
+
// Persistent history — load previous commands
|
|
182
|
+
const HISTORY_FILE = join(CONFIG_DIR, 'history.jsonl');
|
|
183
|
+
function appendHistory(line) {
|
|
184
|
+
try {
|
|
185
|
+
const entry = JSON.stringify({ cmd: line, ts: Date.now(), cwd }) + '\n';
|
|
186
|
+
import('fs').then(({ appendFileSync }) => { try { appendFileSync(HISTORY_FILE, entry, 'utf8'); } catch {} });
|
|
187
|
+
} catch {}
|
|
188
|
+
}
|
|
189
|
+
function loadHistory() {
|
|
190
|
+
try {
|
|
191
|
+
if (!existsSync(HISTORY_FILE)) return [];
|
|
192
|
+
return readFileSync(HISTORY_FILE, 'utf8')
|
|
193
|
+
.trim().split('\n').filter(Boolean)
|
|
194
|
+
.map(l => { try { return JSON.parse(l).cmd; } catch { return null; } })
|
|
195
|
+
.filter(Boolean).reverse().slice(0, 200);
|
|
196
|
+
} catch { return []; }
|
|
197
|
+
}
|
|
198
|
+
const historyEntries = loadHistory();
|
|
199
|
+
let historyIdx = -1;
|
|
200
|
+
|
|
163
201
|
// Preflight checks
|
|
164
202
|
process.stdout.write(dim(' Checking server connection...'));
|
|
165
203
|
const { errors, warnings } = await runPreflight(serverUrl);
|
|
@@ -342,10 +380,28 @@ async function run() {
|
|
|
342
380
|
console.log(red(` Unknown memory subcommand: ${sub}\n Try: /memory, /memory files, /memory view <file>, /memory clear\n`));
|
|
343
381
|
}
|
|
344
382
|
|
|
383
|
+
// Frustration detection — from leaked userPromptKeywords.ts pattern
|
|
384
|
+
const FRUSTRATION_PATTERNS = [
|
|
385
|
+
/\b(wtf|fuck|shit|damn|idiot|stupid|useless|broken|wrong|garbage|trash|terrible|awful|hate)\b/i,
|
|
386
|
+
/\b(not working|still broken|still wrong|same error|again|ugh|argh)\b/i,
|
|
387
|
+
/!{2,}/,
|
|
388
|
+
/\b(why (would|did|is|are|does|do) you|you (keep|always|never|can't|cannot|won't|don't))\b/i,
|
|
389
|
+
/\b(i (said|told|asked)|stop|listen|pay attention)\b/i
|
|
390
|
+
];
|
|
391
|
+
function detectFrustration(text) {
|
|
392
|
+
return FRUSTRATION_PATTERNS.some(p => p.test(text));
|
|
393
|
+
}
|
|
394
|
+
|
|
345
395
|
rl.on('line', async (input) => {
|
|
346
396
|
const line = input.trim();
|
|
347
397
|
if (!line) { rl.prompt(); return; }
|
|
348
398
|
|
|
399
|
+
// Append to persistent history
|
|
400
|
+
if (!line.startsWith('/')) {
|
|
401
|
+
appendHistory(line);
|
|
402
|
+
historyIdx = -1;
|
|
403
|
+
}
|
|
404
|
+
|
|
349
405
|
if (line.startsWith('/')) {
|
|
350
406
|
const parts = line.slice(1).split(' ');
|
|
351
407
|
const cmd = parts[0].toLowerCase();
|
|
@@ -370,6 +426,7 @@ async function run() {
|
|
|
370
426
|
['/status', 'Show context size and server'],
|
|
371
427
|
['/server <url>', 'Change model server URL'],
|
|
372
428
|
['/model [name]', 'Show or change active model (default: local)'],
|
|
429
|
+
['/history', 'Show recent command history'],
|
|
373
430
|
['/exit', 'Exit'],
|
|
374
431
|
];
|
|
375
432
|
for (const [c, desc] of cmds) {
|
|
@@ -416,13 +473,22 @@ async function run() {
|
|
|
416
473
|
console.log(dim(` CWD : ${cwd}\n`));
|
|
417
474
|
break;
|
|
418
475
|
}
|
|
419
|
-
case 'cost':
|
|
420
|
-
|
|
421
|
-
console.log(
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
476
|
+
case 'cost': {
|
|
477
|
+
const hasReal = tokenStats.requests > 0;
|
|
478
|
+
console.log(blue('\n Session Token Usage'));
|
|
479
|
+
if (hasReal) {
|
|
480
|
+
console.log(dim(` Input : ${tokenStats.input.toLocaleString()} tokens (actual)`));
|
|
481
|
+
console.log(dim(` Output : ${tokenStats.output.toLocaleString()} tokens (actual)`));
|
|
482
|
+
console.log(dim(` Total : ${(tokenStats.input + tokenStats.output).toLocaleString()} tokens`));
|
|
483
|
+
console.log(dim(` Requests: ${tokenStats.requests}`));
|
|
484
|
+
} else {
|
|
485
|
+
console.log(dim(` Input : ~${_inputTokens.toLocaleString()} (estimated)`));
|
|
486
|
+
console.log(dim(` Output : ~${_outputTokens.toLocaleString()} (estimated)`));
|
|
487
|
+
console.log(dim(` Total : ~${(_inputTokens + _outputTokens).toLocaleString()} (estimated)`));
|
|
488
|
+
}
|
|
489
|
+
console.log();
|
|
425
490
|
break;
|
|
491
|
+
}
|
|
426
492
|
case 'clear':
|
|
427
493
|
resetSession();
|
|
428
494
|
console.log(green(' Session cleared.\n'));
|
|
@@ -449,6 +515,14 @@ async function run() {
|
|
|
449
515
|
case 'memory':
|
|
450
516
|
await handleMemoryCommand(rest);
|
|
451
517
|
break;
|
|
518
|
+
case 'history': {
|
|
519
|
+
const recent = historyEntries.slice(0, 20);
|
|
520
|
+
if (recent.length === 0) { console.log(dim(' No history yet.\n')); break; }
|
|
521
|
+
console.log(blue(`\n Recent commands (${recent.length}):`));
|
|
522
|
+
recent.forEach((h, i) => console.log(dim(` ${String(i + 1).padStart(2)} ${h.slice(0, 100)}`)));
|
|
523
|
+
console.log();
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
452
526
|
case 'exit': case 'quit':
|
|
453
527
|
gracefulExit(0);
|
|
454
528
|
return;
|
|
@@ -465,6 +539,11 @@ async function run() {
|
|
|
465
539
|
userContent = `[Previous session summary]\n${lastSessionSummary}\n\n---\n\n${line}`;
|
|
466
540
|
}
|
|
467
541
|
|
|
542
|
+
// Frustration injection — tell the model to acknowledge and course-correct
|
|
543
|
+
if (detectFrustration(line)) {
|
|
544
|
+
userContent += '\n\n[system: User appears frustrated. Acknowledge the issue directly, do not repeat the same approach. Be concise and action-focused.]';
|
|
545
|
+
}
|
|
546
|
+
|
|
468
547
|
const fullMessages = [
|
|
469
548
|
{ role: 'system', content: systemPrompt },
|
|
470
549
|
...messages,
|
package/lib/loop.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fetch from 'node-fetch';
|
|
2
|
-
import { ALL_TOOLS } from './tools.js';
|
|
2
|
+
import { ALL_TOOLS, injectAgentLoop, injectServerContext } from './tools.js';
|
|
3
3
|
import {
|
|
4
4
|
assembleContext,
|
|
5
5
|
extractFilePaths,
|
|
@@ -29,6 +29,9 @@ function isDangerous(cmd) {
|
|
|
29
29
|
// Skip waitForServer after first successful connection
|
|
30
30
|
let _serverVerified = false;
|
|
31
31
|
|
|
32
|
+
// Real token tracking from API responses
|
|
33
|
+
export const tokenStats = { input: 0, output: 0, requests: 0 };
|
|
34
|
+
|
|
32
35
|
// Current model identifier — changeable at runtime via /model command
|
|
33
36
|
let _currentModel = 'local';
|
|
34
37
|
export function setModel(model) { _currentModel = model; }
|
|
@@ -234,6 +237,12 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
|
|
|
234
237
|
fullContent += token;
|
|
235
238
|
onToken(token);
|
|
236
239
|
}
|
|
240
|
+
// Capture real token counts from usage field (sent on final chunk by llama.cpp)
|
|
241
|
+
if (parsed.usage) {
|
|
242
|
+
tokenStats.input += parsed.usage.prompt_tokens || 0;
|
|
243
|
+
tokenStats.output += parsed.usage.completion_tokens || 0;
|
|
244
|
+
tokenStats.requests++;
|
|
245
|
+
}
|
|
237
246
|
}
|
|
238
247
|
}
|
|
239
248
|
|
|
@@ -278,6 +287,12 @@ export async function quickCompletion(serverUrl, systemPrompt, userMessage) {
|
|
|
278
287
|
}
|
|
279
288
|
}
|
|
280
289
|
|
|
290
|
+
// Self-register into tools.js so AgentTool can call back into us
|
|
291
|
+
// (done here at module load time to avoid circular import at parse time)
|
|
292
|
+
import('./tools.js').then(m => {
|
|
293
|
+
m.injectAgentLoop(agentLoop);
|
|
294
|
+
}).catch(() => {});
|
|
295
|
+
|
|
281
296
|
export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessionInfo = null, confirmCallback = null, maxIterations = 15) {
|
|
282
297
|
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
|
|
283
298
|
const currentTask = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '';
|
|
@@ -317,6 +332,20 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
|
|
|
317
332
|
continue;
|
|
318
333
|
}
|
|
319
334
|
|
|
335
|
+
// AskUser: intercept and surface the question to the user
|
|
336
|
+
if (tc.name === 'AskUser' && confirmCallback) {
|
|
337
|
+
const parsed = typeof tc.arguments === 'string' ? JSON.parse(tc.arguments) : tc.arguments;
|
|
338
|
+
const normalized = normalizeArgs(parsed);
|
|
339
|
+
onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
|
|
340
|
+
// Reuse confirmCallback channel but pass question back as answer
|
|
341
|
+
const answer = await new Promise(resolve => confirmCallback._askUser
|
|
342
|
+
? confirmCallback._askUser(normalized.question, resolve)
|
|
343
|
+
: resolve('[No answer provided]')
|
|
344
|
+
);
|
|
345
|
+
results.push(`[AskUser result]\nUser answered: ${answer}`);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
|
|
320
349
|
// Dangerous command confirmation
|
|
321
350
|
if (tc.name === 'Bash' && confirmCallback) {
|
|
322
351
|
const parsed = typeof tc.arguments === 'string' ? JSON.parse(tc.arguments) : tc.arguments;
|
|
@@ -333,7 +362,26 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
|
|
|
333
362
|
|
|
334
363
|
onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
|
|
335
364
|
const result = await runTool(tc.name, tc.arguments, sessionInfo, sessionFiles);
|
|
336
|
-
|
|
365
|
+
const resultStr = String(result);
|
|
366
|
+
|
|
367
|
+
// Handle Snip signal — surgically remove message indices from context
|
|
368
|
+
if (tc.name === 'Snip') {
|
|
369
|
+
try {
|
|
370
|
+
const sig = JSON.parse(resultStr);
|
|
371
|
+
if (sig.__snip__ && Array.isArray(sig.indices)) {
|
|
372
|
+
const toRemove = new Set(sig.indices.map(Number));
|
|
373
|
+
// messages[0] is system prompt, indices are 1-based user/assistant turns
|
|
374
|
+
const kept = messages.filter((_, i) => i === 0 || !toRemove.has(i - 1));
|
|
375
|
+
const removed = messages.length - kept.length;
|
|
376
|
+
messages.length = 0;
|
|
377
|
+
kept.forEach(m => messages.push(m));
|
|
378
|
+
results.push(`[Snip result]\nRemoved ${removed} messages from context. Reason: ${sig.reason}`);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
} catch {}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
results.push(`[${tc.name} result]\n${resultStr.slice(0, 8000)}`);
|
|
337
385
|
}
|
|
338
386
|
|
|
339
387
|
messages.push({
|
package/lib/prompt.js
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
function getGitContext() {
|
|
7
|
+
try {
|
|
8
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
9
|
+
const status = execSync('git status --short', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
10
|
+
const log = execSync('git log --oneline -5', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
11
|
+
const parts = [`Branch: ${branch}`];
|
|
12
|
+
if (status) parts.push(`Changed files:\n${status}`);
|
|
13
|
+
if (log) parts.push(`Recent commits:\n${log}`);
|
|
14
|
+
return `\n\n<git>\n${parts.join('\n\n')}\n</git>`;
|
|
15
|
+
} catch {
|
|
16
|
+
return '';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
4
19
|
|
|
5
20
|
function loadContextFile() {
|
|
6
21
|
const locations = [
|
|
@@ -23,6 +38,8 @@ export function buildSystemPrompt(memoryContext = '') {
|
|
|
23
38
|
const cwd = process.cwd();
|
|
24
39
|
const context = loadContextFile();
|
|
25
40
|
|
|
41
|
+
const gitContext = getGitContext();
|
|
42
|
+
|
|
26
43
|
return `You are an expert software engineer and technical assistant operating as a CLI agent. You think deeply before acting, trace data flow before changing code, and verify your work.
|
|
27
44
|
|
|
28
45
|
To use a tool, output ONLY this format — nothing else before or after the tool call block:
|
|
@@ -38,7 +55,7 @@ Available tools: Bash, Read, Write, Edit, Glob, Grep, Note, Checkpoint. Use them
|
|
|
38
55
|
- Platform: ${process.platform}
|
|
39
56
|
- Shell: ${process.platform === 'win32' ? 'cmd/powershell' : 'bash'}
|
|
40
57
|
- Date: ${new Date().toISOString().split('T')[0]}
|
|
41
|
-
</environment
|
|
58
|
+
</environment>${gitContext}
|
|
42
59
|
|
|
43
60
|
<tools>
|
|
44
61
|
- **Bash**: Run shell commands. Never use for grep/find/cat — use dedicated tools.
|
|
@@ -49,6 +66,13 @@ Available tools: Bash, Read, Write, Edit, Glob, Grep, Note, Checkpoint. Use them
|
|
|
49
66
|
- **Grep**: Search file contents by regex. Always use instead of grep in Bash.
|
|
50
67
|
- **Note**: Save a permanent discovery to project memory (.tsunami/). Use liberally for traps, patterns, architectural decisions.
|
|
51
68
|
- **Checkpoint**: Save current task progress to session memory so work is resumable if the session ends.
|
|
69
|
+
- **WebFetch**: Fetch any URL and get the page content as text. Use for docs, GitHub files, APIs.
|
|
70
|
+
- **WebSearch**: Search the web via DuckDuckGo. Returns titles, URLs, snippets. Follow up with WebFetch.
|
|
71
|
+
- **TodoWrite**: Manage a persistent task list (add/complete/delete/list). Use for any multi-step task.
|
|
72
|
+
- **AskUser**: Ask the user a clarifying question when genuinely blocked. Use sparingly.
|
|
73
|
+
- **Agent**: Spawn a sub-agent to handle an independent task. Call multiple times in one response for parallel execution.
|
|
74
|
+
- **Snip**: Surgically remove specific messages from context to free space without losing everything.
|
|
75
|
+
- **Brief**: Write a working-memory note to yourself. Injected into next turn — ensures nothing is forgotten on long tasks.
|
|
52
76
|
</tools>
|
|
53
77
|
|
|
54
78
|
<reasoning_protocol>
|
package/lib/tools.js
CHANGED
|
@@ -4,6 +4,7 @@ import { glob } from 'glob';
|
|
|
4
4
|
import { promisify } from 'util';
|
|
5
5
|
import { getRgPath } from './preflight.js';
|
|
6
6
|
import { addFileNote, updateContext, appendDecision } from './memory.js';
|
|
7
|
+
import fetch from 'node-fetch';
|
|
7
8
|
|
|
8
9
|
const execAsync = promisify(exec);
|
|
9
10
|
|
|
@@ -324,4 +325,327 @@ EXAMPLE:
|
|
|
324
325
|
}
|
|
325
326
|
};
|
|
326
327
|
|
|
327
|
-
|
|
328
|
+
// ── WEB FETCH ─────────────────────────────────────────────────────────────────
|
|
329
|
+
export const WebFetchTool = {
|
|
330
|
+
name: 'WebFetch',
|
|
331
|
+
description: `Fetches content from a URL and returns it as text. Use for reading documentation, API references, GitHub files, or any web resource needed to complete a task.
|
|
332
|
+
|
|
333
|
+
- Returns page content as plain text (HTML stripped)
|
|
334
|
+
- Max ~50KB returned; large pages are truncated
|
|
335
|
+
- Do not use for downloading binaries`,
|
|
336
|
+
input_schema: {
|
|
337
|
+
type: 'object',
|
|
338
|
+
properties: {
|
|
339
|
+
url: { type: 'string', description: 'The URL to fetch' },
|
|
340
|
+
prompt: { type: 'string', description: 'What to extract or summarize from the page (optional — returns raw text if omitted)' }
|
|
341
|
+
},
|
|
342
|
+
required: ['url']
|
|
343
|
+
},
|
|
344
|
+
async run({ url, prompt: _prompt }) {
|
|
345
|
+
try {
|
|
346
|
+
const controller = new AbortController();
|
|
347
|
+
const timer = setTimeout(() => controller.abort(), 15000);
|
|
348
|
+
const res = await fetch(url, {
|
|
349
|
+
signal: controller.signal,
|
|
350
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; TsunamiCode/2.9)' }
|
|
351
|
+
});
|
|
352
|
+
clearTimeout(timer);
|
|
353
|
+
if (!res.ok) return `Error: HTTP ${res.status} ${res.statusText}`;
|
|
354
|
+
const raw = await res.text();
|
|
355
|
+
// Strip HTML tags, collapse whitespace
|
|
356
|
+
const text = raw
|
|
357
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
358
|
+
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
359
|
+
.replace(/<[^>]+>/g, ' ')
|
|
360
|
+
.replace(/ /g, ' ')
|
|
361
|
+
.replace(/&/g, '&')
|
|
362
|
+
.replace(/</g, '<')
|
|
363
|
+
.replace(/>/g, '>')
|
|
364
|
+
.replace(/"/g, '"')
|
|
365
|
+
.replace(/\s{3,}/g, '\n\n')
|
|
366
|
+
.trim();
|
|
367
|
+
return text.slice(0, 50000) + (text.length > 50000 ? '\n\n[truncated]' : '');
|
|
368
|
+
} catch (e) {
|
|
369
|
+
if (e.name === 'AbortError') return 'Error: Request timed out after 15s';
|
|
370
|
+
return `Error fetching URL: ${e.message}`;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// ── TODO WRITE ────────────────────────────────────────────────────────────────
|
|
376
|
+
// In-memory todo list — persists for the session, visible to the model
|
|
377
|
+
const _todos = [];
|
|
378
|
+
let _todoId = 0;
|
|
379
|
+
|
|
380
|
+
export const TodoWriteTool = {
|
|
381
|
+
name: 'TodoWrite',
|
|
382
|
+
description: `Manage a persistent task list for the current session. Use this to track multi-step work so nothing gets lost.
|
|
383
|
+
|
|
384
|
+
Operations:
|
|
385
|
+
- add: Add a new todo item
|
|
386
|
+
- complete: Mark a todo as done (by id)
|
|
387
|
+
- delete: Remove a todo (by id)
|
|
388
|
+
- list: Show all todos (also happens automatically)
|
|
389
|
+
|
|
390
|
+
WHEN TO USE:
|
|
391
|
+
- Any task with 3+ steps — create the list upfront
|
|
392
|
+
- After completing a step — mark it done immediately
|
|
393
|
+
- When starting work on a step — mark it in_progress
|
|
394
|
+
|
|
395
|
+
The list is shown to the user after every update.`,
|
|
396
|
+
input_schema: {
|
|
397
|
+
type: 'object',
|
|
398
|
+
properties: {
|
|
399
|
+
op: { type: 'string', enum: ['add', 'complete', 'delete', 'list'], description: 'Operation to perform' },
|
|
400
|
+
text: { type: 'string', description: 'Todo text (for add)' },
|
|
401
|
+
id: { type: 'number', description: 'Todo ID (for complete/delete)' },
|
|
402
|
+
status: { type: 'string', enum: ['pending', 'in_progress', 'done'], description: 'Status for complete op (default: done)' }
|
|
403
|
+
},
|
|
404
|
+
required: ['op']
|
|
405
|
+
},
|
|
406
|
+
async run({ op, text, id, status = 'done' }) {
|
|
407
|
+
if (op === 'add') {
|
|
408
|
+
if (!text) return 'Error: text required for add';
|
|
409
|
+
_todoId++;
|
|
410
|
+
_todos.push({ id: _todoId, text, status: 'pending' });
|
|
411
|
+
} else if (op === 'complete') {
|
|
412
|
+
const todo = _todos.find(t => t.id === id);
|
|
413
|
+
if (!todo) return `Error: todo #${id} not found`;
|
|
414
|
+
todo.status = status;
|
|
415
|
+
} else if (op === 'delete') {
|
|
416
|
+
const idx = _todos.findIndex(t => t.id === id);
|
|
417
|
+
if (idx === -1) return `Error: todo #${id} not found`;
|
|
418
|
+
_todos.splice(idx, 1);
|
|
419
|
+
}
|
|
420
|
+
// Always return current list
|
|
421
|
+
if (_todos.length === 0) return 'Todo list is empty.';
|
|
422
|
+
const icons = { pending: '○', in_progress: '◉', done: '✓' };
|
|
423
|
+
return _todos.map(t => `${icons[t.status] || '○'} [${t.id}] ${t.text}`).join('\n');
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// ── ASK USER ──────────────────────────────────────────────────────────────────
|
|
428
|
+
// This tool is a signal — the agent loop in index.js intercepts it and prompts the user
|
|
429
|
+
export const AskUserTool = {
|
|
430
|
+
name: 'AskUser',
|
|
431
|
+
description: `Ask the user a clarifying question and wait for their answer. Use this when you are genuinely blocked and need input that cannot be inferred.
|
|
432
|
+
|
|
433
|
+
Only use when:
|
|
434
|
+
- Multiple valid approaches exist with meaningfully different outcomes
|
|
435
|
+
- Required information cannot be found in the codebase, env, or context
|
|
436
|
+
- A destructive or irreversible action needs explicit confirmation
|
|
437
|
+
|
|
438
|
+
Do NOT use for:
|
|
439
|
+
- Things you can infer from context
|
|
440
|
+
- Choices that don't materially affect the outcome
|
|
441
|
+
- Asking if you should proceed (just proceed)`,
|
|
442
|
+
input_schema: {
|
|
443
|
+
type: 'object',
|
|
444
|
+
properties: {
|
|
445
|
+
question: { type: 'string', description: 'The question to ask the user' }
|
|
446
|
+
},
|
|
447
|
+
required: ['question']
|
|
448
|
+
},
|
|
449
|
+
async run({ question }) {
|
|
450
|
+
// The agent loop intercepts this and handles the actual prompt.
|
|
451
|
+
// Return value here is fallback only.
|
|
452
|
+
return `[AskUser] ${question}`;
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// ── WEB SEARCH ────────────────────────────────────────────────────────────────
|
|
457
|
+
export const WebSearchTool = {
|
|
458
|
+
name: 'WebSearch',
|
|
459
|
+
description: `Search the web and return results. Use when you need current information, documentation, error solutions, or anything not in the codebase.
|
|
460
|
+
|
|
461
|
+
Returns titles, URLs, and snippets for the top results. Follow up with WebFetch on a specific result to get the full content.`,
|
|
462
|
+
input_schema: {
|
|
463
|
+
type: 'object',
|
|
464
|
+
properties: {
|
|
465
|
+
query: { type: 'string', description: 'Search query' },
|
|
466
|
+
num_results: { type: 'number', description: 'Number of results to return (default 8, max 20)' }
|
|
467
|
+
},
|
|
468
|
+
required: ['query']
|
|
469
|
+
},
|
|
470
|
+
async run({ query, num_results = 8 }) {
|
|
471
|
+
try {
|
|
472
|
+
const n = Math.min(num_results, 20);
|
|
473
|
+
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
474
|
+
const controller = new AbortController();
|
|
475
|
+
const timer = setTimeout(() => controller.abort(), 12000);
|
|
476
|
+
const res = await fetch(url, {
|
|
477
|
+
signal: controller.signal,
|
|
478
|
+
headers: {
|
|
479
|
+
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
|
|
480
|
+
'Accept': 'text/html'
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
clearTimeout(timer);
|
|
484
|
+
if (!res.ok) return `Error: HTTP ${res.status}`;
|
|
485
|
+
const html = await res.text();
|
|
486
|
+
|
|
487
|
+
// Parse results from DuckDuckGo HTML
|
|
488
|
+
const results = [];
|
|
489
|
+
const resultBlocks = html.match(/<div class="result[^"]*"[\s\S]*?<\/div>\s*<\/div>/g) || [];
|
|
490
|
+
for (const block of resultBlocks.slice(0, n)) {
|
|
491
|
+
const titleMatch = block.match(/<a[^>]+class="result__a"[^>]*>([\s\S]*?)<\/a>/);
|
|
492
|
+
const urlMatch = block.match(/href="([^"]+)"/);
|
|
493
|
+
const snippetMatch = block.match(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/);
|
|
494
|
+
|
|
495
|
+
const title = titleMatch ? titleMatch[1].replace(/<[^>]+>/g, '').trim() : '';
|
|
496
|
+
const href = urlMatch ? urlMatch[1] : '';
|
|
497
|
+
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
|
|
498
|
+
|
|
499
|
+
// DuckDuckGo uses redirect URLs — extract the actual URL
|
|
500
|
+
const actualUrl = href.includes('uddg=')
|
|
501
|
+
? decodeURIComponent(href.match(/uddg=([^&]+)/)?.[1] || href)
|
|
502
|
+
: href;
|
|
503
|
+
|
|
504
|
+
if (title && actualUrl) {
|
|
505
|
+
results.push(`${results.length + 1}. ${title}\n ${actualUrl}\n ${snippet}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (results.length === 0) return `No results found for: ${query}`;
|
|
510
|
+
return `Search results for "${query}":\n\n${results.join('\n\n')}`;
|
|
511
|
+
} catch (e) {
|
|
512
|
+
if (e.name === 'AbortError') return 'Error: Search timed out';
|
|
513
|
+
return `Error: ${e.message}`;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
// ── AGENT TOOL ────────────────────────────────────────────────────────────────
|
|
519
|
+
// Circular import prevention — agentLoop is injected at runtime by loop.js
|
|
520
|
+
let _agentLoopRef = null;
|
|
521
|
+
export function injectAgentLoop(fn) { _agentLoopRef = fn; }
|
|
522
|
+
|
|
523
|
+
export const AgentTool = {
|
|
524
|
+
name: 'Agent',
|
|
525
|
+
description: `Launch a sub-agent to handle an independent task in parallel. The sub-agent has access to all the same tools (Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, etc.) and works autonomously until done.
|
|
526
|
+
|
|
527
|
+
USE THIS FOR:
|
|
528
|
+
- Independent subtasks that don't depend on each other (run multiple Agents in one response)
|
|
529
|
+
- Long-running research tasks you don't want in the main context
|
|
530
|
+
- Isolated operations (e.g. "analyze file A" + "analyze file B" simultaneously)
|
|
531
|
+
|
|
532
|
+
The agent returns a summary of what it did and what it found. Keep the task description specific and self-contained — the sub-agent has no knowledge of the current conversation.
|
|
533
|
+
|
|
534
|
+
IMPORTANT: You can call Agent multiple times in one response to run tasks truly in parallel.`,
|
|
535
|
+
input_schema: {
|
|
536
|
+
type: 'object',
|
|
537
|
+
properties: {
|
|
538
|
+
task: { type: 'string', description: 'Complete, self-contained task description for the sub-agent' },
|
|
539
|
+
serverUrl: { type: 'string', description: 'Override server URL (optional)' }
|
|
540
|
+
},
|
|
541
|
+
required: ['task']
|
|
542
|
+
},
|
|
543
|
+
async run({ task, serverUrl }) {
|
|
544
|
+
if (!_agentLoopRef) return 'Error: AgentTool not initialized (no agent loop reference)';
|
|
545
|
+
if (!_currentServerUrl) return 'Error: AgentTool not initialized (no server URL)';
|
|
546
|
+
|
|
547
|
+
const url = serverUrl || _currentServerUrl;
|
|
548
|
+
const subMessages = [
|
|
549
|
+
{ role: 'system', content: _agentSystemPrompt || 'You are a capable software engineering sub-agent. Complete the given task fully and return a summary of what you did.' },
|
|
550
|
+
{ role: 'user', content: task }
|
|
551
|
+
];
|
|
552
|
+
|
|
553
|
+
const outputTokens = [];
|
|
554
|
+
let done = false;
|
|
555
|
+
try {
|
|
556
|
+
await _agentLoopRef(
|
|
557
|
+
url,
|
|
558
|
+
subMessages,
|
|
559
|
+
(token) => { outputTokens.push(token); },
|
|
560
|
+
() => {}, // tool call display — silent in sub-agent
|
|
561
|
+
null, // no session info for sub-agents
|
|
562
|
+
null, // no confirm callback
|
|
563
|
+
10 // max iterations
|
|
564
|
+
);
|
|
565
|
+
done = true;
|
|
566
|
+
} catch (e) {
|
|
567
|
+
return `Sub-agent error: ${e.message}`;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Find the last assistant message as the result
|
|
571
|
+
const lastAssistant = [...subMessages].reverse().find(m => m.role === 'assistant');
|
|
572
|
+
const result = lastAssistant?.content || outputTokens.join('');
|
|
573
|
+
return `[Sub-agent result]\n${String(result).slice(0, 6000)}`;
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
// Server URL + system prompt injected by loop.js at startup
|
|
578
|
+
let _currentServerUrl = null;
|
|
579
|
+
let _agentSystemPrompt = null;
|
|
580
|
+
export function injectServerContext(serverUrl, systemPrompt) {
|
|
581
|
+
_currentServerUrl = serverUrl;
|
|
582
|
+
_agentSystemPrompt = systemPrompt;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ── SNIP TOOL ─────────────────────────────────────────────────────────────────
|
|
586
|
+
// The model calls this to surgically remove specific turns from context
|
|
587
|
+
// loop.js handles the actual splice — this is a signal tool
|
|
588
|
+
export const SnipTool = {
|
|
589
|
+
name: 'Snip',
|
|
590
|
+
description: `Remove specific conversation turns from context to free up space, without losing the whole conversation like /compact does.
|
|
591
|
+
|
|
592
|
+
Use when:
|
|
593
|
+
- A specific tool result was very large and is no longer needed (e.g. a full file read you've already processed)
|
|
594
|
+
- An early exploration phase produced lots of output that's no longer relevant
|
|
595
|
+
- You want to keep recent context but drop stale earlier parts
|
|
596
|
+
|
|
597
|
+
The 'indices' are 0-based positions in the conversation (0 = first user message after system prompt).
|
|
598
|
+
Use /status to see current message count, then pick which to snip.`,
|
|
599
|
+
input_schema: {
|
|
600
|
+
type: 'object',
|
|
601
|
+
properties: {
|
|
602
|
+
indices: {
|
|
603
|
+
type: 'array',
|
|
604
|
+
items: { type: 'number' },
|
|
605
|
+
description: 'Array of 0-based message indices to remove from context'
|
|
606
|
+
},
|
|
607
|
+
reason: { type: 'string', description: 'Why you are snipping these (logged for transparency)' }
|
|
608
|
+
},
|
|
609
|
+
required: ['indices']
|
|
610
|
+
},
|
|
611
|
+
async run({ indices, reason }) {
|
|
612
|
+
// Actual snipping happens in agentLoop — this signals intent
|
|
613
|
+
return JSON.stringify({ __snip__: true, indices, reason: reason || 'context management' });
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
// ── BRIEF TOOL ────────────────────────────────────────────────────────────────
|
|
618
|
+
export const BriefTool = {
|
|
619
|
+
name: 'Brief',
|
|
620
|
+
description: `Write a self-briefing note to yourself about the current task state. This gets injected into context on the next turn, ensuring nothing is forgotten even if context is trimmed.
|
|
621
|
+
|
|
622
|
+
Use after major milestones in long tasks. Different from Checkpoint — Brief is for your own working memory, not user-visible progress.
|
|
623
|
+
|
|
624
|
+
Example:
|
|
625
|
+
Brief({ content: "Working on auth refactor. Done: DB schema updated, sessions table migrated. Current: updating login route. Not done: logout, password reset. Key discovery: sessions table uses TEXT not UUID for user_id — do not cast." })`,
|
|
626
|
+
input_schema: {
|
|
627
|
+
type: 'object',
|
|
628
|
+
properties: {
|
|
629
|
+
content: { type: 'string', description: 'Your working memory note — what you know, what is done, what is next, what to watch out for' }
|
|
630
|
+
},
|
|
631
|
+
required: ['content']
|
|
632
|
+
},
|
|
633
|
+
async run({ content }) {
|
|
634
|
+
try {
|
|
635
|
+
if (!_sessionDir) return 'Brief recorded (no session initialized)';
|
|
636
|
+
// Reuse checkpoint mechanism — both go to session context
|
|
637
|
+
updateContext(_sessionDir, `[BRIEF]\n${content}`);
|
|
638
|
+
return 'Brief saved to working memory.';
|
|
639
|
+
} catch (e) {
|
|
640
|
+
return `Brief recorded (write failed: ${e.message})`;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
export const ALL_TOOLS = [
|
|
646
|
+
BashTool, ReadTool, WriteTool, EditTool, GlobTool, GrepTool,
|
|
647
|
+
NoteTool, CheckpointTool,
|
|
648
|
+
WebFetchTool, WebSearchTool,
|
|
649
|
+
TodoWriteTool, AskUserTool,
|
|
650
|
+
AgentTool, SnipTool, BriefTool
|
|
651
|
+
];
|