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 +155 -8
- package/lib/loop.js +58 -6
- package/lib/tools.js +12 -1
- package/package.json +1 -1
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.
|
|
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
|
|
572
|
-
console.log(dim(` Output
|
|
573
|
-
console.log(dim(` Total
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
257
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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}`;
|