tsunami-code 2.7.0 → 2.9.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 +26 -3
- package/lib/loop.js +21 -2
- package/lib/prompt.js +21 -1
- package/lib/tools.js +130 -1
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -4,7 +4,7 @@ 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 } from './lib/loop.js';
|
|
7
|
+
import { agentLoop, quickCompletion, setModel, getModel } from './lib/loop.js';
|
|
8
8
|
import { buildSystemPrompt } from './lib/prompt.js';
|
|
9
9
|
import { runPreflight, checkServer } from './lib/preflight.js';
|
|
10
10
|
import { setSession, undo, undoStackSize } from './lib/tools.js';
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
getSessionContext
|
|
21
21
|
} from './lib/memory.js';
|
|
22
22
|
|
|
23
|
-
const VERSION = '2.
|
|
23
|
+
const VERSION = '2.9.0';
|
|
24
24
|
const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
|
|
25
25
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
26
26
|
const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
|
|
@@ -131,7 +131,7 @@ if (setServerIdx !== -1 && argv[setServerIdx + 1]) {
|
|
|
131
131
|
|
|
132
132
|
// ── Confirm Callback (dangerous command prompt) ─────────────────────────────
|
|
133
133
|
function makeConfirmCallback(rl) {
|
|
134
|
-
|
|
134
|
+
const cb = async (cmd) => {
|
|
135
135
|
return new Promise((resolve) => {
|
|
136
136
|
rl.pause();
|
|
137
137
|
process.stdout.write(`\n ${yellow('⚠ Dangerous:')} ${dim(cmd.slice(0, 120))}\n`);
|
|
@@ -145,6 +145,20 @@ function makeConfirmCallback(rl) {
|
|
|
145
145
|
process.stdin.once('data', handler);
|
|
146
146
|
});
|
|
147
147
|
};
|
|
148
|
+
|
|
149
|
+
cb._askUser = (question, resolve) => {
|
|
150
|
+
rl.pause();
|
|
151
|
+
process.stdout.write(`\n ${cyan('?')} ${question}\n ${dim('> ')}`);
|
|
152
|
+
const handler = (data) => {
|
|
153
|
+
process.stdin.removeListener('data', handler);
|
|
154
|
+
rl.resume();
|
|
155
|
+
process.stdout.write('\n');
|
|
156
|
+
resolve(data.toString().trim());
|
|
157
|
+
};
|
|
158
|
+
process.stdin.once('data', handler);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return cb;
|
|
148
162
|
}
|
|
149
163
|
|
|
150
164
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
@@ -369,6 +383,7 @@ async function run() {
|
|
|
369
383
|
['/clear', 'Start new conversation'],
|
|
370
384
|
['/status', 'Show context size and server'],
|
|
371
385
|
['/server <url>', 'Change model server URL'],
|
|
386
|
+
['/model [name]', 'Show or change active model (default: local)'],
|
|
372
387
|
['/exit', 'Exit'],
|
|
373
388
|
];
|
|
374
389
|
for (const [c, desc] of cmds) {
|
|
@@ -437,6 +452,14 @@ async function run() {
|
|
|
437
452
|
console.log(dim(` Current server: ${currentServerUrl}\n`));
|
|
438
453
|
}
|
|
439
454
|
break;
|
|
455
|
+
case 'model':
|
|
456
|
+
if (rest[0]) {
|
|
457
|
+
setModel(rest[0]);
|
|
458
|
+
console.log(green(` Model changed to: ${rest[0]}\n`));
|
|
459
|
+
} else {
|
|
460
|
+
console.log(dim(` Current model: ${getModel()}\n`));
|
|
461
|
+
}
|
|
462
|
+
break;
|
|
440
463
|
case 'memory':
|
|
441
464
|
await handleMemoryCommand(rest);
|
|
442
465
|
break;
|
package/lib/loop.js
CHANGED
|
@@ -29,6 +29,11 @@ function isDangerous(cmd) {
|
|
|
29
29
|
// Skip waitForServer after first successful connection
|
|
30
30
|
let _serverVerified = false;
|
|
31
31
|
|
|
32
|
+
// Current model identifier — changeable at runtime via /model command
|
|
33
|
+
let _currentModel = 'local';
|
|
34
|
+
export function setModel(model) { _currentModel = model; }
|
|
35
|
+
export function getModel() { return _currentModel; }
|
|
36
|
+
|
|
32
37
|
// Parse tool calls from any format the model might produce
|
|
33
38
|
function parseToolCalls(content) {
|
|
34
39
|
const calls = [];
|
|
@@ -199,7 +204,7 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
|
|
|
199
204
|
method: 'POST',
|
|
200
205
|
headers: { 'Content-Type': 'application/json' },
|
|
201
206
|
body: JSON.stringify({
|
|
202
|
-
model:
|
|
207
|
+
model: _currentModel,
|
|
203
208
|
messages: finalMessages,
|
|
204
209
|
stream: true,
|
|
205
210
|
temperature: 0.1,
|
|
@@ -254,7 +259,7 @@ export async function quickCompletion(serverUrl, systemPrompt, userMessage) {
|
|
|
254
259
|
signal: controller.signal,
|
|
255
260
|
headers: { 'Content-Type': 'application/json' },
|
|
256
261
|
body: JSON.stringify({
|
|
257
|
-
model:
|
|
262
|
+
model: _currentModel,
|
|
258
263
|
messages: [
|
|
259
264
|
{ role: 'system', content: systemPrompt },
|
|
260
265
|
{ role: 'user', content: userMessage }
|
|
@@ -312,6 +317,20 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
|
|
|
312
317
|
continue;
|
|
313
318
|
}
|
|
314
319
|
|
|
320
|
+
// AskUser: intercept and surface the question to the user
|
|
321
|
+
if (tc.name === 'AskUser' && confirmCallback) {
|
|
322
|
+
const parsed = typeof tc.arguments === 'string' ? JSON.parse(tc.arguments) : tc.arguments;
|
|
323
|
+
const normalized = normalizeArgs(parsed);
|
|
324
|
+
onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
|
|
325
|
+
// Reuse confirmCallback channel but pass question back as answer
|
|
326
|
+
const answer = await new Promise(resolve => confirmCallback._askUser
|
|
327
|
+
? confirmCallback._askUser(normalized.question, resolve)
|
|
328
|
+
: resolve('[No answer provided]')
|
|
329
|
+
);
|
|
330
|
+
results.push(`[AskUser result]\nUser answered: ${answer}`);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
|
|
315
334
|
// Dangerous command confirmation
|
|
316
335
|
if (tc.name === 'Bash' && confirmCallback) {
|
|
317
336
|
const parsed = typeof tc.arguments === 'string' ? JSON.parse(tc.arguments) : tc.arguments;
|
package/lib/prompt.js
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
function getGitContext() {
|
|
7
|
+
try {
|
|
8
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
9
|
+
const status = execSync('git status --short', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
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}`);
|
|
13
|
+
if (log) parts.push(`Recent commits:\n${log}`);
|
|
14
|
+
return `\n\n<git>\n${parts.join('\n\n')}\n</git>`;
|
|
15
|
+
} catch {
|
|
16
|
+
return '';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
4
19
|
|
|
5
20
|
function loadContextFile() {
|
|
6
21
|
const locations = [
|
|
@@ -23,6 +38,8 @@ export function buildSystemPrompt(memoryContext = '') {
|
|
|
23
38
|
const cwd = process.cwd();
|
|
24
39
|
const context = loadContextFile();
|
|
25
40
|
|
|
41
|
+
const gitContext = getGitContext();
|
|
42
|
+
|
|
26
43
|
return `You are an expert software engineer and technical assistant operating as a CLI agent. You think deeply before acting, trace data flow before changing code, and verify your work.
|
|
27
44
|
|
|
28
45
|
To use a tool, output ONLY this format — nothing else before or after the tool call block:
|
|
@@ -38,7 +55,7 @@ Available tools: Bash, Read, Write, Edit, Glob, Grep, Note, Checkpoint. Use them
|
|
|
38
55
|
- Platform: ${process.platform}
|
|
39
56
|
- Shell: ${process.platform === 'win32' ? 'cmd/powershell' : 'bash'}
|
|
40
57
|
- Date: ${new Date().toISOString().split('T')[0]}
|
|
41
|
-
</environment
|
|
58
|
+
</environment>${gitContext}
|
|
42
59
|
|
|
43
60
|
<tools>
|
|
44
61
|
- **Bash**: Run shell commands. Never use for grep/find/cat — use dedicated tools.
|
|
@@ -49,6 +66,9 @@ Available tools: Bash, Read, Write, Edit, Glob, Grep, Note, Checkpoint. Use them
|
|
|
49
66
|
- **Grep**: Search file contents by regex. Always use instead of grep in Bash.
|
|
50
67
|
- **Note**: Save a permanent discovery to project memory (.tsunami/). Use liberally for traps, patterns, architectural decisions.
|
|
51
68
|
- **Checkpoint**: Save current task progress to session memory so work is resumable if the session ends.
|
|
69
|
+
- **WebFetch**: Fetch any URL and get the page content as text. Use for docs, GitHub files, APIs.
|
|
70
|
+
- **TodoWrite**: Manage a persistent task list (add/complete/delete/list). Use for any multi-step task.
|
|
71
|
+
- **AskUser**: Ask the user a clarifying question when genuinely blocked. Use sparingly.
|
|
52
72
|
</tools>
|
|
53
73
|
|
|
54
74
|
<reasoning_protocol>
|
package/lib/tools.js
CHANGED
|
@@ -4,6 +4,7 @@ import { glob } from 'glob';
|
|
|
4
4
|
import { promisify } from 'util';
|
|
5
5
|
import { getRgPath } from './preflight.js';
|
|
6
6
|
import { addFileNote, updateContext, appendDecision } from './memory.js';
|
|
7
|
+
import fetch from 'node-fetch';
|
|
7
8
|
|
|
8
9
|
const execAsync = promisify(exec);
|
|
9
10
|
|
|
@@ -324,4 +325,132 @@ EXAMPLE:
|
|
|
324
325
|
}
|
|
325
326
|
};
|
|
326
327
|
|
|
327
|
-
|
|
328
|
+
// ── WEB FETCH ─────────────────────────────────────────────────────────────────
|
|
329
|
+
export const WebFetchTool = {
|
|
330
|
+
name: 'WebFetch',
|
|
331
|
+
description: `Fetches content from a URL and returns it as text. Use for reading documentation, API references, GitHub files, or any web resource needed to complete a task.
|
|
332
|
+
|
|
333
|
+
- Returns page content as plain text (HTML stripped)
|
|
334
|
+
- Max ~50KB returned; large pages are truncated
|
|
335
|
+
- Do not use for downloading binaries`,
|
|
336
|
+
input_schema: {
|
|
337
|
+
type: 'object',
|
|
338
|
+
properties: {
|
|
339
|
+
url: { type: 'string', description: 'The URL to fetch' },
|
|
340
|
+
prompt: { type: 'string', description: 'What to extract or summarize from the page (optional — returns raw text if omitted)' }
|
|
341
|
+
},
|
|
342
|
+
required: ['url']
|
|
343
|
+
},
|
|
344
|
+
async run({ url, prompt: _prompt }) {
|
|
345
|
+
try {
|
|
346
|
+
const controller = new AbortController();
|
|
347
|
+
const timer = setTimeout(() => controller.abort(), 15000);
|
|
348
|
+
const res = await fetch(url, {
|
|
349
|
+
signal: controller.signal,
|
|
350
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; TsunamiCode/2.9)' }
|
|
351
|
+
});
|
|
352
|
+
clearTimeout(timer);
|
|
353
|
+
if (!res.ok) return `Error: HTTP ${res.status} ${res.statusText}`;
|
|
354
|
+
const raw = await res.text();
|
|
355
|
+
// Strip HTML tags, collapse whitespace
|
|
356
|
+
const text = raw
|
|
357
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
358
|
+
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
359
|
+
.replace(/<[^>]+>/g, ' ')
|
|
360
|
+
.replace(/ /g, ' ')
|
|
361
|
+
.replace(/&/g, '&')
|
|
362
|
+
.replace(/</g, '<')
|
|
363
|
+
.replace(/>/g, '>')
|
|
364
|
+
.replace(/"/g, '"')
|
|
365
|
+
.replace(/\s{3,}/g, '\n\n')
|
|
366
|
+
.trim();
|
|
367
|
+
return text.slice(0, 50000) + (text.length > 50000 ? '\n\n[truncated]' : '');
|
|
368
|
+
} catch (e) {
|
|
369
|
+
if (e.name === 'AbortError') return 'Error: Request timed out after 15s';
|
|
370
|
+
return `Error fetching URL: ${e.message}`;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// ── TODO WRITE ────────────────────────────────────────────────────────────────
|
|
376
|
+
// In-memory todo list — persists for the session, visible to the model
|
|
377
|
+
const _todos = [];
|
|
378
|
+
let _todoId = 0;
|
|
379
|
+
|
|
380
|
+
export const TodoWriteTool = {
|
|
381
|
+
name: 'TodoWrite',
|
|
382
|
+
description: `Manage a persistent task list for the current session. Use this to track multi-step work so nothing gets lost.
|
|
383
|
+
|
|
384
|
+
Operations:
|
|
385
|
+
- add: Add a new todo item
|
|
386
|
+
- complete: Mark a todo as done (by id)
|
|
387
|
+
- delete: Remove a todo (by id)
|
|
388
|
+
- list: Show all todos (also happens automatically)
|
|
389
|
+
|
|
390
|
+
WHEN TO USE:
|
|
391
|
+
- Any task with 3+ steps — create the list upfront
|
|
392
|
+
- After completing a step — mark it done immediately
|
|
393
|
+
- When starting work on a step — mark it in_progress
|
|
394
|
+
|
|
395
|
+
The list is shown to the user after every update.`,
|
|
396
|
+
input_schema: {
|
|
397
|
+
type: 'object',
|
|
398
|
+
properties: {
|
|
399
|
+
op: { type: 'string', enum: ['add', 'complete', 'delete', 'list'], description: 'Operation to perform' },
|
|
400
|
+
text: { type: 'string', description: 'Todo text (for add)' },
|
|
401
|
+
id: { type: 'number', description: 'Todo ID (for complete/delete)' },
|
|
402
|
+
status: { type: 'string', enum: ['pending', 'in_progress', 'done'], description: 'Status for complete op (default: done)' }
|
|
403
|
+
},
|
|
404
|
+
required: ['op']
|
|
405
|
+
},
|
|
406
|
+
async run({ op, text, id, status = 'done' }) {
|
|
407
|
+
if (op === 'add') {
|
|
408
|
+
if (!text) return 'Error: text required for add';
|
|
409
|
+
_todoId++;
|
|
410
|
+
_todos.push({ id: _todoId, text, status: 'pending' });
|
|
411
|
+
} else if (op === 'complete') {
|
|
412
|
+
const todo = _todos.find(t => t.id === id);
|
|
413
|
+
if (!todo) return `Error: todo #${id} not found`;
|
|
414
|
+
todo.status = status;
|
|
415
|
+
} else if (op === 'delete') {
|
|
416
|
+
const idx = _todos.findIndex(t => t.id === id);
|
|
417
|
+
if (idx === -1) return `Error: todo #${id} not found`;
|
|
418
|
+
_todos.splice(idx, 1);
|
|
419
|
+
}
|
|
420
|
+
// Always return current list
|
|
421
|
+
if (_todos.length === 0) return 'Todo list is empty.';
|
|
422
|
+
const icons = { pending: '○', in_progress: '◉', done: '✓' };
|
|
423
|
+
return _todos.map(t => `${icons[t.status] || '○'} [${t.id}] ${t.text}`).join('\n');
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// ── ASK USER ──────────────────────────────────────────────────────────────────
|
|
428
|
+
// This tool is a signal — the agent loop in index.js intercepts it and prompts the user
|
|
429
|
+
export const AskUserTool = {
|
|
430
|
+
name: 'AskUser',
|
|
431
|
+
description: `Ask the user a clarifying question and wait for their answer. Use this when you are genuinely blocked and need input that cannot be inferred.
|
|
432
|
+
|
|
433
|
+
Only use when:
|
|
434
|
+
- Multiple valid approaches exist with meaningfully different outcomes
|
|
435
|
+
- Required information cannot be found in the codebase, env, or context
|
|
436
|
+
- A destructive or irreversible action needs explicit confirmation
|
|
437
|
+
|
|
438
|
+
Do NOT use for:
|
|
439
|
+
- Things you can infer from context
|
|
440
|
+
- Choices that don't materially affect the outcome
|
|
441
|
+
- Asking if you should proceed (just proceed)`,
|
|
442
|
+
input_schema: {
|
|
443
|
+
type: 'object',
|
|
444
|
+
properties: {
|
|
445
|
+
question: { type: 'string', description: 'The question to ask the user' }
|
|
446
|
+
},
|
|
447
|
+
required: ['question']
|
|
448
|
+
},
|
|
449
|
+
async run({ question }) {
|
|
450
|
+
// The agent loop intercepts this and handles the actual prompt.
|
|
451
|
+
// Return value here is fallback only.
|
|
452
|
+
return `[AskUser] ${question}`;
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
export const ALL_TOOLS = [BashTool, ReadTool, WriteTool, EditTool, GlobTool, GrepTool, NoteTool, CheckpointTool, WebFetchTool, TodoWriteTool, AskUserTool];
|