tsunami-code 2.6.1 → 2.8.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 +148 -17
- package/lib/loop.js +75 -2
- 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, setModel, getModel } 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.8.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,75 @@ 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
|
+
['/model [name]', 'Show or change active model (default: local)'],
|
|
373
|
+
['/exit', 'Exit'],
|
|
374
|
+
];
|
|
375
|
+
for (const [c, desc] of cmds) {
|
|
376
|
+
console.log(` ${cyan(c.padEnd(22))} ${dim(desc)}`);
|
|
377
|
+
}
|
|
378
|
+
console.log();
|
|
379
|
+
}
|
|
380
|
+
break;
|
|
381
|
+
case 'compact':
|
|
382
|
+
await compactMessages(rest.join(' '));
|
|
383
|
+
break;
|
|
384
|
+
case 'plan':
|
|
385
|
+
planMode = !planMode;
|
|
386
|
+
if (planMode) console.log(yellow(' Plan mode ON — read-only, no writes or execution.\n'));
|
|
387
|
+
else console.log(green(' Plan mode OFF — full capabilities restored.\n'));
|
|
388
|
+
rl.setPrompt(planMode ? yellow('❯ [plan] ') : cyan('❯ '));
|
|
389
|
+
break;
|
|
390
|
+
case 'undo': {
|
|
391
|
+
const restored = undo();
|
|
392
|
+
if (restored) console.log(green(` ✓ Restored: ${restored}\n`));
|
|
393
|
+
else console.log(dim(' Nothing to undo.\n'));
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
case 'doctor': {
|
|
397
|
+
const { getRgPath } = await import('./lib/preflight.js');
|
|
398
|
+
const { getMemoryStats: _getMemoryStats } = await import('./lib/memory.js');
|
|
399
|
+
|
|
400
|
+
const nodeVer = process.versions.node;
|
|
401
|
+
const nodeMajor = parseInt(nodeVer.split('.')[0]);
|
|
402
|
+
const rgPath = getRgPath();
|
|
403
|
+
const stats = _getMemoryStats(cwd);
|
|
404
|
+
const hasTsunami = existsSync(join(cwd, '.tsunami'));
|
|
405
|
+
const hasTsunamiMd = existsSync(join(cwd, 'TSUNAMI.md'));
|
|
406
|
+
const serverOk = await checkServer(currentServerUrl).catch(() => false);
|
|
407
|
+
|
|
408
|
+
console.log(blue('\n 🩺 Tsunami Code Diagnostics\n'));
|
|
409
|
+
console.log((nodeMajor >= 18 ? green : red)(` ${nodeMajor >= 18 ? '✓' : '✗'} Node.js v${nodeVer}`));
|
|
410
|
+
console.log((serverOk ? green : red)(` ${serverOk ? '✓' : '✗'} Server: ${currentServerUrl}`));
|
|
411
|
+
console.log((rgPath ? green : yellow)(` ${rgPath ? '✓' : '⚠'} ripgrep: ${rgPath || 'not found'}`));
|
|
412
|
+
console.log((hasTsunami ? green : dim)(` ${hasTsunami ? '✓' : '○'} Project memory: ${hasTsunami ? `.tsunami/ (${stats.projectMemoryFiles} files, ${formatBytes(stats.projectMemorySize)})` : 'none'}`));
|
|
413
|
+
console.log((hasTsunamiMd ? green : yellow)(` ${hasTsunamiMd ? '✓' : '⚠'} TSUNAMI.md: ${hasTsunamiMd ? 'found' : 'not found (add one for project instructions)'}`));
|
|
414
|
+
console.log(dim(`\n Session : ${sessionId}`));
|
|
415
|
+
console.log(dim(` Version : tsunami-code v${VERSION}`));
|
|
416
|
+
console.log(dim(` CWD : ${cwd}\n`));
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
case 'cost':
|
|
420
|
+
console.log(blue('\n Session Token Estimate'));
|
|
421
|
+
console.log(dim(` Input : ~${_inputTokens.toLocaleString()}`));
|
|
422
|
+
console.log(dim(` Output : ~${_outputTokens.toLocaleString()}`));
|
|
423
|
+
console.log(dim(` Total : ~${(_inputTokens + _outputTokens).toLocaleString()}`));
|
|
424
|
+
console.log(dim(' (Estimates only)\n'));
|
|
315
425
|
break;
|
|
316
426
|
case 'clear':
|
|
317
427
|
resetSession();
|
|
@@ -328,6 +438,14 @@ async function run() {
|
|
|
328
438
|
console.log(dim(` Current server: ${currentServerUrl}\n`));
|
|
329
439
|
}
|
|
330
440
|
break;
|
|
441
|
+
case 'model':
|
|
442
|
+
if (rest[0]) {
|
|
443
|
+
setModel(rest[0]);
|
|
444
|
+
console.log(green(` Model changed to: ${rest[0]}\n`));
|
|
445
|
+
} else {
|
|
446
|
+
console.log(dim(` Current model: ${getModel()}\n`));
|
|
447
|
+
}
|
|
448
|
+
break;
|
|
331
449
|
case 'memory':
|
|
332
450
|
await handleMemoryCommand(rest);
|
|
333
451
|
break;
|
|
@@ -371,14 +489,27 @@ async function run() {
|
|
|
371
489
|
printToolCall(toolName, toolArgs);
|
|
372
490
|
firstToken = true;
|
|
373
491
|
},
|
|
374
|
-
{ sessionDir, cwd }
|
|
492
|
+
{ sessionDir, cwd, planMode },
|
|
493
|
+
makeConfirmCallback(rl)
|
|
375
494
|
);
|
|
376
495
|
|
|
496
|
+
// Token estimation
|
|
497
|
+
const inputChars = fullMessages.reduce((s, m) => s + (typeof m.content === 'string' ? m.content.length : 0), 0);
|
|
498
|
+
_inputTokens += Math.round(inputChars / 4);
|
|
499
|
+
const outputChars = fullMessages.filter(m => m.role === 'assistant').reduce((s, m) => s + (m.content?.length || 0), 0);
|
|
500
|
+
_outputTokens = Math.round(outputChars / 4);
|
|
501
|
+
|
|
377
502
|
// Update messages from the loop (fullMessages was mutated by agentLoop)
|
|
378
503
|
// Trim to rolling window: keep system + last 8 entries
|
|
379
504
|
const loopMessages = fullMessages.slice(1); // drop system, agentLoop added to fullMessages
|
|
380
505
|
messages = trimMessages([{ role: 'system', content: systemPrompt }, ...loopMessages]).slice(1);
|
|
381
506
|
|
|
507
|
+
// Auto-compact
|
|
508
|
+
if (messages.length > 24) {
|
|
509
|
+
await compactMessages();
|
|
510
|
+
console.log(dim(' ↯ Auto-compacted\n'));
|
|
511
|
+
}
|
|
512
|
+
|
|
382
513
|
process.stdout.write('\n\n');
|
|
383
514
|
} catch (e) {
|
|
384
515
|
process.stdout.write('\n');
|
package/lib/loop.js
CHANGED
|
@@ -7,9 +7,33 @@ 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
|
|
|
32
|
+
// Current model identifier — changeable at runtime via /model command
|
|
33
|
+
let _currentModel = 'local';
|
|
34
|
+
export function setModel(model) { _currentModel = model; }
|
|
35
|
+
export function getModel() { return _currentModel; }
|
|
36
|
+
|
|
13
37
|
// Parse tool calls from any format the model might produce
|
|
14
38
|
function parseToolCalls(content) {
|
|
15
39
|
const calls = [];
|
|
@@ -180,7 +204,7 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
|
|
|
180
204
|
method: 'POST',
|
|
181
205
|
headers: { 'Content-Type': 'application/json' },
|
|
182
206
|
body: JSON.stringify({
|
|
183
|
-
model:
|
|
207
|
+
model: _currentModel,
|
|
184
208
|
messages: finalMessages,
|
|
185
209
|
stream: true,
|
|
186
210
|
temperature: 0.1,
|
|
@@ -226,7 +250,35 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
|
|
|
226
250
|
* @param {{ sessionDir: string, cwd: string } | null} sessionInfo
|
|
227
251
|
* @param {number} maxIterations
|
|
228
252
|
*/
|
|
229
|
-
export async function
|
|
253
|
+
export async function quickCompletion(serverUrl, systemPrompt, userMessage) {
|
|
254
|
+
const controller = new AbortController();
|
|
255
|
+
const timer = setTimeout(() => controller.abort(), 12000);
|
|
256
|
+
try {
|
|
257
|
+
const res = await fetch(`${serverUrl}/v1/chat/completions`, {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
signal: controller.signal,
|
|
260
|
+
headers: { 'Content-Type': 'application/json' },
|
|
261
|
+
body: JSON.stringify({
|
|
262
|
+
model: _currentModel,
|
|
263
|
+
messages: [
|
|
264
|
+
{ role: 'system', content: systemPrompt },
|
|
265
|
+
{ role: 'user', content: userMessage }
|
|
266
|
+
],
|
|
267
|
+
stream: false,
|
|
268
|
+
temperature: 0.1,
|
|
269
|
+
max_tokens: 600
|
|
270
|
+
})
|
|
271
|
+
});
|
|
272
|
+
clearTimeout(timer);
|
|
273
|
+
const json = await res.json();
|
|
274
|
+
return json.choices?.[0]?.message?.content || '';
|
|
275
|
+
} catch {
|
|
276
|
+
clearTimeout(timer);
|
|
277
|
+
return '';
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessionInfo = null, confirmCallback = null, maxIterations = 15) {
|
|
230
282
|
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
|
|
231
283
|
const currentTask = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '';
|
|
232
284
|
|
|
@@ -258,6 +310,27 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
|
|
|
258
310
|
|
|
259
311
|
const results = [];
|
|
260
312
|
for (const tc of toolCalls) {
|
|
313
|
+
// Plan mode: block Write, Edit, Bash
|
|
314
|
+
if (sessionInfo?.planMode && ['Write', 'Edit', 'Bash'].includes(tc.name)) {
|
|
315
|
+
results.push(`[${tc.name} result]\n[PLAN MODE] ${tc.name} is disabled. Describe what you would do instead.`);
|
|
316
|
+
onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Dangerous command confirmation
|
|
321
|
+
if (tc.name === 'Bash' && confirmCallback) {
|
|
322
|
+
const parsed = typeof tc.arguments === 'string' ? JSON.parse(tc.arguments) : tc.arguments;
|
|
323
|
+
const normalized = normalizeArgs(parsed);
|
|
324
|
+
if (isDangerous(normalized.command)) {
|
|
325
|
+
const ok = await confirmCallback(normalized.command);
|
|
326
|
+
if (!ok) {
|
|
327
|
+
onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
|
|
328
|
+
results.push(`[${tc.name} result]\nCommand blocked by user. Find a safer approach to accomplish this.`);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
261
334
|
onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
|
|
262
335
|
const result = await runTool(tc.name, tc.arguments, sessionInfo, sessionFiles);
|
|
263
336
|
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;
|