tsunami-code 2.6.1 → 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/index.js +139 -17
- package/lib/loop.js +69 -1
- package/lib/tools.js +22 -0
- 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';
|
|
@@ -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
|
|
|
@@ -301,17 +353,74 @@ async function run() {
|
|
|
301
353
|
|
|
302
354
|
switch (cmd) {
|
|
303
355
|
case 'help':
|
|
304
|
-
console.log(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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'));
|
|
315
424
|
break;
|
|
316
425
|
case 'clear':
|
|
317
426
|
resetSession();
|
|
@@ -371,14 +480,27 @@ async function run() {
|
|
|
371
480
|
printToolCall(toolName, toolArgs);
|
|
372
481
|
firstToken = true;
|
|
373
482
|
},
|
|
374
|
-
{ sessionDir, cwd }
|
|
483
|
+
{ sessionDir, cwd, planMode },
|
|
484
|
+
makeConfirmCallback(rl)
|
|
375
485
|
);
|
|
376
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
|
+
|
|
377
493
|
// Update messages from the loop (fullMessages was mutated by agentLoop)
|
|
378
494
|
// Trim to rolling window: keep system + last 8 entries
|
|
379
495
|
const loopMessages = fullMessages.slice(1); // drop system, agentLoop added to fullMessages
|
|
380
496
|
messages = trimMessages([{ role: 'system', content: systemPrompt }, ...loopMessages]).slice(1);
|
|
381
497
|
|
|
498
|
+
// Auto-compact
|
|
499
|
+
if (messages.length > 24) {
|
|
500
|
+
await compactMessages();
|
|
501
|
+
console.log(dim(' ↯ Auto-compacted\n'));
|
|
502
|
+
}
|
|
503
|
+
|
|
382
504
|
process.stdout.write('\n\n');
|
|
383
505
|
} catch (e) {
|
|
384
506
|
process.stdout.write('\n');
|
package/lib/loop.js
CHANGED
|
@@ -7,6 +7,25 @@ import {
|
|
|
7
7
|
appendDecision
|
|
8
8
|
} from './memory.js';
|
|
9
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
|
+
|
|
10
29
|
// Skip waitForServer after first successful connection
|
|
11
30
|
let _serverVerified = false;
|
|
12
31
|
|
|
@@ -226,7 +245,35 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
|
|
|
226
245
|
* @param {{ sessionDir: string, cwd: string } | null} sessionInfo
|
|
227
246
|
* @param {number} maxIterations
|
|
228
247
|
*/
|
|
229
|
-
export async function
|
|
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) {
|
|
230
277
|
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
|
|
231
278
|
const currentTask = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '';
|
|
232
279
|
|
|
@@ -258,6 +305,27 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
|
|
|
258
305
|
|
|
259
306
|
const results = [];
|
|
260
307
|
for (const tc of toolCalls) {
|
|
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
|
+
}
|
|
314
|
+
|
|
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
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
261
329
|
onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
|
|
262
330
|
const result = await runTool(tc.name, tc.arguments, sessionInfo, sessionFiles);
|
|
263
331
|
results.push(`[${tc.name} result]\n${String(result).slice(0, 8000)}`);
|
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;
|