tsunami-code 2.8.0 → 3.0.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
@@ -4,7 +4,8 @@ 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, setModel, getModel } from './lib/loop.js';
7
+ import { agentLoop, quickCompletion, setModel, getModel, tokenStats } from './lib/loop.js';
8
+ import { injectServerContext } from './lib/tools.js';
8
9
  import { buildSystemPrompt } from './lib/prompt.js';
9
10
  import { runPreflight, checkServer } from './lib/preflight.js';
10
11
  import { setSession, undo, undoStackSize } from './lib/tools.js';
@@ -20,7 +21,7 @@ import {
20
21
  getSessionContext
21
22
  } from './lib/memory.js';
22
23
 
23
- const VERSION = '2.8.0';
24
+ const VERSION = '3.0.0';
24
25
  const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
25
26
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
26
27
  const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
@@ -131,7 +132,7 @@ if (setServerIdx !== -1 && argv[setServerIdx + 1]) {
131
132
 
132
133
  // ── Confirm Callback (dangerous command prompt) ─────────────────────────────
133
134
  function makeConfirmCallback(rl) {
134
- return async (cmd) => {
135
+ const cb = async (cmd) => {
135
136
  return new Promise((resolve) => {
136
137
  rl.pause();
137
138
  process.stdout.write(`\n ${yellow('⚠ Dangerous:')} ${dim(cmd.slice(0, 120))}\n`);
@@ -145,6 +146,20 @@ function makeConfirmCallback(rl) {
145
146
  process.stdin.once('data', handler);
146
147
  });
147
148
  };
149
+
150
+ cb._askUser = (question, resolve) => {
151
+ rl.pause();
152
+ process.stdout.write(`\n ${cyan('?')} ${question}\n ${dim('> ')}`);
153
+ const handler = (data) => {
154
+ process.stdin.removeListener('data', handler);
155
+ rl.resume();
156
+ process.stdout.write('\n');
157
+ resolve(data.toString().trim());
158
+ };
159
+ process.stdin.once('data', handler);
160
+ };
161
+
162
+ return cb;
148
163
  }
149
164
 
150
165
  // ── Main ──────────────────────────────────────────────────────────────────────
@@ -158,8 +173,31 @@ async function run() {
158
173
  initProjectMemory(cwd);
159
174
  setSession({ sessionDir, cwd });
160
175
 
176
+ // Inject server context into AgentTool
177
+ injectServerContext(serverUrl, buildSystemPrompt());
178
+
161
179
  printBanner(serverUrl);
162
180
 
181
+ // Persistent history — load previous commands
182
+ const HISTORY_FILE = join(CONFIG_DIR, 'history.jsonl');
183
+ function appendHistory(line) {
184
+ try {
185
+ const entry = JSON.stringify({ cmd: line, ts: Date.now(), cwd }) + '\n';
186
+ import('fs').then(({ appendFileSync }) => { try { appendFileSync(HISTORY_FILE, entry, 'utf8'); } catch {} });
187
+ } catch {}
188
+ }
189
+ function loadHistory() {
190
+ try {
191
+ if (!existsSync(HISTORY_FILE)) return [];
192
+ return readFileSync(HISTORY_FILE, 'utf8')
193
+ .trim().split('\n').filter(Boolean)
194
+ .map(l => { try { return JSON.parse(l).cmd; } catch { return null; } })
195
+ .filter(Boolean).reverse().slice(0, 200);
196
+ } catch { return []; }
197
+ }
198
+ const historyEntries = loadHistory();
199
+ let historyIdx = -1;
200
+
163
201
  // Preflight checks
164
202
  process.stdout.write(dim(' Checking server connection...'));
165
203
  const { errors, warnings } = await runPreflight(serverUrl);
@@ -342,10 +380,28 @@ async function run() {
342
380
  console.log(red(` Unknown memory subcommand: ${sub}\n Try: /memory, /memory files, /memory view <file>, /memory clear\n`));
343
381
  }
344
382
 
383
+ // Frustration detection — from leaked userPromptKeywords.ts pattern
384
+ const FRUSTRATION_PATTERNS = [
385
+ /\b(wtf|fuck|shit|damn|idiot|stupid|useless|broken|wrong|garbage|trash|terrible|awful|hate)\b/i,
386
+ /\b(not working|still broken|still wrong|same error|again|ugh|argh)\b/i,
387
+ /!{2,}/,
388
+ /\b(why (would|did|is|are|does|do) you|you (keep|always|never|can't|cannot|won't|don't))\b/i,
389
+ /\b(i (said|told|asked)|stop|listen|pay attention)\b/i
390
+ ];
391
+ function detectFrustration(text) {
392
+ return FRUSTRATION_PATTERNS.some(p => p.test(text));
393
+ }
394
+
345
395
  rl.on('line', async (input) => {
346
396
  const line = input.trim();
347
397
  if (!line) { rl.prompt(); return; }
348
398
 
399
+ // Append to persistent history
400
+ if (!line.startsWith('/')) {
401
+ appendHistory(line);
402
+ historyIdx = -1;
403
+ }
404
+
349
405
  if (line.startsWith('/')) {
350
406
  const parts = line.slice(1).split(' ');
351
407
  const cmd = parts[0].toLowerCase();
@@ -370,6 +426,7 @@ async function run() {
370
426
  ['/status', 'Show context size and server'],
371
427
  ['/server <url>', 'Change model server URL'],
372
428
  ['/model [name]', 'Show or change active model (default: local)'],
429
+ ['/history', 'Show recent command history'],
373
430
  ['/exit', 'Exit'],
374
431
  ];
375
432
  for (const [c, desc] of cmds) {
@@ -416,13 +473,22 @@ async function run() {
416
473
  console.log(dim(` CWD : ${cwd}\n`));
417
474
  break;
418
475
  }
419
- case 'cost':
420
- console.log(blue('\n Session Token Estimate'));
421
- console.log(dim(` Input : ~${_inputTokens.toLocaleString()}`));
422
- console.log(dim(` Output : ~${_outputTokens.toLocaleString()}`));
423
- console.log(dim(` Total : ~${(_inputTokens + _outputTokens).toLocaleString()}`));
424
- console.log(dim(' (Estimates only)\n'));
476
+ case 'cost': {
477
+ const hasReal = tokenStats.requests > 0;
478
+ console.log(blue('\n Session Token Usage'));
479
+ if (hasReal) {
480
+ console.log(dim(` Input : ${tokenStats.input.toLocaleString()} tokens (actual)`));
481
+ console.log(dim(` Output : ${tokenStats.output.toLocaleString()} tokens (actual)`));
482
+ console.log(dim(` Total : ${(tokenStats.input + tokenStats.output).toLocaleString()} tokens`));
483
+ console.log(dim(` Requests: ${tokenStats.requests}`));
484
+ } else {
485
+ console.log(dim(` Input : ~${_inputTokens.toLocaleString()} (estimated)`));
486
+ console.log(dim(` Output : ~${_outputTokens.toLocaleString()} (estimated)`));
487
+ console.log(dim(` Total : ~${(_inputTokens + _outputTokens).toLocaleString()} (estimated)`));
488
+ }
489
+ console.log();
425
490
  break;
491
+ }
426
492
  case 'clear':
427
493
  resetSession();
428
494
  console.log(green(' Session cleared.\n'));
@@ -449,6 +515,14 @@ async function run() {
449
515
  case 'memory':
450
516
  await handleMemoryCommand(rest);
451
517
  break;
518
+ case 'history': {
519
+ const recent = historyEntries.slice(0, 20);
520
+ if (recent.length === 0) { console.log(dim(' No history yet.\n')); break; }
521
+ console.log(blue(`\n Recent commands (${recent.length}):`));
522
+ recent.forEach((h, i) => console.log(dim(` ${String(i + 1).padStart(2)} ${h.slice(0, 100)}`)));
523
+ console.log();
524
+ break;
525
+ }
452
526
  case 'exit': case 'quit':
453
527
  gracefulExit(0);
454
528
  return;
@@ -465,6 +539,11 @@ async function run() {
465
539
  userContent = `[Previous session summary]\n${lastSessionSummary}\n\n---\n\n${line}`;
466
540
  }
467
541
 
542
+ // Frustration injection — tell the model to acknowledge and course-correct
543
+ if (detectFrustration(line)) {
544
+ userContent += '\n\n[system: User appears frustrated. Acknowledge the issue directly, do not repeat the same approach. Be concise and action-focused.]';
545
+ }
546
+
468
547
  const fullMessages = [
469
548
  { role: 'system', content: systemPrompt },
470
549
  ...messages,
package/lib/loop.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import fetch from 'node-fetch';
2
- import { ALL_TOOLS } from './tools.js';
2
+ import { ALL_TOOLS, injectAgentLoop, injectServerContext } from './tools.js';
3
3
  import {
4
4
  assembleContext,
5
5
  extractFilePaths,
@@ -29,6 +29,9 @@ function isDangerous(cmd) {
29
29
  // Skip waitForServer after first successful connection
30
30
  let _serverVerified = false;
31
31
 
32
+ // Real token tracking from API responses
33
+ export const tokenStats = { input: 0, output: 0, requests: 0 };
34
+
32
35
  // Current model identifier — changeable at runtime via /model command
33
36
  let _currentModel = 'local';
34
37
  export function setModel(model) { _currentModel = model; }
@@ -234,6 +237,12 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
234
237
  fullContent += token;
235
238
  onToken(token);
236
239
  }
240
+ // Capture real token counts from usage field (sent on final chunk by llama.cpp)
241
+ if (parsed.usage) {
242
+ tokenStats.input += parsed.usage.prompt_tokens || 0;
243
+ tokenStats.output += parsed.usage.completion_tokens || 0;
244
+ tokenStats.requests++;
245
+ }
237
246
  }
238
247
  }
239
248
 
@@ -278,6 +287,12 @@ export async function quickCompletion(serverUrl, systemPrompt, userMessage) {
278
287
  }
279
288
  }
280
289
 
290
+ // Self-register into tools.js so AgentTool can call back into us
291
+ // (done here at module load time to avoid circular import at parse time)
292
+ import('./tools.js').then(m => {
293
+ m.injectAgentLoop(agentLoop);
294
+ }).catch(() => {});
295
+
281
296
  export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessionInfo = null, confirmCallback = null, maxIterations = 15) {
282
297
  const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
283
298
  const currentTask = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '';
@@ -317,6 +332,20 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
317
332
  continue;
318
333
  }
319
334
 
335
+ // AskUser: intercept and surface the question to the user
336
+ if (tc.name === 'AskUser' && confirmCallback) {
337
+ const parsed = typeof tc.arguments === 'string' ? JSON.parse(tc.arguments) : tc.arguments;
338
+ const normalized = normalizeArgs(parsed);
339
+ onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
340
+ // Reuse confirmCallback channel but pass question back as answer
341
+ const answer = await new Promise(resolve => confirmCallback._askUser
342
+ ? confirmCallback._askUser(normalized.question, resolve)
343
+ : resolve('[No answer provided]')
344
+ );
345
+ results.push(`[AskUser result]\nUser answered: ${answer}`);
346
+ continue;
347
+ }
348
+
320
349
  // Dangerous command confirmation
321
350
  if (tc.name === 'Bash' && confirmCallback) {
322
351
  const parsed = typeof tc.arguments === 'string' ? JSON.parse(tc.arguments) : tc.arguments;
@@ -333,7 +362,26 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
333
362
 
334
363
  onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
335
364
  const result = await runTool(tc.name, tc.arguments, sessionInfo, sessionFiles);
336
- results.push(`[${tc.name} result]\n${String(result).slice(0, 8000)}`);
365
+ const resultStr = String(result);
366
+
367
+ // Handle Snip signal — surgically remove message indices from context
368
+ if (tc.name === 'Snip') {
369
+ try {
370
+ const sig = JSON.parse(resultStr);
371
+ if (sig.__snip__ && Array.isArray(sig.indices)) {
372
+ const toRemove = new Set(sig.indices.map(Number));
373
+ // messages[0] is system prompt, indices are 1-based user/assistant turns
374
+ const kept = messages.filter((_, i) => i === 0 || !toRemove.has(i - 1));
375
+ const removed = messages.length - kept.length;
376
+ messages.length = 0;
377
+ kept.forEach(m => messages.push(m));
378
+ results.push(`[Snip result]\nRemoved ${removed} messages from context. Reason: ${sig.reason}`);
379
+ continue;
380
+ }
381
+ } catch {}
382
+ }
383
+
384
+ results.push(`[${tc.name} result]\n${resultStr.slice(0, 8000)}`);
337
385
  }
338
386
 
339
387
  messages.push({
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,13 @@ 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
+ - **WebSearch**: Search the web via DuckDuckGo. Returns titles, URLs, snippets. Follow up with WebFetch.
71
+ - **TodoWrite**: Manage a persistent task list (add/complete/delete/list). Use for any multi-step task.
72
+ - **AskUser**: Ask the user a clarifying question when genuinely blocked. Use sparingly.
73
+ - **Agent**: Spawn a sub-agent to handle an independent task. Call multiple times in one response for parallel execution.
74
+ - **Snip**: Surgically remove specific messages from context to free space without losing everything.
75
+ - **Brief**: Write a working-memory note to yourself. Injected into next turn — ensures nothing is forgotten on long tasks.
52
76
  </tools>
53
77
 
54
78
  <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,327 @@ EXAMPLE:
324
325
  }
325
326
  };
326
327
 
327
- export const ALL_TOOLS = [BashTool, ReadTool, WriteTool, EditTool, GlobTool, GrepTool, NoteTool, CheckpointTool];
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(/&nbsp;/g, ' ')
361
+ .replace(/&amp;/g, '&')
362
+ .replace(/&lt;/g, '<')
363
+ .replace(/&gt;/g, '>')
364
+ .replace(/&quot;/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
+ // ── WEB SEARCH ────────────────────────────────────────────────────────────────
457
+ export const WebSearchTool = {
458
+ name: 'WebSearch',
459
+ description: `Search the web and return results. Use when you need current information, documentation, error solutions, or anything not in the codebase.
460
+
461
+ Returns titles, URLs, and snippets for the top results. Follow up with WebFetch on a specific result to get the full content.`,
462
+ input_schema: {
463
+ type: 'object',
464
+ properties: {
465
+ query: { type: 'string', description: 'Search query' },
466
+ num_results: { type: 'number', description: 'Number of results to return (default 8, max 20)' }
467
+ },
468
+ required: ['query']
469
+ },
470
+ async run({ query, num_results = 8 }) {
471
+ try {
472
+ const n = Math.min(num_results, 20);
473
+ const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
474
+ const controller = new AbortController();
475
+ const timer = setTimeout(() => controller.abort(), 12000);
476
+ const res = await fetch(url, {
477
+ signal: controller.signal,
478
+ headers: {
479
+ 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
480
+ 'Accept': 'text/html'
481
+ }
482
+ });
483
+ clearTimeout(timer);
484
+ if (!res.ok) return `Error: HTTP ${res.status}`;
485
+ const html = await res.text();
486
+
487
+ // Parse results from DuckDuckGo HTML
488
+ const results = [];
489
+ const resultBlocks = html.match(/<div class="result[^"]*"[\s\S]*?<\/div>\s*<\/div>/g) || [];
490
+ for (const block of resultBlocks.slice(0, n)) {
491
+ const titleMatch = block.match(/<a[^>]+class="result__a"[^>]*>([\s\S]*?)<\/a>/);
492
+ const urlMatch = block.match(/href="([^"]+)"/);
493
+ const snippetMatch = block.match(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/);
494
+
495
+ const title = titleMatch ? titleMatch[1].replace(/<[^>]+>/g, '').trim() : '';
496
+ const href = urlMatch ? urlMatch[1] : '';
497
+ const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
498
+
499
+ // DuckDuckGo uses redirect URLs — extract the actual URL
500
+ const actualUrl = href.includes('uddg=')
501
+ ? decodeURIComponent(href.match(/uddg=([^&]+)/)?.[1] || href)
502
+ : href;
503
+
504
+ if (title && actualUrl) {
505
+ results.push(`${results.length + 1}. ${title}\n ${actualUrl}\n ${snippet}`);
506
+ }
507
+ }
508
+
509
+ if (results.length === 0) return `No results found for: ${query}`;
510
+ return `Search results for "${query}":\n\n${results.join('\n\n')}`;
511
+ } catch (e) {
512
+ if (e.name === 'AbortError') return 'Error: Search timed out';
513
+ return `Error: ${e.message}`;
514
+ }
515
+ }
516
+ };
517
+
518
+ // ── AGENT TOOL ────────────────────────────────────────────────────────────────
519
+ // Circular import prevention — agentLoop is injected at runtime by loop.js
520
+ let _agentLoopRef = null;
521
+ export function injectAgentLoop(fn) { _agentLoopRef = fn; }
522
+
523
+ export const AgentTool = {
524
+ name: 'Agent',
525
+ description: `Launch a sub-agent to handle an independent task in parallel. The sub-agent has access to all the same tools (Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, etc.) and works autonomously until done.
526
+
527
+ USE THIS FOR:
528
+ - Independent subtasks that don't depend on each other (run multiple Agents in one response)
529
+ - Long-running research tasks you don't want in the main context
530
+ - Isolated operations (e.g. "analyze file A" + "analyze file B" simultaneously)
531
+
532
+ The agent returns a summary of what it did and what it found. Keep the task description specific and self-contained — the sub-agent has no knowledge of the current conversation.
533
+
534
+ IMPORTANT: You can call Agent multiple times in one response to run tasks truly in parallel.`,
535
+ input_schema: {
536
+ type: 'object',
537
+ properties: {
538
+ task: { type: 'string', description: 'Complete, self-contained task description for the sub-agent' },
539
+ serverUrl: { type: 'string', description: 'Override server URL (optional)' }
540
+ },
541
+ required: ['task']
542
+ },
543
+ async run({ task, serverUrl }) {
544
+ if (!_agentLoopRef) return 'Error: AgentTool not initialized (no agent loop reference)';
545
+ if (!_currentServerUrl) return 'Error: AgentTool not initialized (no server URL)';
546
+
547
+ const url = serverUrl || _currentServerUrl;
548
+ const subMessages = [
549
+ { role: 'system', content: _agentSystemPrompt || 'You are a capable software engineering sub-agent. Complete the given task fully and return a summary of what you did.' },
550
+ { role: 'user', content: task }
551
+ ];
552
+
553
+ const outputTokens = [];
554
+ let done = false;
555
+ try {
556
+ await _agentLoopRef(
557
+ url,
558
+ subMessages,
559
+ (token) => { outputTokens.push(token); },
560
+ () => {}, // tool call display — silent in sub-agent
561
+ null, // no session info for sub-agents
562
+ null, // no confirm callback
563
+ 10 // max iterations
564
+ );
565
+ done = true;
566
+ } catch (e) {
567
+ return `Sub-agent error: ${e.message}`;
568
+ }
569
+
570
+ // Find the last assistant message as the result
571
+ const lastAssistant = [...subMessages].reverse().find(m => m.role === 'assistant');
572
+ const result = lastAssistant?.content || outputTokens.join('');
573
+ return `[Sub-agent result]\n${String(result).slice(0, 6000)}`;
574
+ }
575
+ };
576
+
577
+ // Server URL + system prompt injected by loop.js at startup
578
+ let _currentServerUrl = null;
579
+ let _agentSystemPrompt = null;
580
+ export function injectServerContext(serverUrl, systemPrompt) {
581
+ _currentServerUrl = serverUrl;
582
+ _agentSystemPrompt = systemPrompt;
583
+ }
584
+
585
+ // ── SNIP TOOL ─────────────────────────────────────────────────────────────────
586
+ // The model calls this to surgically remove specific turns from context
587
+ // loop.js handles the actual splice — this is a signal tool
588
+ export const SnipTool = {
589
+ name: 'Snip',
590
+ description: `Remove specific conversation turns from context to free up space, without losing the whole conversation like /compact does.
591
+
592
+ Use when:
593
+ - A specific tool result was very large and is no longer needed (e.g. a full file read you've already processed)
594
+ - An early exploration phase produced lots of output that's no longer relevant
595
+ - You want to keep recent context but drop stale earlier parts
596
+
597
+ The 'indices' are 0-based positions in the conversation (0 = first user message after system prompt).
598
+ Use /status to see current message count, then pick which to snip.`,
599
+ input_schema: {
600
+ type: 'object',
601
+ properties: {
602
+ indices: {
603
+ type: 'array',
604
+ items: { type: 'number' },
605
+ description: 'Array of 0-based message indices to remove from context'
606
+ },
607
+ reason: { type: 'string', description: 'Why you are snipping these (logged for transparency)' }
608
+ },
609
+ required: ['indices']
610
+ },
611
+ async run({ indices, reason }) {
612
+ // Actual snipping happens in agentLoop — this signals intent
613
+ return JSON.stringify({ __snip__: true, indices, reason: reason || 'context management' });
614
+ }
615
+ };
616
+
617
+ // ── BRIEF TOOL ────────────────────────────────────────────────────────────────
618
+ export const BriefTool = {
619
+ name: 'Brief',
620
+ description: `Write a self-briefing note to yourself about the current task state. This gets injected into context on the next turn, ensuring nothing is forgotten even if context is trimmed.
621
+
622
+ Use after major milestones in long tasks. Different from Checkpoint — Brief is for your own working memory, not user-visible progress.
623
+
624
+ Example:
625
+ Brief({ content: "Working on auth refactor. Done: DB schema updated, sessions table migrated. Current: updating login route. Not done: logout, password reset. Key discovery: sessions table uses TEXT not UUID for user_id — do not cast." })`,
626
+ input_schema: {
627
+ type: 'object',
628
+ properties: {
629
+ content: { type: 'string', description: 'Your working memory note — what you know, what is done, what is next, what to watch out for' }
630
+ },
631
+ required: ['content']
632
+ },
633
+ async run({ content }) {
634
+ try {
635
+ if (!_sessionDir) return 'Brief recorded (no session initialized)';
636
+ // Reuse checkpoint mechanism — both go to session context
637
+ updateContext(_sessionDir, `[BRIEF]\n${content}`);
638
+ return 'Brief saved to working memory.';
639
+ } catch (e) {
640
+ return `Brief recorded (write failed: ${e.message})`;
641
+ }
642
+ }
643
+ };
644
+
645
+ export const ALL_TOOLS = [
646
+ BashTool, ReadTool, WriteTool, EditTool, GlobTool, GrepTool,
647
+ NoteTool, CheckpointTool,
648
+ WebFetchTool, WebSearchTool,
649
+ TodoWriteTool, AskUserTool,
650
+ AgentTool, SnipTool, BriefTool
651
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsunami-code",
3
- "version": "2.8.0",
3
+ "version": "3.0.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": {