tsunami-code 3.3.0 → 3.4.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
@@ -11,7 +11,7 @@ import { isCoordinatorTask, stripCoordinatorPrefix, buildCoordinatorSystemPrompt
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, 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.4.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'].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,12 @@ 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
+ ['/copy', 'Copy last response to clipboard'],
504
+ ['/btw <note>', 'Inject a note into conversation without a response'],
505
+ ['/rewind', 'Remove last user+assistant exchange'],
506
+ ['/diff', 'Show git diff of session file changes'],
507
+ ['/stats', 'Session stats: lines, tokens, duration'],
508
+ ['/export [file]', 'Export conversation to markdown file'],
501
509
  ['/history', 'Show recent command history'],
502
510
  ['/exit', 'Exit'],
503
511
  ];
@@ -566,12 +574,20 @@ async function run() {
566
574
  }
567
575
  case 'cost': {
568
576
  const hasReal = tokenStats.requests > 0;
569
- console.log(blue('\n Session Token Usage'));
577
+ console.log(blue('\n Session Token Usage\n'));
570
578
  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}`));
579
+ console.log(dim(` Input : ${tokenStats.input.toLocaleString()} tokens`));
580
+ console.log(dim(` Output : ${tokenStats.output.toLocaleString()} tokens`));
581
+ console.log(dim(` Total : ${(tokenStats.input + tokenStats.output).toLocaleString()} tokens`));
582
+ console.log(dim(` Requests : ${tokenStats.requests}`));
583
+ // Per-model breakdown (mirrors cost-tracker.ts formatModelUsage)
584
+ const models = Object.entries(tokenStats.byModel);
585
+ if (models.length > 1) {
586
+ console.log(blue('\n By model:\n'));
587
+ for (const [model, usage] of models) {
588
+ console.log(dim(` ${model.padEnd(20)} ${usage.input.toLocaleString()} in ${usage.output.toLocaleString()} out (${usage.requests} req)`));
589
+ }
590
+ }
575
591
  } else {
576
592
  console.log(dim(` Input : ~${_inputTokens.toLocaleString()} (estimated)`));
577
593
  console.log(dim(` Output : ~${_outputTokens.toLocaleString()} (estimated)`));
@@ -580,6 +596,137 @@ async function run() {
580
596
  console.log();
581
597
  break;
582
598
  }
599
+ case 'copy': {
600
+ // Copy last assistant response to clipboard — from commands.ts copy command
601
+ const lastAss = [...messages].reverse().find(m => m.role === 'assistant');
602
+ if (!lastAss) { console.log(dim(' Nothing to copy.\n')); break; }
603
+ const text = typeof lastAss.content === 'string' ? lastAss.content : JSON.stringify(lastAss.content);
604
+ try {
605
+ const { execSync } = await import('child_process');
606
+ if (process.platform === 'darwin') {
607
+ execSync('pbcopy', { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
608
+ } else if (process.platform === 'win32') {
609
+ execSync('clip', { input: text, stdio: ['pipe', 'ignore', 'ignore'], shell: true });
610
+ } else {
611
+ execSync('xclip -selection clipboard 2>/dev/null || xsel --clipboard --input', { input: text, stdio: ['pipe', 'ignore', 'ignore'], shell: true });
612
+ }
613
+ console.log(green(` ✓ Copied ${text.length.toLocaleString()} chars to clipboard\n`));
614
+ } catch (e) {
615
+ console.log(red(` Copy failed: ${e.message}\n`));
616
+ }
617
+ break;
618
+ }
619
+ case 'btw': {
620
+ // Inject a sticky note into conversation without triggering a model turn
621
+ // From CC's btw command — useful for mid-task context injection
622
+ const note = rest.join(' ');
623
+ if (!note) { console.log(red(' Usage: /btw <note>\n')); break; }
624
+ messages.push({ role: 'user', content: `[btw: ${note}]` });
625
+ messages.push({ role: 'assistant', content: 'Noted.' });
626
+ console.log(green(' ✓ Noted.\n'));
627
+ break;
628
+ }
629
+ case 'rewind': {
630
+ // Remove last user+assistant exchange from context
631
+ // From CC's rewind command
632
+ if (messages.length < 2) { console.log(dim(' Nothing to rewind.\n')); break; }
633
+ // Find last assistant message
634
+ let lastAssIdx = -1;
635
+ for (let i = messages.length - 1; i >= 0; i--) {
636
+ if (messages[i].role === 'assistant') { lastAssIdx = i; break; }
637
+ }
638
+ if (lastAssIdx === -1) { console.log(dim(' Nothing to rewind.\n')); break; }
639
+ // Find the user message that preceded it
640
+ let lastUserIdx = lastAssIdx;
641
+ for (let i = lastAssIdx - 1; i >= 0; i--) {
642
+ if (messages[i].role === 'user') { lastUserIdx = i; break; }
643
+ }
644
+ const removed = messages.splice(lastUserIdx).length;
645
+ console.log(green(` ✓ Rewound ${removed} message(s). Context: ${messages.length} messages.\n`));
646
+ break;
647
+ }
648
+ case 'diff': {
649
+ // Show git diff of file changes made this session
650
+ // From CC's diff command
651
+ try {
652
+ const { execSync: _exec } = await import('child_process');
653
+ const statArgs = rest.includes('--full') ? '' : '--stat';
654
+ let diffOut = '';
655
+ try {
656
+ diffOut = _exec(`git diff HEAD ${statArgs}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
657
+ } catch {
658
+ diffOut = _exec(`git diff ${statArgs}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
659
+ }
660
+ if (!diffOut.trim()) {
661
+ console.log(dim(' No changes detected. (Use git add to stage new files.)\n'));
662
+ } else {
663
+ console.log(blue('\n Session diff:\n'));
664
+ console.log(dim(diffOut.trim().split('\n').map(l => ' ' + l).join('\n')));
665
+ if (statArgs === '--stat') console.log(dim('\n Tip: /diff --full for full patch\n'));
666
+ else console.log();
667
+ }
668
+ } catch (e) {
669
+ console.log(dim(` No git repository or no changes. (${e.message.slice(0, 60)})\n`));
670
+ }
671
+ break;
672
+ }
673
+ case 'stats': {
674
+ // Session statistics — mirrors cost-tracker.ts formatTotalCost pattern
675
+ const elapsed = Date.now() - sessionStartTime;
676
+ const mins = Math.floor(elapsed / 60000);
677
+ const secs = Math.floor((elapsed % 60000) / 1000);
678
+ const durationStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
679
+ const totalTok = tokenStats.input + tokenStats.output;
680
+ console.log(blue('\n Session Stats\n'));
681
+ console.log(dim(` Duration ${durationStr}`));
682
+ console.log(dim(` Requests ${tokenStats.requests}`));
683
+ console.log(dim(` Input tokens ${tokenStats.input.toLocaleString()}`));
684
+ console.log(dim(` Output tokens ${tokenStats.output.toLocaleString()}`));
685
+ console.log(dim(` Total tokens ${totalTok.toLocaleString()}`));
686
+ console.log(dim(` Lines added ${linesChanged.added}`));
687
+ console.log(dim(` Lines removed ${linesChanged.removed}`));
688
+ console.log(dim(` Messages ${messages.length}`));
689
+ const models = Object.entries(tokenStats.byModel);
690
+ if (models.length > 1) {
691
+ console.log(blue('\n By model:\n'));
692
+ for (const [model, usage] of models) {
693
+ console.log(dim(` ${model.padEnd(20)} ${usage.input.toLocaleString()} in ${usage.output.toLocaleString()} out`));
694
+ }
695
+ }
696
+ console.log();
697
+ break;
698
+ }
699
+ case 'export': {
700
+ // Export full conversation to a markdown file
701
+ // From CC's export command
702
+ const filename = rest[0] || `tsunami-session-${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.md`;
703
+ const exportPath = filename.startsWith('/') || filename.startsWith('D:') || filename.startsWith('C:') ? filename : join(cwd, filename);
704
+ const lines = [
705
+ `# Tsunami Code Session`,
706
+ ``,
707
+ `**Date:** ${new Date().toISOString()}`,
708
+ `**Directory:** ${cwd}`,
709
+ `**Messages:** ${messages.length}`,
710
+ `**Tokens:** ${(tokenStats.input + tokenStats.output).toLocaleString()}`,
711
+ ``,
712
+ `---`,
713
+ ``
714
+ ];
715
+ for (const msg of messages) {
716
+ if (msg.role === 'system') continue;
717
+ const roleLabel = msg.role === 'user' ? '### User' : '### Assistant';
718
+ const text = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content, null, 2);
719
+ lines.push(roleLabel, '', text, '', '---', '');
720
+ }
721
+ try {
722
+ const { writeFileSync: _wfs } = await import('fs');
723
+ _wfs(exportPath, lines.join('\n'), 'utf8');
724
+ console.log(green(` ✓ Exported to: ${exportPath}\n`));
725
+ } catch (e) {
726
+ console.log(red(` Export failed: ${e.message}\n`));
727
+ }
728
+ break;
729
+ }
583
730
  case 'clear':
