tsunami-code 3.2.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 +194 -11
- package/lib/loop.js +83 -7
- package/lib/mcp.js +258 -0
- package/lib/tools.js +25 -1
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -11,7 +11,8 @@ 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 } from './lib/tools.js';
|
|
14
|
+
import { setSession, undo, undoStackSize, registerMcpTools, linesChanged } from './lib/tools.js';
|
|
15
|
+
import { connectMcpServers, getMcpToolObjects, getMcpStatus, getMcpConfigPath, disconnectAll as disconnectMcp } from './lib/mcp.js';
|
|
15
16
|
import {
|
|
16
17
|
initSession,
|
|
17
18
|
initProjectMemory,
|
|
@@ -24,7 +25,7 @@ import {
|
|
|
24
25
|
getSessionContext
|
|
25
26
|
} from './lib/memory.js';
|
|
26
27
|
|
|
27
|
-
const VERSION = '3.
|
|
28
|
+
const VERSION = '3.4.0';
|
|
28
29
|
const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
|
|
29
30
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
30
31
|
const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
|
|
@@ -171,6 +172,8 @@ async function run() {
|
|
|
171
172
|
const cwd = process.cwd();
|
|
172
173
|
let planMode = argv.includes('--plan');
|
|
173
174
|
|
|
175
|
+
const sessionStartTime = Date.now();
|
|
176
|
+
|
|
174
177
|
// Initialize memory systems
|
|
175
178
|
const { sessionId, sessionDir } = initSession(cwd);
|
|
176
179
|
initProjectMemory(cwd);
|
|
@@ -225,6 +228,22 @@ async function run() {
|
|
|
225
228
|
dim(` · ${sessionId} · Type your task. /help for commands. Ctrl+C to exit.\n`)
|
|
226
229
|
);
|
|
227
230
|
|
|
231
|
+
// MCP: connect servers from ~/.tsunami-code/mcp.json (non-blocking — warnings only)
|
|
232
|
+
{
|
|
233
|
+
const mcpResults = await connectMcpServers();
|
|
234
|
+
if (mcpResults.length > 0) {
|
|
235
|
+
const mcpTools = getMcpToolObjects();
|
|
236
|
+
registerMcpTools(mcpTools);
|
|
237
|
+
const ok = mcpResults.filter(r => !r.error);
|
|
238
|
+
const fail = mcpResults.filter(r => r.error);
|
|
239
|
+
if (ok.length) console.log(green(` ✓ MCP`) + dim(` ${ok.map(r => `${r.name}(${r.toolCount})`).join(', ')}`));
|
|
240
|
+
if (fail.length) {
|
|
241
|
+
for (const f of fail) console.log(yellow(` ⚠ MCP "${f.name}" failed: ${f.error}`));
|
|
242
|
+
}
|
|
243
|
+
if (ok.length || fail.length) console.log();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
228
247
|
// KAIROS: run due background tasks on startup
|
|
229
248
|
const dueTasks = getDueTasks();
|
|
230
249
|
if (dueTasks.length > 0) {
|
|
@@ -318,9 +337,8 @@ async function run() {
|
|
|
318
337
|
|
|
319
338
|
// ── Exit handler ─────────────────────────────────────────────────────────────
|
|
320
339
|
function gracefulExit(code = 0) {
|
|
321
|
-
try {
|
|
322
|
-
|
|
323
|
-
} catch {}
|
|
340
|
+
try { endSession(sessionDir); } catch {}
|
|
341
|
+
try { disconnectMcp(); } catch {}
|
|
324
342
|
console.log(dim('\n Goodbye.\n'));
|
|
325
343
|
process.exit(code);
|
|
326
344
|
}
|
|
@@ -440,7 +458,7 @@ async function run() {
|
|
|
440
458
|
|
|
441
459
|
// Skills: check if this is a skill command before built-ins
|
|
442
460
|
const skillMatch = getSkillCommand(skills, line);
|
|
443
|
-
if (skillMatch && !['help','compact','plan','undo','doctor','cost','clear','status','server','model','memory','history','exit','quit','skill-create','skill-list','skills'].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)) {
|
|
444
462
|
// Run the skill prompt as a user message
|
|
445
463
|
const userContent = skillMatch.args
|
|
446
464
|
? `[Skill: ${skillMatch.skill.name}]\n${skillMatch.prompt}`
|
|
@@ -481,6 +499,13 @@ async function run() {
|
|
|
481
499
|
['/status', 'Show context size and server'],
|
|
482
500
|
['/server <url>', 'Change model server URL'],
|
|
483
501
|
['/model [name]', 'Show or change active model (default: local)'],
|
|
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'],
|
|
484
509
|
['/history', 'Show recent command history'],
|
|
485
510
|
['/exit', 'Exit'],
|
|
486
511
|
];
|
|
@@ -505,6 +530,25 @@ async function run() {
|
|
|
505
530
|
else console.log(dim(' Nothing to undo.\n'));
|
|
506
531
|
break;
|
|
507
532
|
}
|
|
533
|
+
case 'mcp': {
|
|
534
|
+
const mcpServers = getMcpStatus();
|
|
535
|
+
if (mcpServers.length === 0) {
|
|
536
|
+
console.log(dim(`\n No MCP servers connected.`));
|
|
537
|
+
console.log(dim(` Add servers to: ${getMcpConfigPath()}`));
|
|
538
|
+
console.log(dim(' Format: { "servers": { "name": { "command": "npx", "args": [...] } } }\n'));
|
|
539
|
+
} else {
|
|
540
|
+
console.log(blue(`\n MCP Servers (${mcpServers.length})\n`));
|
|
541
|
+
for (const srv of mcpServers) {
|
|
542
|
+
console.log(` ${cyan(srv.name)} ${dim(`— ${srv.toolCount} tool${srv.toolCount !== 1 ? 's' : ''}`)}`);
|
|
543
|
+
for (const tool of srv.tools) {
|
|
544
|
+
const desc = tool.description ? dim(` — ${tool.description}`) : '';
|
|
545
|
+
console.log(` ${dim('•')} ${tool.name}${desc}`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
console.log();
|
|
549
|
+
}
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
508
552
|
case 'doctor': {
|
|
509
553
|
const { getRgPath } = await import('./lib/preflight.js');
|
|
510
554
|
const { getMemoryStats: _getMemoryStats } = await import('./lib/memory.js');
|
|
@@ -530,12 +574,20 @@ async function run() {
|
|
|
530
574
|
}
|
|
531
575
|
case 'cost': {
|
|
532
576
|
const hasReal = tokenStats.requests > 0;
|
|
533
|
-
console.log(blue('\n Session Token Usage'));
|
|
577
|
+
console.log(blue('\n Session Token Usage\n'));
|
|
534
578
|
if (hasReal) {
|
|
535
|
-
console.log(dim(` Input
|
|
536
|
-
console.log(dim(` Output
|
|
537
|
-
console.log(dim(` Total
|
|
538
|
-
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
|
+
}
|
|
539
591
|
} else {
|
|
540
592
|
console.log(dim(` Input : ~${_inputTokens.toLocaleString()} (estimated)`));
|
|
541
593
|
console.log(dim(` Output : ~${_outputTokens.toLocaleString()} (estimated)`));
|
|
@@ -544,6 +596,137 @@ async function run() {
|
|
|
544
596
|
console.log();
|
|
545
597
|
break;
|
|
546
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
|
+
}
|
|
547
730
|
case 'clear':
|
|
548
731
|
resetSession();
|
|
549
732
|
console.log(green(' Session cleared.\n'));
|
package/lib/loop.js
CHANGED
|
@@ -7,6 +7,20 @@ import {
|
|
|
7
7
|
appendDecision
|
|
8
8
|
} from './memory.js';
|
|
9
9
|
|
|
10
|
+
// ── Tool result summarization (generateToolUseSummary pattern) ───────────────
|
|
11
|
+
const SUMMARY_THRESHOLD = 2000; // chars — results larger than this get compressed
|
|
12
|
+
const SUMMARIZABLE_TOOLS = new Set(['Read', 'Bash', 'WebFetch', 'WebSearch', 'Grep']);
|
|
13
|
+
|
|
14
|
+
async function generateToolUseSummary(serverUrl, toolName, result) {
|
|
15
|
+
const systemPrompt =
|
|
16
|
+
'You are a lossless summarizer for an AI agent\'s working memory. ' +
|
|
17
|
+
'Extract every key fact, file path, function name, error message, variable name, value, and data point from the tool result. ' +
|
|
18
|
+
'Preserve anything the agent might need to act on. ' +
|
|
19
|
+
'Be dense and specific. No preamble. Target: under 350 words.';
|
|
20
|
+
const userMessage = `Tool: ${toolName}\n\nResult:\n${result.slice(0, 12000)}`;
|
|
21
|
+
return await quickCompletion(serverUrl, systemPrompt, userMessage);
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
// ── Dangerous command detection ──────────────────────────────────────────────
|
|
11
25
|
const DANGEROUS_PATTERNS = [
|
|
12
26
|
/rm\s+-[rf]+\s+[^-]/,
|
|
@@ -30,7 +44,15 @@ function isDangerous(cmd) {
|
|
|
30
44
|
let _serverVerified = false;
|
|
31
45
|
|
|
32
46
|
// Real token tracking from API responses
|
|
33
|
-
|
|
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
|
+
];
|
|
34
56
|
|
|
35
57
|
// Current model identifier — changeable at runtime via /model command
|
|
36
58
|
let _currentModel = 'local';
|
|
@@ -216,7 +238,16 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
|
|
|
216
238
|
})
|
|
217
239
|
});
|
|
218
240
|
|
|
219
|
-
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
|
+
}
|
|
220
251
|
|
|
221
252
|
let fullContent = '';
|
|
222
253
|
let buffer = '';
|
|
@@ -239,10 +270,18 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
|
|
|
239
270
|
}
|
|
240
271
|
// Capture real token counts + finish reason
|
|
241
272
|
if (parsed.usage) {
|
|
242
|
-
|
|
243
|
-
|
|
273
|
+
const pt = parsed.usage.prompt_tokens || 0;
|
|
274
|
+
const ct = parsed.usage.completion_tokens || 0;
|
|
275
|
+
tokenStats.input += pt;
|
|
276
|
+
tokenStats.output += ct;
|
|
244
277
|
tokenStats.requests++;
|
|
245
|
-
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++;
|
|
246
285
|
}
|
|
247
286
|
if (parsed.choices?.[0]?.finish_reason) {
|
|
248
287
|
tokenStats.lastFinishReason = parsed.choices[0].finish_reason;
|
|
@@ -338,7 +377,34 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
|
|
|
338
377
|
tokenStats.lastPromptTokens = 0;
|
|
339
378
|
}
|
|
340
379
|
|
|
341
|
-
|
|
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
|
+
|
|
342
408
|
const toolCalls = parseToolCalls(content);
|
|
343
409
|
|
|
344
410
|
messages.push({ role: 'assistant', content });
|
|
@@ -412,7 +478,17 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
|
|
|
412
478
|
} catch {}
|
|
413
479
|
}
|
|
414
480
|
|
|
415
|
-
results
|
|
481
|
+
// Compress large results before storing in context
|
|
482
|
+
let resultForContext = resultStr;
|
|
483
|
+
if (resultStr.length > SUMMARY_THRESHOLD && SUMMARIZABLE_TOOLS.has(tc.name)) {
|
|
484
|
+
try {
|
|
485
|
+
const summary = await generateToolUseSummary(serverUrl, tc.name, resultStr);
|
|
486
|
+
if (summary && summary.length < resultStr.length * 0.75) {
|
|
487
|
+
resultForContext = `[Summarized ${resultStr.length} → ${summary.length} chars]\n${summary}`;
|
|
488
|
+
}
|
|
489
|
+
} catch { /* keep raw result on failure */ }
|
|
490
|
+
}
|
|
491
|
+
results.push(`[${tc.name} result]\n${resultForContext.slice(0, 8000)}`);
|
|
416
492
|
}
|
|
417
493
|
|
|
418
494
|
messages.push({
|
package/lib/mcp.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP (Model Context Protocol) client for Tsunami Code.
|
|
3
|
+
*
|
|
4
|
+
* Reads ~/.tsunami-code/mcp.json, spawns server processes,
|
|
5
|
+
* speaks JSON-RPC 2.0 over stdio, discovers tools, and wraps
|
|
6
|
+
* them as Tsunami tool objects that plug into ALL_TOOLS.
|
|
7
|
+
*
|
|
8
|
+
* Config format (~/.tsunami-code/mcp.json):
|
|
9
|
+
* {
|
|
10
|
+
* "servers": {
|
|
11
|
+
* "filesystem": {
|
|
12
|
+
* "command": "npx",
|
|
13
|
+
* "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
|
14
|
+
* "env": {}
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawn } from 'child_process';
|
|
21
|
+
import { existsSync, readFileSync } from 'fs';
|
|
22
|
+
import { join } from 'path';
|
|
23
|
+
import os from 'os';
|
|
24
|
+
|
|
25
|
+
const MCP_CONFIG_PATH = join(os.homedir(), '.tsunami-code', 'mcp.json');
|
|
26
|
+
const MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
27
|
+
|
|
28
|
+
// Active connections: name → { process, pending: Map<id, {resolve, reject}>, tools: [], buffer: '' }
|
|
29
|
+
const _servers = new Map();
|
|
30
|
+
let _nextId = 1000;
|
|
31
|
+
|
|
32
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
33
|
+
function loadMcpConfig() {
|
|
34
|
+
if (!existsSync(MCP_CONFIG_PATH)) return { servers: {} };
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(readFileSync(MCP_CONFIG_PATH, 'utf8'));
|
|
37
|
+
} catch {
|
|
38
|
+
return { servers: {} };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── JSON-RPC over stdio ───────────────────────────────────────────────────────
|
|
43
|
+
function sendRequest(server, method, params = {}) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const id = _nextId++;
|
|
46
|
+
server.pending.set(id, { resolve, reject });
|
|
47
|
+
|
|
48
|
+
const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
|
|
49
|
+
try {
|
|
50
|
+
server.process.stdin.write(msg);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
server.pending.delete(id);
|
|
53
|
+
reject(new Error(`Failed to write to MCP server stdin: ${e.message}`));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Auto-reject after 10s to prevent hanging
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
if (server.pending.has(id)) {
|
|
60
|
+
server.pending.delete(id);
|
|
61
|
+
reject(new Error(`MCP request timed out (${method})`));
|
|
62
|
+
}
|
|
63
|
+
}, 10000);
|
|
64
|
+
|
|
65
|
+
// Clear timer when resolved
|
|
66
|
+
const orig = server.pending.get(id);
|
|
67
|
+
server.pending.set(id, {
|
|
68
|
+
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
69
|
+
reject: (e) => { clearTimeout(timer); reject(e); }
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sendNotification(server, method, params = {}) {
|
|
75
|
+
try {
|
|
76
|
+
const msg = JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n';
|
|
77
|
+
server.process.stdin.write(msg);
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function attachStdioHandler(serverName, server) {
|
|
82
|
+
server.process.stdout.on('data', (chunk) => {
|
|
83
|
+
server.buffer += chunk.toString();
|
|
84
|
+
// Process complete newline-delimited JSON messages
|
|
85
|
+
let nl;
|
|
86
|
+
while ((nl = server.buffer.indexOf('\n')) !== -1) {
|
|
87
|
+
const line = server.buffer.slice(0, nl).trim();
|
|
88
|
+
server.buffer = server.buffer.slice(nl + 1);
|
|
89
|
+
if (!line) continue;
|
|
90
|
+
try {
|
|
91
|
+
const msg = JSON.parse(line);
|
|
92
|
+
// Only handle responses (have id), not notifications
|
|
93
|
+
if (msg.id !== undefined && server.pending.has(msg.id)) {
|
|
94
|
+
const { resolve, reject } = server.pending.get(msg.id);
|
|
95
|
+
server.pending.delete(msg.id);
|
|
96
|
+
if (msg.error) reject(new Error(msg.error.message || JSON.stringify(msg.error)));
|
|
97
|
+
else resolve(msg.result);
|
|
98
|
+
}
|
|
99
|
+
} catch { /* malformed JSON — skip */ }
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
server.process.stderr.on('data', () => {}); // absorb stderr silently
|
|
104
|
+
|
|
105
|
+
server.process.on('error', () => {
|
|
106
|
+
_servers.delete(serverName);
|
|
107
|
+
// Reject all pending requests
|
|
108
|
+
for (const { reject } of server.pending.values()) {
|
|
109
|
+
reject(new Error(`MCP server "${serverName}" crashed`));
|
|
110
|
+
}
|
|
111
|
+
server.pending.clear();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
server.process.on('exit', () => {
|
|
115
|
+
_servers.delete(serverName);
|
|
116
|
+
for (const { reject } of server.pending.values()) {
|
|
117
|
+
reject(new Error(`MCP server "${serverName}" exited`));
|
|
118
|
+
}
|
|
119
|
+
server.pending.clear();
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Connect a single server ───────────────────────────────────────────────────
|
|
124
|
+
async function connectServer(name, config) {
|
|
125
|
+
try {
|
|
126
|
+
const { command, args = [], env = {} } = config;
|
|
127
|
+
|
|
128
|
+
if (!command) throw new Error('Missing "command" in MCP server config');
|
|
129
|
+
|
|
130
|
+
const child = spawn(command, args, {
|
|
131
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
132
|
+
env: { ...process.env, ...env },
|
|
133
|
+
shell: process.platform === 'win32'
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const server = {
|
|
137
|
+
process: child,
|
|
138
|
+
pending: new Map(),
|
|
139
|
+
tools: [],
|
|
140
|
+
buffer: ''
|
|
141
|
+
};
|
|
142
|
+
_servers.set(name, server);
|
|
143
|
+
attachStdioHandler(name, server);
|
|
144
|
+
|
|
145
|
+
// 1. Initialize handshake
|
|
146
|
+
await sendRequest(server, 'initialize', {
|
|
147
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
148
|
+
capabilities: { tools: {} },
|
|
149
|
+
clientInfo: { name: 'tsunami-code', version: '3.3.0' }
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// 2. Send initialized notification (spec requires this, no response)
|
|
153
|
+
sendNotification(server, 'notifications/initialized');
|
|
154
|
+
|
|
155
|
+
// 3. Discover tools
|
|
156
|
+
const listResult = await sendRequest(server, 'tools/list', {});
|
|
157
|
+
server.tools = listResult?.tools || [];
|
|
158
|
+
|
|
159
|
+
return { name, toolCount: server.tools.length, tools: server.tools.map(t => t.name) };
|
|
160
|
+
} catch (e) {
|
|
161
|
+
_servers.delete(name);
|
|
162
|
+
return { name, toolCount: 0, error: e.message };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Connect all servers from mcp.json and return a status array.
|
|
170
|
+
* Safe to call even if mcp.json doesn't exist.
|
|
171
|
+
*/
|
|
172
|
+
export async function connectMcpServers() {
|
|
173
|
+
const config = loadMcpConfig();
|
|
174
|
+
const entries = Object.entries(config.servers || {});
|
|
175
|
+
if (entries.length === 0) return [];
|
|
176
|
+
|
|
177
|
+
const results = await Promise.allSettled(
|
|
178
|
+
entries.map(([name, cfg]) => connectServer(name, cfg))
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
return results.map(r =>
|
|
182
|
+
r.status === 'fulfilled' ? r.value : { error: r.reason?.message || 'unknown error' }
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Returns Tsunami tool objects for all discovered MCP tools.
|
|
188
|
+
* Call this after connectMcpServers() and push the results into ALL_TOOLS.
|
|
189
|
+
*/
|
|
190
|
+
export function getMcpToolObjects() {
|
|
191
|
+
const toolObjects = [];
|
|
192
|
+
|
|
193
|
+
for (const [serverName, server] of _servers) {
|
|
194
|
+
for (const toolDef of server.tools) {
|
|
195
|
+
// Prefix with mcp__ to avoid name collisions with built-ins
|
|
196
|
+
const tsunamiName = `mcp__${serverName}__${toolDef.name}`;
|
|
197
|
+
|
|
198
|
+
toolObjects.push({
|
|
199
|
+
name: tsunamiName,
|
|
200
|
+
description: `[MCP:${serverName}] ${toolDef.description || toolDef.name}\n\nOriginal name: ${toolDef.name}`,
|
|
201
|
+
input_schema: toolDef.inputSchema || { type: 'object', properties: {} },
|
|
202
|
+
async run(args) {
|
|
203
|
+
const srv = _servers.get(serverName);
|
|
204
|
+
if (!srv) return `Error: MCP server "${serverName}" is not connected`;
|
|
205
|
+
try {
|
|
206
|
+
const result = await sendRequest(srv, 'tools/call', {
|
|
207
|
+
name: toolDef.name,
|
|
208
|
+
arguments: args
|
|
209
|
+
});
|
|
210
|
+
// MCP content blocks: [{ type: "text", text: "..." }, { type: "image", ... }]
|
|
211
|
+
const content = result?.content || [];
|
|
212
|
+
if (content.length === 0) return '(empty result)';
|
|
213
|
+
return content
|
|
214
|
+
.map(block => {
|
|
215
|
+
if (block.type === 'text') return block.text;
|
|
216
|
+
if (block.type === 'image') return `[image: ${block.mimeType || 'unknown'}]`;
|
|
217
|
+
return JSON.stringify(block);
|
|
218
|
+
})
|
|
219
|
+
.join('\n');
|
|
220
|
+
} catch (e) {
|
|
221
|
+
return `Error: ${e.message}`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return toolObjects;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Returns status of all connected servers for /mcp command.
|
|
233
|
+
*/
|
|
234
|
+
export function getMcpStatus() {
|
|
235
|
+
if (_servers.size === 0) return [];
|
|
236
|
+
return Array.from(_servers.entries()).map(([name, server]) => ({
|
|
237
|
+
name,
|
|
238
|
+
toolCount: server.tools.length,
|
|
239
|
+
tools: server.tools.map(t => ({ name: t.name, description: (t.description || '').slice(0, 80) }))
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Returns config file path for user reference.
|
|
245
|
+
*/
|
|
246
|
+
export function getMcpConfigPath() {
|
|
247
|
+
return MCP_CONFIG_PATH;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Kill all child processes on exit.
|
|
252
|
+
*/
|
|
253
|
+
export function disconnectAll() {
|
|
254
|
+
for (const [, server] of _servers) {
|
|
255
|
+
try { server.process.kill('SIGTERM'); } catch {}
|
|
256
|
+
}
|
|
257
|
+
_servers.clear();
|
|
258
|
+
}
|
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}`;
|
|
@@ -680,3 +691,16 @@ export const ALL_TOOLS = [
|
|
|
680
691
|
AgentTool, SnipTool, BriefTool,
|
|
681
692
|
KairosTool
|
|
682
693
|
];
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Register MCP tool objects into the live tool list.
|
|
697
|
+
* Called after MCP servers connect so the agent loop sees them immediately.
|
|
698
|
+
*/
|
|
699
|
+
export function registerMcpTools(mcpToolObjects) {
|
|
700
|
+
for (const tool of mcpToolObjects) {
|
|
701
|
+
// Don't double-register if reconnecting
|
|
702
|
+
if (!ALL_TOOLS.find(t => t.name === tool.name)) {
|
|
703
|
+
ALL_TOOLS.push(tool);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|