tsunami-code 3.3.0 → 3.5.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,14 +4,14 @@ 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, quickCompletion, setModel, getModel, tokenStats } from './lib/loop.js';
7
+ import { agentLoop, quickCompletion, setModel, getModel, tokenStats, setTemperature, getTemperature } from './lib/loop.js';
8
8
  import { injectServerContext } from './lib/tools.js';
9
9
  import { loadSkills, getSkillCommand, createSkill, listSkills } from './lib/skills.js';
10
10
  import { isCoordinatorTask, stripCoordinatorPrefix, buildCoordinatorSystemPrompt } from './lib/coordinator.js';
11
11
  import { getDueTasks, markDone, cancelTask, listTasks, formatTaskList } from './lib/kairos.js';
12
12
  import { buildSystemPrompt } from './lib/prompt.js';
13
13
  import { runPreflight, checkServer } from './lib/preflight.js';
14
- import { setSession, undo, undoStackSize, registerMcpTools } from './lib/tools.js';
14
+ import { setSession, undo, undoStackSize, beginUndoTurn, registerMcpTools, linesChanged } from './lib/tools.js';
15
15
  import { connectMcpServers, getMcpToolObjects, getMcpStatus, getMcpConfigPath, disconnectAll as disconnectMcp } from './lib/mcp.js';
