tsunami-code 3.2.0 → 3.3.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 +42 -6
- package/lib/loop.js +25 -1
- package/lib/mcp.js +258 -0
- package/lib/tools.js +13 -0
- 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 } 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.3.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';
|
|
@@ -225,6 +226,22 @@ async function run() {
|
|
|
225
226
|
dim(` · ${sessionId} · Type your task. /help for commands. Ctrl+C to exit.\n`)
|
|
226
227
|
);
|
|
227
228
|
|
|
229
|
+
// MCP: connect servers from ~/.tsunami-code/mcp.json (non-blocking — warnings only)
|
|
230
|
+
{
|
|
231
|
+
const mcpResults = await connectMcpServers();
|
|
232
|
+
if (mcpResults.length > 0) {
|
|
233
|
+
const mcpTools = getMcpToolObjects();
|
|
234
|
+
registerMcpTools(mcpTools);
|
|
235
|
+
const ok = mcpResults.filter(r => !r.error);
|
|
236
|
+
const fail = mcpResults.filter(r => r.error);
|
|
237
|
+
if (ok.length) console.log(green(` ✓ MCP`) + dim(` ${ok.map(r => `${r.name}(${r.toolCount})`).join(', ')}`));
|
|
238
|
+
if (fail.length) {
|
|
239
|
+
for (const f of fail) console.log(yellow(` ⚠ MCP "${f.name}" failed: ${f.error}`));
|
|
240
|
+
}
|
|
241
|
+
if (ok.length || fail.length) console.log();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
228
245
|
// KAIROS: run due background tasks on startup
|
|
229
246
|
const dueTasks = getDueTasks();
|
|
230
247
|
if (dueTasks.length > 0) {
|
|
@@ -318,9 +335,8 @@ async function run() {
|
|
|
318
335
|
|
|
319
336
|
// ── Exit handler ─────────────────────────────────────────────────────────────
|
|
320
337
|
function gracefulExit(code = 0) {
|
|
321
|
-
try {
|
|
322
|
-
|
|
323
|
-
} catch {}
|
|
338
|
+
try { endSession(sessionDir); } catch {}
|
|
339
|
+
try { disconnectMcp(); } catch {}
|
|
324
340
|
console.log(dim('\n Goodbye.\n'));
|
|
325
341
|
process.exit(code);
|
|
326
342
|
}
|
|
@@ -440,7 +456,7 @@ async function run() {
|
|
|
440
456
|
|
|
441
457
|
// Skills: check if this is a skill command before built-ins
|
|
442
458
|
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)) {
|
|
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)) {
|
|
444
460
|
// Run the skill prompt as a user message
|
|
445
461
|
const userContent = skillMatch.args
|
|
446
462
|
? `[Skill: ${skillMatch.skill.name}]\n${skillMatch.prompt}`
|
|
@@ -481,6 +497,7 @@ async function run() {
|
|
|
481
497
|
['/status', 'Show context size and server'],
|
|
482
498
|
['/server <url>', 'Change model server URL'],
|
|
483
499
|
['/model [name]', 'Show or change active model (default: local)'],
|
|
500
|
+
['/mcp', 'Show MCP server status and tools'],
|
|
484
501
|
['/history', 'Show recent command history'],
|
|
485
502
|
['/exit', 'Exit'],
|
|
486
503
|
];
|
|
@@ -505,6 +522,25 @@ async function run() {
|
|
|
505
522
|
else console.log(dim(' Nothing to undo.\n'));
|
|
506
523
|
break;
|
|
507
524
|
}
|
|
525
|
+
case 'mcp': {
|
|
526
|
+
const mcpServers = getMcpStatus();
|
|
527
|
+
if (mcpServers.length === 0) {
|
|
528
|
+
console.log(dim(`\n No MCP servers connected.`));
|
|
529
|
+
console.log(dim(` Add servers to: ${getMcpConfigPath()}`));
|
|
530
|
+
console.log(dim(' Format: { "servers": { "name": { "command": "npx", "args": [...] } } }\n'));
|
|
531
|
+
} else {
|
|
532
|
+
console.log(blue(`\n MCP Servers (${mcpServers.length})\n`));
|
|
533
|
+
for (const srv of mcpServers) {
|
|
534
|
+
console.log(` ${cyan(srv.name)} ${dim(`— ${srv.toolCount} tool${srv.toolCount !== 1 ? 's' : ''}`)}`);
|
|
535
|
+
for (const tool of srv.tools) {
|
|
536
|
+
const desc = tool.description ? dim(` — ${tool.description}`) : '';
|
|
537
|
+
console.log(` ${dim('•')} ${tool.name}${desc}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
console.log();
|
|
541
|
+
}
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
508
544
|
case 'doctor': {
|
|
509
545
|
const { getRgPath } = await import('./lib/preflight.js');
|
|
510
546
|
const { getMemoryStats: _getMemoryStats } = await import('./lib/memory.js');
|
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+[^-]/,
|
|
@@ -412,7 +426,17 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
|
|
|
412
426
|
} catch {}
|
|
413
427
|
}
|
|
414
428
|
|
|
415
|
-
results
|
|
429
|
+
// Compress large results before storing in context
|
|
430
|
+
let resultForContext = resultStr;
|
|
431
|
+
if (resultStr.length > SUMMARY_THRESHOLD && SUMMARIZABLE_TOOLS.has(tc.name)) {
|
|
432
|
+
try {
|
|
433
|
+
const summary = await generateToolUseSummary(serverUrl, tc.name, resultStr);
|
|
434
|
+
if (summary && summary.length < resultStr.length * 0.75) {
|
|
435
|
+
resultForContext = `[Summarized ${resultStr.length} → ${summary.length} chars]\n${summary}`;
|
|
436
|
+
}
|
|
437
|
+
} catch { /* keep raw result on failure */ }
|
|
438
|
+
}
|
|
439
|
+
results.push(`[${tc.name} result]\n${resultForContext.slice(0, 8000)}`);
|
|
416
440
|
}
|
|
417
441
|
|
|
418
442
|
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
|
@@ -680,3 +680,16 @@ export const ALL_TOOLS = [
|
|
|
680
680
|
AgentTool, SnipTool, BriefTool,
|
|
681
681
|
KairosTool
|
|
682
682
|
];
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Register MCP tool objects into the live tool list.
|
|
686
|
+
* Called after MCP servers connect so the agent loop sees them immediately.
|
|
687
|
+
*/
|
|
688
|
+
export function registerMcpTools(mcpToolObjects) {
|
|
689
|
+
for (const tool of mcpToolObjects) {
|
|
690
|
+
// Don't double-register if reconnecting
|
|
691
|
+
if (!ALL_TOOLS.find(t => t.name === tool.name)) {
|
|
692
|
+
ALL_TOOLS.push(tool);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|