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 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.6.1';
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(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
+ ['/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 agentLoop(serverUrl, messages, onToken, onToolCall, sessionInfo = null, maxIterations = 15) {
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsunami-code",
3
- "version": "2.6.1",
3
+ "version": "2.7.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": {