584
731
  resetSession();
585
732
  console.log(green(' Session cleared.\n'));
package/lib/loop.js CHANGED
@@ -44,7 +44,15 @@ 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';
@@ -230,7 +238,16 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
230
238
  })
231
239
  });
232
240
 
233
- if (!res.ok) throw new Error(`Model server: ${res.status} ${await res.text()}`);
241
+ if (!res.ok) {
242
+ const errText = await res.text();
243
+ // PROMPT_TOO_LONG detection — mirrors query.ts isPromptTooLongMessage
244
+ if (res.status === 400 || errText.toLowerCase().includes('context') || errText.toLowerCase().includes('too long') || errText.toLowerCase().includes('tokens')) {
245
+ const e = new Error(`Model server: ${res.status} ${errText}`);
246
+ e.isPromptTooLong = true;
247
+ throw e;
248
+ }
249
+ throw new Error(`Model server: ${res.status} ${errText}`);
250
+ }
234
251
 
235
252
  let fullContent = '';
236
253
  let buffer = '';
@@ -253,10 +270,18 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
253
270
  }
254
271
  // Capture real token counts + finish reason
255
272
  if (parsed.usage) {
256
- tokenStats.input += parsed.usage.prompt_tokens || 0;
257
- tokenStats.output += parsed.usage.completion_tokens || 0;
273
+ const pt = parsed.usage.prompt_tokens || 0;
274
+ const ct = parsed.usage.completion_tokens || 0;
275
+ tokenStats.input += pt;
276
+ tokenStats.output += ct;
258
277
  tokenStats.requests++;
259
- tokenStats.lastPromptTokens = parsed.usage.prompt_tokens || 0;
278
+ tokenStats.lastPromptTokens = pt;
279
+ // Per-model accumulation
280
+ const m = _currentModel;
281
+ if (!tokenStats.byModel[m]) tokenStats.byModel[m] = { input: 0, output: 0, requests: 0 };
282
+ tokenStats.byModel[m].input += pt;
283
+ tokenStats.byModel[m].output += ct;
284
+ tokenStats.byModel[m].requests++;
260
285
  }