16
16
  import {
17
17
  initSession,
@@ -25,7 +25,7 @@ import {
25
25
  getSessionContext
26
26
  } from './lib/memory.js';
27
27
 
28
- const VERSION = '3.3.0';
28
+ const VERSION = '3.5.0';
29
29
  const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
30
30
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
31
31
  const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
@@ -172,6 +172,8 @@ async function run() {
172
172
  const cwd = process.cwd();
173
173
  let planMode = argv.includes('--plan');
174
174
 
175
+ const sessionStartTime = Date.now();
176
+
175
177
  // Initialize memory systems
176
178
  const { sessionId, sessionDir } = initSession(cwd);
177
179
  initProjectMemory(cwd);
@@ -456,7 +458,7 @@ async function run() {
456
458
 
457
459
  // Skills: check if this is a skill command before built-ins
458
460
  const skillMatch = getSkillCommand(skills, line);
459
- if (skillMatch && !['help','compact','plan','undo','doctor','cost','clear','status','server','model','memory','history','exit','quit','skill-create','skill-list','skills','mcp'].includes(cmd)) {
461
+ if (skillMatch && !['help','compact','plan','undo','doctor','cost','clear','status','server','model','memory','history','exit','quit','skill-create','skill-list','skills','mcp','copy','btw','rewind','diff','stats','export','effort'].includes(cmd)) {
460
462
  // Run the skill prompt as a user message
461
463
  const userContent = skillMatch.args
462
464
  ? `[Skill: ${skillMatch.skill.name}]\n${skillMatch.prompt}`
@@ -498,6 +500,13 @@ async function run() {
498
500
  ['/server <url>', 'Change model server URL'],
499
501
  ['/model [name]', 'Show or change active model (default: local)'],
500
502
  ['/mcp', 'Show MCP server status and tools'],
503
+ ['/effort <level>', 'Set reasoning effort: low/medium/high/max'],
504
+ ['/copy', 'Copy last response to clipboard'],
505
+ ['/btw <note>', 'Inject a note into conversation without a response'],
506
+ ['/rewind', 'Remove last user+assistant exchange'],
507
+ ['/diff', 'Show git diff of session file changes'],
508
+ ['/stats', 'Session stats: lines, tokens, duration'],
509
+ ['/export [file]', 'Export conversation to markdown file'],
501
510
  ['/history', 'Show recent command history'],
502
511
  ['/exit', 'Exit'],
503
512
  ];
@@ -518,8 +527,24 @@ async function run() {
518
527
  break;
519
528
  case 'undo': {
520
529
  const restored = undo();
521
- if (restored) console.log(green(` ✓ Restored: ${restored}\n`));
522
- else console.log(dim(' Nothing to undo.\n'));
530
+ if (restored) {
531
+ if (restored.length === 1) console.log(green(` Restored: ${restored[0]}\n`));
532
+ else console.log(green(` ✓ Restored ${restored.length} files:\n`) + restored.map(f => dim(` ${f}`)).join('\n') + '\n');
533
+ } else {
534
+ console.log(dim(' Nothing to undo.\n'));
535
+ }
536
+ break;
537
+ }
538
+ case 'effort': {
539
+ const level = rest[0]?.toLowerCase();
540
+ const levels = { low: 0.5, medium: 0.2, high: 0.05, max: 0.0 };
541
+ if (!level || !levels[level] && levels[level] !== 0) {
542
+ const cur = getTemperature();
543
+ console.log(dim(` Current temperature: ${cur}\n Usage: /effort <low|medium|high|max>\n`));
544
+ break;
545
+ }
546
+ setTemperature(levels[level]);
547
+ console.log(green(` Effort: ${level} (temperature ${levels[level]})\n`));
523
548
  break;
524
549
  }
525
550
  case 'mcp': {
@@ -566,12 +591,20 @@ async function run() {
566
591
  }
567
592
  case 'cost': {
568
593
  const hasReal = tokenStats.requests > 0;
569
- console.log(blue('\n Session Token Usage'));
594
+ console.log(blue('\n Session Token Usage\n'));
570
595
  if (hasReal) {
571
- console.log(dim(` Input : ${tokenStats.input.toLocaleString()} tokens (actual)`));
572
- console.log(dim(` Output : ${tokenStats.output.toLocaleString()} tokens (actual)`));
573
- console.log(dim(` Total : ${(tokenStats.input + tokenStats.output).toLocaleString()} tokens`));
574
- console.log(dim(` Requests: ${tokenStats.requests}`));
596
+ console.log(dim(` Input : ${tokenStats.input.toLocaleString()} tokens`));
597
+ console.log(dim(` Output : ${tokenStats.output.toLocaleString()} tokens`));
598
+ console.log(dim(` Total : ${(tokenStats.input + tokenStats.output).toLocaleString()} tokens`));
599
+ console.log(dim(` Requests : ${tokenStats.requests}`));
600
+ // Per-model breakdown (mirrors cost-tracker.ts formatModelUsage)
601
+ const models = Object.entries(tokenStats.byModel);
602
+ if (models.length > 1) {
603
+ console.log(blue('\n By model:\n'));
604
+ for (const [model, usage] of models) {
605
+ console.log(dim(` ${model.padEnd(20)} ${usage.input.toLocaleString()} in ${usage.output.toLocaleString()} out (${usage.requests} req)`));
606
+ }
607
+ }
575
608
  } else {
576
609
  console.log(dim(` Input : ~${_inputTokens.toLocaleString()} (estimated)`));
577
610
  console.log(dim(` Output : ~${_outputTokens.toLocaleString()} (estimated)`));
@@ -580,6 +613,137 @@ async function run() {
580
613
  console.log();
581
614
  break;
582
615
  }
616
+ case 'copy': {
617
+ // Copy last assistant response to clipboard — from commands.ts copy command
618
+ const lastAss = [...messages].reverse().find(m => m.role === 'assistant');
619
+ if (!lastAss) { console.log(dim(' Nothing to copy.\n')); break; }
620
+ const text = typeof lastAss.content === 'string' ? lastAss.content : JSON.stringify(lastAss.content);
621
+ try {
622
+ const { execSync } = await import('child_process');
623
+ if (process.platform === 'darwin') {
624
+ execSync('pbcopy', { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
625
+ } else if (process.platform === 'win32') {
626
+ execSync('clip', { input: text, stdio: ['pipe', 'ignore', 'ignore'], shell: true });
627
+ } else {
628
+ execSync('xclip -selection clipboard 2>/dev/null || xsel --clipboard --input', { input: text, stdio: ['pipe', 'ignore', 'ignore'], shell: true });
629
+ }
630
+ console.log(green(` ✓ Copied ${text.length.toLocaleString()} chars to clipboard\n`));
631
+ } catch (e) {
632
+ console.log(red(` Copy failed: ${e.message}\n`));
633
+ }
634
+ break;
635
+ }
636
+ case 'btw': {
637
+ // Inject a sticky note into conversation without triggering a model turn
638
+ // From CC's btw command — useful for mid-task context injection
639
+ const note = rest.join(' ');
640
+ if (!note) { console.log(red(' Usage: /btw <note>\n')); break; }
641
+ messages.push({ role: 'user', content: `[btw: ${note}]` });
642
+ messages.push({ role: 'assistant', content: 'Noted.' });
643
+ console.log(green(' ✓ Noted.\n'));
644
+ break;
645
+ }
646
+ case 'rewind': {
647
+ // Remove last user+assistant exchange from context
648
+ // From CC's rewind command
649
+ if (messages.length < 2) { console.log(dim(' Nothing to rewind.\n')); break; }
650
+ // Find last assistant message
651
+ let lastAssIdx = -1;
652
+ for (let i = messages.length - 1; i >= 0; i--) {
653
+ if (messages[i].role === 'assistant') { lastAssIdx = i; break; }
654
+ }
655
+ if (lastAssIdx === -1) { console.log(dim(' Nothing to rewind.\n')); break; }
656
+ // Find the user message that preceded it
657
+ let lastUserIdx = lastAssIdx;
658
+ for (let i = lastAssIdx - 1; i >= 0; i--) {
659
+ if (messages[i].role === 'user') { lastUserIdx = i; break; }
660
+ }
661
+ const removed = messages.splice(lastUserIdx).length;
662
+ console.log(green(` ✓ Rewound ${removed} message(s). Context: ${messages.length} messages.\n`));
663
+ break;
664
+ }
665
+ case 'diff': {
666
+ // Show git diff of file changes made this session
667
+ // From CC's diff command
668
+ try {
669
+ const { execSync: _exec } = await import('child_process');
670
+ const statArgs = rest.includes('--full') ? '' : '--stat';
671
+ let diffOut = '';
672
+ try {
673
+ diffOut = _exec(`git diff HEAD ${statArgs}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
674
+ } catch {
675
+ diffOut = _exec(`git diff ${statArgs}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
676
+ }
677
+ if (!diffOut.trim()) {
678
+ console.log(dim(' No changes detected. (Use git add to stage new files.)\n'));
679
+ } else {
680
+ console.log(blue('\n Session diff:\n'));
681
+ console.log(dim(diffOut.trim().split('\n').map(l => ' ' + l).join('\n')));
682
+ if (statArgs === '--stat') console.log(dim('\n Tip: /diff --full for full patch\n'));
683
+ else console.log();
684
+ }
685
+ } catch (e) {
686
+ console.log(dim(` No git repository or no changes. (${e.message.slice(0, 60)})\n`));
687
+ }
688
+ break;
689
+ }
690
+ case 'stats': {
691
+ // Session statistics — mirrors cost-tracker.ts formatTotalCost pattern
692
+ const elapsed = Date.now() - sessionStartTime;
693
+ const mins = Math.floor(elapsed / 60000);
694
+ const secs = Math.floor((elapsed % 60000) / 1000);
695
+ const durationStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
696
+ const totalTok = tokenStats.input + tokenStats.output;
697
+ console.log(blue('\n Session Stats\n'));
698
+ console.log(dim(` Duration ${durationStr}`));
699
+ console.log(dim(` Requests ${tokenStats.requests}`));
700
+ console.log(dim(` Input tokens ${tokenStats.input.toLocaleString()}`));
701
+ console.log(dim(` Output tokens ${tokenStats.output.toLocaleString()}`));
702
+ console.log(dim(` Total tokens ${totalTok.toLocaleString()}`));
703
+ console.log(dim(` Lines added ${linesChanged.added}`));
704
+ console.log(dim(` Lines removed ${linesChanged.removed}`));
705
+ console.log(dim(` Messages ${messages.length}`));
706
+ const models = Object.entries(tokenStats.byModel);
707
+ if (models.length > 1) {
708
+ console.log(blue('\n By model:\n'));
709
+ for (const [model, usage] of models) {
710
+ console.log(dim(` ${model.padEnd(20)} ${usage.input.toLocaleString()} in ${usage.output.toLocaleString()} out`));
711
+ }
712
+ }
713
+ console.log();
714
+ break;
715
+ }
716
+ case 'export': {
717
+ // Export full conversation to a markdown file
718
+ // From CC's export command
719
+ const filename = rest[0] || `tsunami-session-${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.md`;
720
+ const exportPath = filename.startsWith('/') || filename.startsWith('D:') || filename.startsWith('C:') ? filename : join(cwd, filename);
721
+ const lines = [
722
+ `# Tsunami Code Session`,
723
+ ``,
724
+ `**Date:** ${new Date().toISOString()}`,
725
+ `**Directory:** ${cwd}`,
726
+ `**Messages:** ${messages.length}`,
727
+ `**Tokens:** ${(tokenStats.input + tokenStats.output).toLocaleString()}`,
728
+ ``,
729
+ `---`,
730
+ ``
731
+ ];
732
+ for (const msg of messages) {
733
+ if (msg.role === 'system') continue;
734
+ const roleLabel = msg.role === 'user' ? '### User' : '### Assistant';
735
+ const text = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content, null, 2);
736
+ lines.push(roleLabel, '', text, '', '---', '');
737
+ }
738
+ try {
739
+ const { writeFileSync: _wfs } = await import('fs');
740
+ _wfs(exportPath, lines.join('\n'), 'utf8');
741
+ console.log(green(` ✓ Exported to: ${exportPath}\n`));
742
+ } catch (e) {
743
+ console.log(red(` Export failed: ${e.message}\n`));
744
+ }
745
+ break;
746
+ }
583
747
  case 'clear':
584
748
  resetSession();
585
749
  console.log(green(' Session cleared.\n'));
@@ -682,6 +846,9 @@ async function run() {
682
846
  isProcessing = true;
683
847
  process.stdout.write('\n');
684
848
 
849
+ // Open a new undo turn — all Write/Edit calls during this turn grouped together
850
+ beginUndoTurn();
851
+
685
852
  let firstToken = true;
686
853
  try {
687
854
  await agentLoop(
@@ -716,6 +883,11 @@ async function run() {
716
883
  console.log(dim(' ↯ Auto-compacted\n'));
717
884
  }
718
885
 
886
+ // getDynamicSkills — reload if model created/modified skill files this turn
887
+ // From CC's getDynamicSkills pattern in commands.ts
888
+ const newSkills = loadSkills(cwd);
889
+ if (newSkills.length !== skills.length) skills = newSkills;
890
+
719
891
  process.stdout.write('\n\n');
720
892
  } catch (e) {
721
893
  process.stdout.write('\n');
@@ -735,9 +907,18 @@ async function run() {
735
907
  if (!isProcessing) {
736
908
  gracefulExit(0);
737
909
  } else {
738
- // If processing, mark pending and let the loop finish
910
+ // Remove interrupted command from history (from history.ts removeLastFromHistory)
911
+ try {
912
+ if (existsSync(HISTORY_FILE)) {
913
+ const lines = readFileSync(HISTORY_FILE, 'utf8').trimEnd().split('\n').filter(Boolean);
914
+ if (lines.length > 0) lines.pop();
915
+ import('fs').then(({ writeFileSync: wfs }) => {
916
+ try { wfs(HISTORY_FILE, lines.join('\n') + (lines.length ? '\n' : ''), 'utf8'); } catch {}
917
+ });
918
+ }
919
+ } catch {}
739
920
  pendingClose = true;
740
- console.log(dim('\n (finishing current operation...)\n'));
921
+ console.log(dim('\n (interrupted)\n'));
741
922
  }
742
923
  });
743
924
  }
package/lib/loop.js CHANGED
@@ -44,13 +44,26 @@ function isDangerous(cmd) {
44
44
  let _serverVerified = false;
45
45
 
46
46
  // Real token tracking from API responses
47
- export const tokenStats = { input: 0, output: 0, requests: 0 };
47
+ // byModel mirrors cost-tracker.ts per-model usage accumulation
48
+ export const tokenStats = { input: 0, output: 0, requests: 0, byModel: {} };
49
+
50
+ // Token warning thresholds from query.ts calculateTokenWarningState
51
+ const TOKEN_WARN_LEVELS = [
52
+ { pct: 0.95, label: '⚠ Context critical — run /compact now', urgent: true },
53
+ { pct: 0.85, label: '⚠ Context high', urgent: true },
54
+ { pct: 0.70, label: '◦ Context at', urgent: false },
55
+ ];
48
56
 
49
57
  // Current model identifier — changeable at runtime via /model command
50
58
  let _currentModel = 'local';
51
59
  export function setModel(model) { _currentModel = model; }
52
60
  export function getModel() { return _currentModel; }
53
61
 
62
+ // Temperature — changeable via /effort command
63
+ let _temperature = 0.1;
64
+ export function setTemperature(t) { _temperature = Math.max(0, Math.min(1, t)); }
65
+ export function getTemperature() { return _temperature; }
66
+
54
67
  // Parse tool calls from any format the model might produce
55
68
  function parseToolCalls(content) {
56
69
  const calls = [];
@@ -224,13 +237,22 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
224
237
  model: _currentModel,
225
238
  messages: finalMessages,
226
239
  stream: true,
227
- temperature: 0.1,
240
+ temperature: _temperature,
228
241
  max_tokens: 4096,
229
242
  stop: ['</tool_call>\n\nContinue']
230
243
  })
231
244
  });
232
245
 
233
- if (!res.ok) throw new Error(`Model server: ${res.status} ${await res.text()}`);
246
+ if (!res.ok) {
247
+ const errText = await res.text();
248
+ // PROMPT_TOO_LONG detection — mirrors query.ts isPromptTooLongMessage
249
+ if (res.status === 400 || errText.toLowerCase().includes('context') || errText.toLowerCase().includes('too long') || errText.toLowerCase().includes('tokens')) {
250
+ const e = new Error(`Model server: ${res.status} ${errText}`);
251
+ e.isPromptTooLong = true;
252
+ throw e;
253
+ }
254
+ throw new Error(`Model server: ${res.status} ${errText}`);
255
+ }
234
256
 
235
257
  let fullContent = '';
236
258
  let buffer = '';
@@ -253,10 +275,18 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
253
275
  }
254
276
  // Capture real token counts + finish reason
255
277
  if (parsed.usage) {
256
- tokenStats.input += parsed.usage.prompt_tokens || 0;
257
- tokenStats.output += parsed.usage.completion_tokens || 0;
278
+ const pt = parsed.usage.prompt_tokens || 0;
279
+ const ct = parsed.usage.completion_tokens || 0;
280
+ tokenStats.input += pt;
281
+ tokenStats.output += ct;
258
282
  tokenStats.requests++;
259
- tokenStats.lastPromptTokens = parsed.usage.prompt_tokens || 0;
283
+ tokenStats.lastPromptTokens = pt;
284
+ // Per-model accumulation
285
+ const m = _currentModel;
286
+ if (!tokenStats.byModel[m]) tokenStats.byModel[m] = { input: 0, output: 0, requests: 0 };
287
+ tokenStats.byModel[m].input += pt;
288
+ tokenStats.byModel[m].output += ct;
289
+ tokenStats.byModel[m].requests++;
260
290
  }
261
291
  if (parsed.choices?.[0]?.finish_reason) {
262
292
  tokenStats.lastFinishReason = parsed.choices[0].finish_reason;
@@ -352,7 +382,34 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
352
382
  tokenStats.lastPromptTokens = 0;
353
383
  }
354
384
 
355
- const content = await streamCompletion(serverUrl, messages, onToken, memoryContext);
385
+ let content;
386
+ try {
387
+ content = await streamCompletion(serverUrl, messages, onToken, memoryContext);
388
+ } catch (e) {
389
+ // PROMPT_TOO_LONG recovery — compact and retry once
390
+ if (e.isPromptTooLong && messages.length > 4) {
391
+ onToken('\n[context too long — auto-compacting and retrying]\n');
392
+ const sys = messages[0];
393
+ const tail = messages.slice(-3);
394
+ messages.length = 0;
395
+ messages.push(sys, ...tail);
396
+ content = await streamCompletion(serverUrl, messages, onToken, memoryContext);
397
+ } else {
398
+ throw e;
399
+ }
400
+ }
401
+
402
+ // Token warning thresholds (from query.ts calculateTokenWarningState)
403
+ if (tokenStats.lastPromptTokens > 0) {
404
+ const pct = tokenStats.lastPromptTokens / CONTEXT_WINDOW;
405
+ for (const level of TOKEN_WARN_LEVELS) {
406
+ if (pct >= level.pct) {
407
+ onToken(`\n[${level.label}${level.urgent ? '' : ` ${Math.round(pct * 100)}%`}]\n`);
408
+ break;
409
+ }
410
+ }
411
+ }
412
+
356
413
  const toolCalls = parseToolCalls(content);
357
414
 
358
415
  messages.push({ role: 'assistant', content });
package/lib/prompt.js CHANGED
@@ -8,8 +8,20 @@ function getGitContext() {
8
8
  const branch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
9
9
  const status = execSync('git status --short', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
10
10
  const log = execSync('git log --oneline -5', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
11
- const parts = [`Branch: ${branch}`];
12
- if (status) parts.push(`Changed files:\n${status}`);
11
+ // Main branch detection (from context.ts getDefaultBranch pattern)
12
+ let mainBranch = 'main';
13
+ try { mainBranch = execSync('git symbolic-ref refs/remotes/origin/HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim().replace('refs/remotes/origin/', ''); } catch {}
14
+ if (!mainBranch) try { mainBranch = execSync('git config init.defaultBranch', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch {}
15
+ // Git user name (from context.ts)
16
+ let userName = '';
17
+ try { userName = execSync('git config user.name', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch {}
18
+ // Truncate status at 2000 chars (from context.ts MAX_STATUS_CHARS)
19
+ const truncatedStatus = status.length > 2000
20
+ ? status.slice(0, 2000) + '\n... (truncated — run git status for full output)'
21
+ : status;
22
+ const parts = [`Branch: ${branch}`, `Main branch (for PRs): ${mainBranch}`];
23
+ if (userName) parts.push(`Git user: ${userName}`);
24
+ if (truncatedStatus) parts.push(`Changed files:\n${truncatedStatus}`);
13
25
  if (log) parts.push(`Recent commits:\n${log}`);
14
26
  return `\n\n<git>\n${parts.join('\n\n')}\n</git>`;
15
27
  } catch {
package/lib/tools.js CHANGED
@@ -8,15 +8,59 @@ import fetch from 'node-fetch';
8
8
 
9
9
  const execAsync = promisify(exec);
10
10
 
11
- // ── Undo Stack ───────────────────────────────────────────────────────────────
12
- const _undoStack = [];
13
- const MAX_UNDO = 20;
11
+ // ── Lines Changed Tracking ────────────────────────────────────────────────────
12
+ // Mirrors cost-tracker.ts addToTotalLinesChanged pattern
13
+ export const linesChanged = { added: 0, removed: 0 };
14
+
15
+ // ── Undo Stack (per-turn batching) ───────────────────────────────────────────
16
+ // Mirrors CC's multi-file undo: each turn's file changes grouped together.
17
+ // /undo restores ALL files changed in the last turn at once.
18
+ const _undoTurns = []; // Array of turns: each is [{ filePath, content }]
19
+ let _currentTurnFiles = []; // Accumulates file snapshots for the active turn
20
+ const MAX_UNDO_TURNS = 10;
21
+
22
+ /** Call before each agent turn starts to open a new undo group. */
23
+ export function beginUndoTurn() {
24
+ if (_currentTurnFiles.length > 0) {
25
+ _undoTurns.push([..._currentTurnFiles]);
26
+ if (_undoTurns.length > MAX_UNDO_TURNS) _undoTurns.shift();
27
+ }
28
+ _currentTurnFiles = [];
29
+ }
30
+
31
+ function _pushFileSnapshot(filePath) {
32
+ // Only snapshot once per file per turn (first state = what to restore to)
33
+ if (_currentTurnFiles.some(f => f.filePath === filePath)) return;
34
+ try {
35
+ const content = existsSync(filePath) ? readFileSync(filePath, 'utf8') : null;
36
+ _currentTurnFiles.push({ filePath, content });
37
+ } catch {}
38
+ }
39
+
40
+ /** Undo all file changes from the most recent turn. Returns list of restored paths. */
14
41
  export function undo() {
15
- if (_undoStack.length === 0) return null;
16
- const { filePath, content } = _undoStack.pop();
17
- try { writeFileSync(filePath, content, 'utf8'); return filePath; } catch { return null; }
42
+ // Commit current turn if it has anything
43
+ if (_currentTurnFiles.length > 0) {
44
+ _undoTurns.push([..._currentTurnFiles]);
45
+ _currentTurnFiles = [];
46
+ }
47
+ if (_undoTurns.length === 0) return null;
48
+ const turn = _undoTurns.pop();
49
+ const restored = [];
50
+ for (const { filePath, content } of turn.reverse()) {
51
+ try {
52
+ if (content === null) {
53
+ // File was created this turn — remove it
54
+ import('fs').then(({ unlinkSync }) => { try { unlinkSync(filePath); } catch {} });
55
+ } else {
56
+ writeFileSync(filePath, content, 'utf8');
57
+ }
58
+ restored.push(filePath);
59
+ } catch {}
60
+ }
61
+ return restored.length ? restored : null;
18
62
  }
19
- export function undoStackSize() { return _undoStack.length; }
63
+ export function undoStackSize() { return _undoTurns.length + (_currentTurnFiles.length > 0 ? 1 : 0); }
20
64
 
21
65
  // ── Session Context (set by index.js at startup) ──────────────────────────────
22
66
  let _sessionDir = null;
@@ -108,15 +152,13 @@ export const WriteTool = {
108
152
  },
109
153
  async run({ file_path, content }) {
110
154
  try {
111
- // Undo stack: save previous content before overwriting
112
- try {
113
- if (existsSync(file_path)) {
114
- _undoStack.push({ filePath: file_path, content: readFileSync(file_path, 'utf8') });
115
- if (_undoStack.length > MAX_UNDO) _undoStack.shift();
116
- }
117
- } catch {}
155
+ _pushFileSnapshot(file_path);
156
+ const newLineCount = content.split('\n').length;
157
+ const oldLineCount = existsSync(file_path) ? readFileSync(file_path, 'utf8').split('\n').length : 0;
118
158
  writeFileSync(file_path, content, 'utf8');
119
- return `Written ${content.split('\n').length} lines to ${file_path}`;
159
+ linesChanged.added += Math.max(0, newLineCount - oldLineCount);
160
+ linesChanged.removed += Math.max(0, oldLineCount - newLineCount);
161
+ return `Written ${newLineCount} lines to ${file_path}`;
120
162
  } catch (e) {
121
163
  return `Error writing file: ${e.message}`;
122
164
  }
@@ -145,11 +187,7 @@ export const EditTool = {
145
187
  if (!existsSync(file_path)) return `Error: File not found: ${file_path}`;
146
188
  try {
147
189
  let content = readFileSync(file_path, 'utf8');
148
- // Undo stack: save current content before editing
149
- try {
150
- _undoStack.push({ filePath: file_path, content });
151
- if (_undoStack.length > MAX_UNDO) _undoStack.shift();
152
- } catch {}
190
+ _pushFileSnapshot(file_path);
153
191
  if (!content.includes(old_string)) return `Error: old_string not found in ${file_path}`;
154
192
  if (!replace_all) {
155
193
  const count = content.split(old_string).length - 1;
@@ -159,6 +197,9 @@ export const EditTool = {
159
197
  ? content.split(old_string).join(new_string)
160
198
  : content.replace(old_string, new_string);
161
199
  writeFileSync(file_path, updated, 'utf8');
200
+ // Track lines changed: old_string lines removed, new_string lines added
201
+ linesChanged.added += new_string.split('\n').length;
202
+ linesChanged.removed += old_string.split('\n').length;
162
203
  return `Edited ${file_path}`;
163
204
  } catch (e) {
164
205
  return `Error editing file: ${e.message}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsunami-code",
3
- "version": "3.3.0",
3
+ "version": "3.5.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": {