tsunami-code 2.6.0 → 2.7.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/.tsunami/CODEBASE.md +8 -0
- package/.tsunami/memory/package.json.md +8 -0
- package/.tsunami/memory/schema.ts.md +8 -0
- package/index.js +153 -18
- package/lib/loop.js +117 -62
- package/lib/tools.js +26 -2
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -4,10 +4,10 @@ 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 } from './lib/loop.js';
|
|
7
|
+
import { agentLoop, quickCompletion } from './lib/loop.js';
|
|
8
8
|
import { buildSystemPrompt } from './lib/prompt.js';
|
|
9
|
-
import { runPreflight } from './lib/preflight.js';
|
|
10
|
-
import { setSession } from './lib/tools.js';
|
|
9
|
+
import { runPreflight, checkServer } from './lib/preflight.js';
|
|
10
|
+
import { setSession, undo, undoStackSize } from './lib/tools.js';
|
|
11
11
|
import {
|
|
12
12
|
initSession,
|
|
13
13
|
initProjectMemory,
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
getSessionContext
|
|
21
21
|
} from './lib/memory.js';
|
|
22
22
|
|
|
23
|
-
const VERSION = '2.
|
|
23
|
+
const VERSION = '2.7.0';
|
|
24
24
|
const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
|
|
25
25
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
26
26
|
const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
|
|
@@ -115,7 +115,7 @@ if (argv.includes('--help') || argv.includes('-h')) {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
if (argv.includes('--version') || argv.includes('-v')) {
|
|
118
|
-
console.log(`
|
|
118
|
+
console.log(`tsunami v${VERSION}`);
|
|
119
119
|
process.exit(0);
|
|
120
120
|
}
|
|
121
121
|
|
|
@@ -129,10 +129,29 @@ if (setServerIdx !== -1 && argv[setServerIdx + 1]) {
|
|
|
129
129
|
process.exit(0);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
// ── Confirm Callback (dangerous command prompt) ─────────────────────────────
|
|
133
|
+
function makeConfirmCallback(rl) {
|
|
134
|
+
return async (cmd) => {
|
|
135
|
+
return new Promise((resolve) => {
|
|
136
|
+
rl.pause();
|
|
137
|
+
process.stdout.write(`\n ${yellow('⚠ Dangerous:')} ${dim(cmd.slice(0, 120))}\n`);
|
|
138
|
+
process.stdout.write(` ${yellow('Proceed?')} ${dim('(y/N) ')}`);
|
|
139
|
+
const handler = (data) => {
|
|
140
|
+
process.stdin.removeListener('data', handler);
|
|
141
|
+
rl.resume();
|
|
142
|
+
process.stdout.write('\n');
|
|
143
|
+
resolve(data.toString().trim().toLowerCase() === 'y');
|
|
144
|
+
};
|
|
145
|
+
process.stdin.once('data', handler);
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
132
150
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
133
151
|
async function run() {
|
|
134
152
|
const serverUrl = getServerUrl(argv);
|
|
135
153
|
const cwd = process.cwd();
|
|
154
|
+
let planMode = argv.includes('--plan');
|
|
136
155
|
|
|
137
156
|
// Initialize memory systems
|
|
138
157
|
const { sessionId, sessionDir } = initSession(cwd);
|
|
@@ -168,6 +187,39 @@ async function run() {
|
|
|
168
187
|
let currentServerUrl = serverUrl;
|
|
169
188
|
let messages = [];
|
|
170
189
|
let systemPrompt = buildSystemPrompt();
|
|
190
|
+
let _inputTokens = 0;
|
|
191
|
+
let _outputTokens = 0;
|
|
192
|
+
|
|
193
|
+
// --resume: inject last session summary
|
|
194
|
+
if (argv.includes('--resume')) {
|
|
195
|
+
const lastSummary = getLastSessionSummary(cwd);
|
|
196
|
+
if (lastSummary) {
|
|
197
|
+
console.log(dim(' Resuming from last session...\n'));
|
|
198
|
+
messages.push({ role: 'user', content: `[Resuming]\n\n${lastSummary}` });
|
|
199
|
+
messages.push({ role: 'assistant', content: 'Understood, I have the previous session context. Ready to continue.' });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Compact messages helper
|
|
204
|
+
async function compactMessages(focus = '') {
|
|
205
|
+
if (messages.length < 3) { console.log(dim(' Nothing substantial to compact.\n')); return; }
|
|
206
|
+
process.stdout.write(dim(' ↯ Compacting...'));
|
|
207
|
+
const historyText = messages
|
|
208
|
+
.filter(m => !['system'].includes(m.role))
|
|
209
|
+
.map(m => {
|
|
210
|
+
const c = typeof m.content === 'string' ? m.content.slice(0, 250) : '[tool result]';
|
|
211
|
+
return `${m.role.toUpperCase()}: ${c}`;
|
|
212
|
+
}).join('\n\n');
|
|
213
|
+
const focusLine = focus ? ` Focus on: ${focus}.` : '';
|
|
214
|
+
const prompt = `Summarize this conversation in 4-6 bullet points. Preserve: goal, files changed, key decisions, current progress, next step.${focusLine}\n\n${historyText}`;
|
|
215
|
+
const summary = await quickCompletion(currentServerUrl, buildSystemPrompt(), prompt);
|
|
216
|
+
const before = messages.length;
|
|
217
|
+
messages = summary
|
|
218
|
+
? [{ role: 'user', content: `[Compacted — ${before} messages]\n\n${summary}` }, { role: 'assistant', content: 'Understood, continuing.' }]
|
|
219
|
+
: messages.slice(-4);
|
|
220
|
+
process.stdout.write('\r' + ' '.repeat(25) + '\r');
|
|
221
|
+
console.log(green(' ✓ Compacted') + dim(` ${before} → ${messages.length} messages\n`));
|
|
222
|
+
}
|
|
171
223
|
|
|
172
224
|
function resetSession() {
|
|
173
225
|
messages = [];
|
|
@@ -186,7 +238,7 @@ async function run() {
|
|
|
186
238
|
const rl = readline.createInterface({
|
|
187
239
|
input: process.stdin,
|
|
188
240
|
output: process.stdout,
|
|
189
|
-
prompt: cyan('❯ '),
|
|
241
|
+
prompt: planMode ? yellow('❯ [plan] ') : cyan('❯ '),
|
|
190
242
|
terminal: process.stdin.isTTY
|
|
191
243
|
});
|
|
192
244
|
|
|
@@ -255,6 +307,19 @@ async function run() {
|
|
|
255
307
|
return;
|
|
256
308
|
}
|
|
257
309
|
|
|
310
|
+
if (sub === 'last') {
|
|
311
|
+
const { getLastSessionSummary } = await import('./lib/memory.js');
|
|
312
|
+
const summary = getLastSessionSummary(cwd);
|
|
313
|
+
if (!summary) {
|
|
314
|
+
console.log(dim(' No previous session summary found.\n'));
|
|
315
|
+
} else {
|
|
316
|
+
console.log(blue('\n Last Session Summary'));
|
|
317
|
+
console.log(dim(' ' + summary.replace(/\n/g, '\n ')));
|
|
318
|
+
console.log();
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
258
323
|
if (sub === 'clear') {
|
|
259
324
|
// Reinitialize session (clear session memory, keep project memory)
|
|
260
325
|
resetSession();
|
|
@@ -288,17 +353,74 @@ async function run() {
|
|
|
288
353
|
|
|
289
354
|
switch (cmd) {
|
|
290
355
|
case 'help':
|
|
291
|
-
console.log(
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
356
|
+
console.log(blue('\n Tsunami Code CLI — Commands\n'));
|
|
357
|
+
{
|
|
358
|
+
const cmds = [
|
|
359
|
+
['/compact [focus]', 'Summarize context and continue'],
|
|
360
|
+
['/plan', 'Toggle read-only plan mode'],
|
|
361
|
+
['/undo', 'Undo last file change'],
|
|
362
|
+
['/doctor', 'Run health diagnostics'],
|
|
363
|
+
['/cost', 'Show token usage estimate'],
|
|
364
|
+
['/memory', 'Show project memory stats'],
|
|
365
|
+
['/memory files', 'List files with memory'],
|
|
366
|
+
['/memory view <f>', 'Show memory for a file'],
|
|
367
|
+
['/memory last', 'Show last session summary'],
|
|
368
|
+
['/memory clear', 'Clear session memory'],
|
|
369
|
+
['/clear', 'Start new conversation'],
|
|
370
|
+
['/status', 'Show context size and server'],
|
|
371
|
+
['/server <url>', 'Change model server URL'],
|
|
372
|
+
['/exit', 'Exit'],
|
|
373
|
+
];
|
|
374
|
+
for (const [c, desc] of cmds) {
|
|
375
|
+
console.log(` ${cyan(c.padEnd(22))} ${dim(desc)}`);
|
|
376
|
+
}
|
|
377
|
+
console.log();
|
|
378
|
+
}
|
|
379
|
+
break;
|
|
380
|
+
case 'compact':
|
|
381
|
+
await compactMessages(rest.join(' '));
|
|
382
|
+
break;
|
|
383
|
+
case 'plan':
|
|
384
|
+
planMode = !planMode;
|
|
385
|
+
if (planMode) console.log(yellow(' Plan mode ON — read-only, no writes or execution.\n'));
|
|
386
|
+
else console.log(green(' Plan mode OFF — full capabilities restored.\n'));
|
|
387
|
+
rl.setPrompt(planMode ? yellow('❯ [plan] ') : cyan('❯ '));
|
|
388
|
+
break;
|
|
389
|
+
case 'undo': {
|
|
390
|
+
const restored = undo();
|
|
391
|
+
if (restored) console.log(green(` ✓ Restored: ${restored}\n`));
|
|
392
|
+
else console.log(dim(' Nothing to undo.\n'));
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
case 'doctor': {
|
|
396
|
+
const { getRgPath } = await import('./lib/preflight.js');
|
|
397
|
+
const { getMemoryStats: _getMemoryStats } = await import('./lib/memory.js');
|
|
398
|
+
|
|
399
|
+
const nodeVer = process.versions.node;
|
|
400
|
+
const nodeMajor = parseInt(nodeVer.split('.')[0]);
|
|
401
|
+
const rgPath = getRgPath();
|
|
402
|
+
const stats = _getMemoryStats(cwd);
|
|
403
|
+
const hasTsunami = existsSync(join(cwd, '.tsunami'));
|
|
404
|
+
const hasTsunamiMd = existsSync(join(cwd, 'TSUNAMI.md'));
|
|
405
|
+
const serverOk = await checkServer(currentServerUrl).catch(() => false);
|
|
406
|
+
|
|
407
|
+
console.log(blue('\n 🩺 Tsunami Code Diagnostics\n'));
|
|
408
|
+
console.log((nodeMajor >= 18 ? green : red)(` ${nodeMajor >= 18 ? '✓' : '✗'} Node.js v${nodeVer}`));
|
|
409
|
+
console.log((serverOk ? green : red)(` ${serverOk ? '✓' : '✗'} Server: ${currentServerUrl}`));
|
|
410
|
+
console.log((rgPath ? green : yellow)(` ${rgPath ? '✓' : '⚠'} ripgrep: ${rgPath || 'not found'}`));
|
|
411
|
+
console.log((hasTsunami ? green : dim)(` ${hasTsunami ? '✓' : '○'} Project memory: ${hasTsunami ? `.tsunami/ (${stats.projectMemoryFiles} files, ${formatBytes(stats.projectMemorySize)})` : 'none'}`));
|
|
412
|
+
console.log((hasTsunamiMd ? green : yellow)(` ${hasTsunamiMd ? '✓' : '⚠'} TSUNAMI.md: ${hasTsunamiMd ? 'found' : 'not found (add one for project instructions)'}`));
|
|
413
|
+
console.log(dim(`\n Session : ${sessionId}`));
|
|
414
|
+
console.log(dim(` Version : tsunami-code v${VERSION}`));
|
|
415
|
+
console.log(dim(` CWD : ${cwd}\n`));
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
case 'cost':
|
|
419
|
+
console.log(blue('\n Session Token Estimate'));
|
|
420
|
+
console.log(dim(` Input : ~${_inputTokens.toLocaleString()}`));
|
|
421
|
+
console.log(dim(` Output : ~${_outputTokens.toLocaleString()}`));
|
|
422
|
+
console.log(dim(` Total : ~${(_inputTokens + _outputTokens).toLocaleString()}`));
|
|
423
|
+
console.log(dim(' (Estimates only)\n'));
|
|
302
424
|
break;
|
|
303
425
|
case 'clear':
|
|
304
426
|
resetSession();
|
|
@@ -358,14 +480,27 @@ async function run() {
|
|
|
358
480
|
printToolCall(toolName, toolArgs);
|
|
359
481
|
firstToken = true;
|
|
360
482
|
},
|
|
361
|
-
{ sessionDir, cwd }
|
|
483
|
+
{ sessionDir, cwd, planMode },
|
|
484
|
+
makeConfirmCallback(rl)
|
|
362
485
|
);
|
|
363
486
|
|
|
487
|
+
// Token estimation
|
|
488
|
+
const inputChars = fullMessages.reduce((s, m) => s + (typeof m.content === 'string' ? m.content.length : 0), 0);
|
|
489
|
+
_inputTokens += Math.round(inputChars / 4);
|
|
490
|
+
const outputChars = fullMessages.filter(m => m.role === 'assistant').reduce((s, m) => s + (m.content?.length || 0), 0);
|
|
491
|
+
_outputTokens = Math.round(outputChars / 4);
|
|
492
|
+
|
|
364
493
|
// Update messages from the loop (fullMessages was mutated by agentLoop)
|
|
365
494
|
// Trim to rolling window: keep system + last 8 entries
|
|
366
495
|
const loopMessages = fullMessages.slice(1); // drop system, agentLoop added to fullMessages
|
|
367
496
|
messages = trimMessages([{ role: 'system', content: systemPrompt }, ...loopMessages]).slice(1);
|
|
368
497
|
|
|
498
|
+
// Auto-compact
|
|
499
|
+
if (messages.length > 24) {
|
|
500
|
+
await compactMessages();
|
|
501
|
+
console.log(dim(' ↯ Auto-compacted\n'));
|
|
502
|
+
}
|
|
503
|
+
|
|
369
504
|
process.stdout.write('\n\n');
|
|
370
505
|
} catch (e) {
|
|
371
506
|
process.stdout.write('\n');
|
package/lib/loop.js
CHANGED
|
@@ -3,11 +3,32 @@ import { ALL_TOOLS } from './tools.js';
|
|
|
3
3
|
import {
|
|
4
4
|
assembleContext,
|
|
5
5
|
extractFilePaths,
|
|
6
|
-
logFileAccess,
|
|
7
6
|
logFileChange,
|
|
8
7
|
appendDecision
|
|
9
8
|
} from './memory.js';
|
|
10
9
|
|
|
10
|
+
// ── Dangerous command detection ──────────────────────────────────────────────
|
|
11
|
+
const DANGEROUS_PATTERNS = [
|
|
12
|
+
/rm\s+-[rf]+\s+[^-]/,
|
|
13
|
+
/DROP\s+TABLE/i,
|
|
14
|
+
/DROP\s+DATABASE/i,
|
|
15
|
+
/DROP\s+SCHEMA/i,
|
|
16
|
+
/ALTER\s+TABLE.*DROP\s+COLUMN/i,
|
|
17
|
+
/git\s+push\s+.*--force/,
|
|
18
|
+
/git\s+push\s+-f\b/,
|
|
19
|
+
/git\s+reset\s+--hard/,
|
|
20
|
+
/del\s+\/[sf]/i,
|
|
21
|
+
/Remove-Item.*-Recurse.*-Force/i,
|
|
22
|
+
/truncate\s+table/i,
|
|
23
|
+
/delete\s+from\s+\w+\s*;?\s*$/i,
|
|
24
|
+
];
|
|
25
|
+
function isDangerous(cmd) {
|
|
26
|
+
return DANGEROUS_PATTERNS.some(p => p.test(cmd || ''));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Skip waitForServer after first successful connection
|
|
30
|
+
let _serverVerified = false;
|
|
31
|
+
|
|
11
32
|
// Parse tool calls from any format the model might produce
|
|
12
33
|
function parseToolCalls(content) {
|
|
13
34
|
const calls = [];
|
|
@@ -88,10 +109,6 @@ function normalizeArgs(args) {
|
|
|
88
109
|
return out;
|
|
89
110
|
}
|
|
90
111
|
|
|
91
|
-
/**
|
|
92
|
-
* Detect key patterns in Bash commands and return a decision string, or null.
|
|
93
|
-
* We log psql, npm, pm2, git, and deploy-related commands.
|
|
94
|
-
*/
|
|
95
112
|
function bashDecisionHint(cmd) {
|
|
96
113
|
if (!cmd) return null;
|
|
97
114
|
const c = cmd.trim();
|
|
@@ -105,26 +122,31 @@ function bashDecisionHint(cmd) {
|
|
|
105
122
|
return null;
|
|
106
123
|
}
|
|
107
124
|
|
|
108
|
-
|
|
125
|
+
// Only log decisions that are actually meaningful
|
|
126
|
+
function meaningfulDecision(toolName, args) {
|
|
127
|
+
if (toolName === 'Note') return `NOTE: ${(args.note || '').slice(0, 120)}`;
|
|
128
|
+
if (toolName === 'Checkpoint') return `CHECKPOINT: ${(args.content || '').split('\n')[0].slice(0, 120)}`;
|
|
129
|
+
if (toolName === 'Write') return `WROTE: ${args.file_path} (${(args.content || '').split('\n').length} lines)`;
|
|
130
|
+
if (toolName === 'Edit') return `EDITED: ${args.file_path} — replaced "${(args.old_string || '').slice(0, 60).replace(/\n/g, '↵')}"`;
|
|
131
|
+
if (toolName === 'Bash') return bashDecisionHint(args.command);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function runTool(name, args, sessionInfo, sessionFiles) {
|
|
109
136
|
const tool = ALL_TOOLS.find(t => t.name === name);
|
|
110
137
|
if (!tool) return `Error: Unknown tool "${name}"`;
|
|
111
138
|
try {
|
|
112
139
|
const parsed = typeof args === 'string' ? JSON.parse(args) : args;
|
|
113
140
|
const normalized = normalizeArgs(parsed);
|
|
114
141
|
|
|
115
|
-
// Auto-capture memory BEFORE running the tool (for access/change logging)
|
|
116
|
-
if (sessionInfo) {
|
|
117
|
-
const { sessionDir, cwd } = sessionInfo;
|
|
118
|
-
try {
|
|
119
|
-
if (name === 'Read' && normalized.file_path) {
|
|
120
|
-
logFileAccess(cwd, normalized.file_path);
|
|
121
|
-
}
|
|
122
|
-
} catch {}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
142
|
const result = await tool.run(normalized);
|
|
126
143
|
|
|
127
|
-
// Auto-capture
|
|
144
|
+
// Auto-capture: track files touched for context assembly
|
|
145
|
+
if (sessionFiles && normalized.file_path) {
|
|
146
|
+
sessionFiles.add(normalized.file_path);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Auto-capture: write/edit → log to project memory
|
|
128
150
|
if (sessionInfo) {
|
|
129
151
|
const { sessionDir, cwd } = sessionInfo;
|
|
130
152
|
try {
|
|
@@ -134,10 +156,10 @@ async function runTool(name, args, sessionInfo) {
|
|
|
134
156
|
} else if (name === 'Edit' && normalized.file_path) {
|
|
135
157
|
const preview = (normalized.old_string || '').slice(0, 60).replace(/\n/g, '↵');
|
|
136
158
|
logFileChange(cwd, normalized.file_path, `Edited: replaced "${preview}"`);
|
|
137
|
-
} else if (name === 'Bash') {
|
|
138
|
-
const hint = bashDecisionHint(normalized.command);
|
|
139
|
-
if (hint) appendDecision(sessionDir, hint);
|
|
140
159
|
}
|
|
160
|
+
// Log only meaningful decisions — not every Read/Glob/Grep
|
|
161
|
+
const decision = meaningfulDecision(name, normalized);
|
|
162
|
+
if (decision) appendDecision(sessionDir, decision);
|
|
141
163
|
} catch {}
|
|
142
164
|
}
|
|
143
165
|
|
|
@@ -147,24 +169,20 @@ async function runTool(name, args, sessionInfo) {
|
|
|
147
169
|
}
|
|
148
170
|
}
|
|
149
171
|
|
|
150
|
-
async function waitForServer(serverUrl, retries =
|
|
172
|
+
async function waitForServer(serverUrl, retries = 5, delay = 1000) {
|
|
151
173
|
for (let i = 0; i < retries; i++) {
|
|
152
174
|
try {
|
|
153
175
|
const r = await fetch(`${serverUrl}/health`);
|
|
154
176
|
const j = await r.json();
|
|
155
|
-
if (j.status === 'ok') return;
|
|
177
|
+
if (j.status === 'ok') { _serverVerified = true; return; }
|
|
156
178
|
} catch {}
|
|
157
179
|
await new Promise(r => setTimeout(r, delay));
|
|
158
180
|
}
|
|
159
181
|
throw new Error(`Model server not responding at ${serverUrl}`);
|
|
160
182
|
}
|
|
161
183
|
|
|
162
|
-
/**
|
|
163
|
-
* Stream a completion from the model server.
|
|
164
|
-
* Injects memoryContext into the system message if provided.
|
|
165
|
-
*/
|
|
166
184
|
async function streamCompletion(serverUrl, messages, onToken, memoryContext = '') {
|
|
167
|
-
await waitForServer(serverUrl
|
|
185
|
+
if (!_serverVerified) await waitForServer(serverUrl);
|
|
168
186
|
|
|
169
187
|
// Inject memory context into the system message (first message with role=system)
|
|
170
188
|
let finalMessages = messages;
|
|
@@ -227,55 +245,90 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
|
|
|
227
245
|
* @param {{ sessionDir: string, cwd: string } | null} sessionInfo
|
|
228
246
|
* @param {number} maxIterations
|
|
229
247
|
*/
|
|
230
|
-
export async function
|
|
231
|
-
|
|
248
|
+
export async function quickCompletion(serverUrl, systemPrompt, userMessage) {
|
|
249
|
+
const controller = new AbortController();
|
|
250
|
+
const timer = setTimeout(() => controller.abort(), 12000);
|
|
251
|
+
try {
|
|
252
|
+
const res = await fetch(`${serverUrl}/v1/chat/completions`, {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
signal: controller.signal,
|
|
255
|
+
headers: { 'Content-Type': 'application/json' },
|
|
256
|
+
body: JSON.stringify({
|
|
257
|
+
model: 'local',
|
|
258
|
+
messages: [
|
|
259
|
+
{ role: 'system', content: systemPrompt },
|
|
260
|
+
{ role: 'user', content: userMessage }
|
|
261
|
+
],
|
|
262
|
+
stream: false,
|
|
263
|
+
temperature: 0.1,
|
|
264
|
+
max_tokens: 600
|
|
265
|
+
})
|
|
266
|
+
});
|
|
267
|
+
clearTimeout(timer);
|
|
268
|
+
const json = await res.json();
|
|
269
|
+
return json.choices?.[0]?.message?.content || '';
|
|
270
|
+
} catch {
|
|
271
|
+
clearTimeout(timer);
|
|
272
|
+
return '';
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessionInfo = null, confirmCallback = null, maxIterations = 15) {
|
|
232
277
|
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
|
|
233
278
|
const currentTask = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '';
|
|
234
279
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
}
|
|
280
|
+
// Files touched during this turn — fed back into context assembly each iteration
|
|
281
|
+
const sessionFiles = new Set(extractFilePaths(currentTask));
|
|
282
|
+
|
|
283
|
+
// Assemble memory context once before the turn, refresh after files are touched
|
|
284
|
+
const buildMemoryContext = () => {
|
|
285
|
+
if (!sessionInfo) return '';
|
|
286
|
+
try {
|
|
287
|
+
return assembleContext({
|
|
288
|
+
sessionDir: sessionInfo.sessionDir,
|
|
289
|
+
cwd: sessionInfo.cwd,
|
|
290
|
+
currentTask,
|
|
291
|
+
filesToConsider: [...sessionFiles]
|
|
292
|
+
});
|
|
293
|
+
} catch { return ''; }
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
let memoryContext = buildMemoryContext();
|
|
249
297
|
|
|
298
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
250
299
|
const content = await streamCompletion(serverUrl, messages, onToken, memoryContext);
|
|
251
300
|
const toolCalls = parseToolCalls(content);
|
|
252
301
|
|
|
253
302
|
messages.push({ role: 'assistant', content });
|
|
254
303
|
|
|
255
|
-
if (toolCalls.length === 0)
|
|
256
|
-
// Log final assistant response as a decision if it's substantive
|
|
257
|
-
if (sessionInfo && content.length > 50) {
|
|
258
|
-
try {
|
|
259
|
-
const summary = content.slice(0, 150).replace(/\n/g, ' ');
|
|
260
|
-
appendDecision(sessionInfo.sessionDir, `RESPONSE: ${summary}`);
|
|
261
|
-
} catch {}
|
|
262
|
-
}
|
|
263
|
-
break;
|
|
264
|
-
}
|
|
304
|
+
if (toolCalls.length === 0) break;
|
|
265
305
|
|
|
266
306
|
const results = [];
|
|
267
307
|
for (const tc of toolCalls) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
308
|
+
// Plan mode: block Write, Edit, Bash
|
|
309
|
+
if (sessionInfo?.planMode && ['Write', 'Edit', 'Bash'].includes(tc.name)) {
|
|
310
|
+
results.push(`[${tc.name} result]\n[PLAN MODE] ${tc.name} is disabled. Describe what you would do instead.`);
|
|
311
|
+
onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
271
314
|
|
|
272
|
-
//
|
|
273
|
-
if (
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
315
|
+
// Dangerous command confirmation
|
|
316
|
+
if (tc.name === 'Bash' && confirmCallback) {
|
|
317
|
+
const parsed = typeof tc.arguments === 'string' ? JSON.parse(tc.arguments) : tc.arguments;
|
|
318
|
+
const normalized = normalizeArgs(parsed);
|
|
319
|
+
if (isDangerous(normalized.command)) {
|
|
320
|
+
const ok = await confirmCallback(normalized.command);
|
|
321
|
+
if (!ok) {
|
|
322
|
+
onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
|
|
323
|
+
results.push(`[${tc.name} result]\nCommand blocked by user. Find a safer approach to accomplish this.`);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
278
327
|
}
|
|
328
|
+
|
|
329
|
+
onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
|
|
330
|
+
const result = await runTool(tc.name, tc.arguments, sessionInfo, sessionFiles);
|
|
331
|
+
results.push(`[${tc.name} result]\n${String(result).slice(0, 8000)}`);
|
|
279
332
|
}
|
|
280
333
|
|
|
281
334
|
messages.push({
|
|
@@ -283,6 +336,8 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
|
|
|
283
336
|
content: results.join('\n\n---\n\n') + '\n\nContinue with the task.'
|
|
284
337
|
});
|
|
285
338
|
|
|
339
|
+
// Refresh memory context now that new files may have been touched
|
|
340
|
+
memoryContext = buildMemoryContext();
|
|
286
341
|
onToken('\n');
|
|
287
342
|
}
|
|
288
343
|
}
|
package/lib/tools.js
CHANGED
|
@@ -7,6 +7,16 @@ import { addFileNote, updateContext, appendDecision } from './memory.js';
|
|
|
7
7
|
|
|
8
8
|
const execAsync = promisify(exec);
|
|
9
9
|
|
|
10
|
+
// ── Undo Stack ───────────────────────────────────────────────────────────────
|
|
11
|
+
const _undoStack = [];
|
|
12
|
+
const MAX_UNDO = 20;
|
|
13
|
+
export function undo() {
|
|
14
|
+
if (_undoStack.length === 0) return null;
|
|
15
|
+
const { filePath, content } = _undoStack.pop();
|
|
16
|
+
try { writeFileSync(filePath, content, 'utf8'); return filePath; } catch { return null; }
|
|
17
|
+
}
|
|
18
|
+
export function undoStackSize() { return _undoStack.length; }
|
|
19
|
+
|
|
10
20
|
// ── Session Context (set by index.js at startup) ──────────────────────────────
|
|
11
21
|
let _sessionDir = null;
|
|
12
22
|
let _cwd = null;
|
|
@@ -97,6 +107,13 @@ export const WriteTool = {
|
|
|
97
107
|
},
|
|
98
108
|
async run({ file_path, content }) {
|
|
99
109
|
try {
|
|
110
|
+
// Undo stack: save previous content before overwriting
|
|
111
|
+
try {
|
|
112
|
+
if (existsSync(file_path)) {
|
|
113
|
+
_undoStack.push({ filePath: file_path, content: readFileSync(file_path, 'utf8') });
|
|
114
|
+
if (_undoStack.length > MAX_UNDO) _undoStack.shift();
|
|
115
|
+
}
|
|
116
|
+
} catch {}
|
|
100
117
|
writeFileSync(file_path, content, 'utf8');
|
|
101
118
|
return `Written ${content.split('\n').length} lines to ${file_path}`;
|
|
102
119
|
} catch (e) {
|
|
@@ -127,6 +144,11 @@ export const EditTool = {
|
|
|
127
144
|
if (!existsSync(file_path)) return `Error: File not found: ${file_path}`;
|
|
128
145
|
try {
|
|
129
146
|
let content = readFileSync(file_path, 'utf8');
|
|
147
|
+
// Undo stack: save current content before editing
|
|
148
|
+
try {
|
|
149
|
+
_undoStack.push({ filePath: file_path, content });
|
|
150
|
+
if (_undoStack.length > MAX_UNDO) _undoStack.shift();
|
|
151
|
+
} catch {}
|
|
130
152
|
if (!content.includes(old_string)) return `Error: old_string not found in ${file_path}`;
|
|
131
153
|
if (!replace_all) {
|
|
132
154
|
const count = content.split(old_string).length - 1;
|
|
@@ -249,7 +271,9 @@ Set file_path to null for project-wide notes (written to CODEBASE.md).`,
|
|
|
249
271
|
async run({ file_path, note }) {
|
|
250
272
|
try {
|
|
251
273
|
if (!_cwd) return 'Note saved (no project memory initialized yet)';
|
|
252
|
-
|
|
274
|
+
// Normalize: model sometimes passes "null" or "undefined" as a string
|
|
275
|
+
const fp = (!file_path || file_path === 'null' || file_path === 'undefined') ? null : file_path;
|
|
276
|
+
addFileNote(_cwd, fp, note);
|
|
253
277
|
return `Note saved to project memory${file_path ? ` for ${file_path}` : ' (CODEBASE.md)'}.`;
|
|
254
278
|
} catch (e) {
|
|
255
279
|
return `Note recorded (memory write failed silently: ${e.message})`;
|
|
@@ -277,7 +301,7 @@ The content should be a clear summary of:
|
|
|
277
301
|
4. Any blockers or important context
|
|
278
302
|
|
|
279
303
|
EXAMPLE:
|
|
280
|
-
Checkpoint({ content: "Task:
|
|
304
|
+
Checkpoint({ content: "Task: [what the user asked for]\n\nDone:\n- [steps completed so far]\n\nNext: [exact next step]\n\nContext: [any gotchas or decisions made that affect what comes next]" })`,
|
|
281
305
|
input_schema: {
|
|
282
306
|
type: 'object',
|
|
283
307
|
properties: {
|