261
286
  if (parsed.choices?.[0]?.finish_reason) {
262
287
  tokenStats.lastFinishReason = parsed.choices[0].finish_reason;
@@ -352,7 +377,34 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
352
377
  tokenStats.lastPromptTokens = 0;
353
378
  }
354
379
 
355
- const content = await streamCompletion(serverUrl, messages, onToken, memoryContext);
380
+ let content;
381
+ try {
382
+ content = await streamCompletion(serverUrl, messages, onToken, memoryContext);
383
+ } catch (e) {
384
+ // PROMPT_TOO_LONG recovery — compact and retry once
385
+ if (e.isPromptTooLong && messages.length > 4) {
386
+ onToken('\n[context too long — auto-compacting and retrying]\n');
387
+ const sys = messages[0];
388
+ const tail = messages.slice(-3);
389
+ messages.length = 0;
390
+ messages.push(sys, ...tail);
391
+ content = await streamCompletion(serverUrl, messages, onToken, memoryContext);
392
+ } else {
393
+ throw e;
394
+ }
395
+ }
396
+
397
+ // Token warning thresholds (from query.ts calculateTokenWarningState)
398
+ if (tokenStats.lastPromptTokens > 0) {
399
+ const pct = tokenStats.lastPromptTokens / CONTEXT_WINDOW;
400
+ for (const level of TOKEN_WARN_LEVELS) {
401
+ if (pct >= level.pct) {
402
+ onToken(`\n[${level.label}${level.urgent ? '' : ` ${Math.round(pct * 100)}%`}]\n`);
403
+ break;
404
+ }
405
+ }
406
+ }
407
+
356
408
  const toolCalls = parseToolCalls(content);
357
409
 
358
410
  messages.push({ role: 'assistant', content });
package/lib/tools.js CHANGED
@@ -8,6 +8,10 @@ import fetch from 'node-fetch';
8
8
 
9
9
  const execAsync = promisify(exec);
10
10
 
11
+ // ── Lines Changed Tracking ────────────────────────────────────────────────────
12
+ // Mirrors cost-tracker.ts addToTotalLinesChanged pattern
13
+ export const linesChanged = { added: 0, removed: 0 };
14
+
11
15
  // ── Undo Stack ───────────────────────────────────────────────────────────────
12
16
  const _undoStack = [];
13
17
  const MAX_UNDO = 20;
@@ -115,8 +119,12 @@ export const WriteTool = {
115
119
  if (_undoStack.length > MAX_UNDO) _undoStack.shift();
116
120
  }
117
121
  } catch {}
122
+ const newLineCount = content.split('\n').length;
123
+ const oldLineCount = existsSync(file_path) ? readFileSync(file_path, 'utf8').split('\n').length : 0;
118
124
  writeFileSync(file_path, content, 'utf8');
119
- return `Written ${content.split('\n').length} lines to ${file_path}`;
125
+ linesChanged.added += Math.max(0, newLineCount - oldLineCount);
126
+ linesChanged.removed += Math.max(0, oldLineCount - newLineCount);
127
+ return `Written ${newLineCount} lines to ${file_path}`;
120
128
  } catch (e) {
121
129
  return `Error writing file: ${e.message}`;
122
130
  }
@@ -159,6 +167,9 @@ export const EditTool = {
159
167
  ? content.split(old_string).join(new_string)
160
168
  : content.replace(old_string, new_string);
161
169
  writeFileSync(file_path, updated, 'utf8');
170
+ // Track lines changed: old_string lines removed, new_string lines added
171
+ linesChanged.added += new_string.split('\n').length;
172
+ linesChanged.removed += old_string.split('\n').length;
162
173
  return `Edited ${file_path}`;
163
174
  } catch (e) {
164
175
  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.4.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": {