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 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.6.1';
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(dim(`
305
- Commands:
306
- /clear Start new conversation
307
- /status Show context size
308
- /server <url> Change server URL for this session
309
- /memory Show memory stats
310
- /memory files List files with memory entries
311
- /memory view <f> Show memory for a file
312
- /memory clear Clear session memory (keep project memory)
313
- /exit Exit
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: 'local',
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 agentLoop(serverUrl, messages, onToken, onToolCall, sessionInfo = null, maxIterations = 15) {
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsunami-code",
3
- "version": "2.6.1",
3
+ "version": "2.8.0",
4
4
  "description": "Tsunami Code CLI — AI coding agent by Keystone World Management Navy Seal Unit XI3",
5
5
  "type": "module",
6
6
  "bin": {