tsunami-code 2.9.0 → 3.1.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,9 @@ 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';
9
+ import { loadSkills, getSkillCommand, createSkill, listSkills } from './lib/skills.js';
8
10
  import { buildSystemPrompt } from './lib/prompt.js';
9
11
  import { runPreflight, checkServer } from './lib/preflight.js';
10
12
  import { setSession, undo, undoStackSize } from './lib/tools.js';
@@ -20,7 +22,7 @@ import {
20
22
  getSessionContext
21
23
  } from './lib/memory.js';
22
24
 
23
- const VERSION = '2.9.0';
25
+ const VERSION = '3.1.0';
24
26
  const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
25
27
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
26
28
  const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
@@ -172,8 +174,34 @@ async function run() {
172
174
  initProjectMemory(cwd);
173
175
  setSession({ sessionDir, cwd });
174
176
 
177
+ // Load skills
178
+ let skills = loadSkills(cwd);
179
+
180
+ // Inject server context into AgentTool
181
+ injectServerContext(serverUrl, buildSystemPrompt());
182
+
175
183
  printBanner(serverUrl);
176
184
 
185
+ // Persistent history — load previous commands
186
+ const HISTORY_FILE = join(CONFIG_DIR, 'history.jsonl');
187
+ function appendHistory(line) {
188
+ try {
189
+ const entry = JSON.stringify({ cmd: line, ts: Date.now(), cwd }) + '\n';
190
+ import('fs').then(({ appendFileSync }) => { try { appendFileSync(HISTORY_FILE, entry, 'utf8'); } catch {} });
191
+ } catch {}
192
+ }
193
+ function loadHistory() {
194
+ try {
195
+ if (!existsSync(HISTORY_FILE)) return [];
196
+ return readFileSync(HISTORY_FILE, 'utf8')
197
+ .trim().split('\n').filter(Boolean)
198
+ .map(l => { try { return JSON.parse(l).cmd; } catch { return null; } })
199
+ .filter(Boolean).reverse().slice(0, 200);
200
+ } catch { return []; }
201
+ }
202
+ const historyEntries = loadHistory();
203
+ let historyIdx = -1;
204
+
177
205
  // Preflight checks
178
206
  process.stdout.write(dim(' Checking server connection...'));
179
207
  const { errors, warnings } = await runPreflight(serverUrl);
@@ -356,15 +384,57 @@ async function run() {
356
384
  console.log(red(` Unknown memory subcommand: ${sub}\n Try: /memory, /memory files, /memory view <file>, /memory clear\n`));
357
385
  }
358
386
 
387
+ // Frustration detection — from leaked userPromptKeywords.ts pattern
388
+ const FRUSTRATION_PATTERNS = [
389
+ /\b(wtf|fuck|shit|damn|idiot|stupid|useless|broken|wrong|garbage|trash|terrible|awful|hate)\b/i,
390
+ /\b(not working|still broken|still wrong|same error|again|ugh|argh)\b/i,
391
+ /!{2,}/,
392
+ /\b(why (would|did|is|are|does|do) you|you (keep|always|never|can't|cannot|won't|don't))\b/i,
393
+ /\b(i (said|told|asked)|stop|listen|pay attention)\b/i
394
+ ];
395
+ function detectFrustration(text) {
396
+ return FRUSTRATION_PATTERNS.some(p => p.test(text));
397
+ }
398
+
359
399
  rl.on('line', async (input) => {
360
400
  const line = input.trim();
361
401
  if (!line) { rl.prompt(); return; }
362
402
 
403
+ // Append to persistent history
404
+ if (!line.startsWith('/')) {
405
+ appendHistory(line);
406
+ historyIdx = -1;
407
+ }
408
+
363
409
  if (line.startsWith('/')) {
364
410
  const parts = line.slice(1).split(' ');
365
411
  const cmd = parts[0].toLowerCase();
366
412
  const rest = parts.slice(1);
367
413
 
414
+ // Skills: check if this is a skill command before built-ins
415
+ const skillMatch = getSkillCommand(skills, line);
416
+ if (skillMatch && !['help','compact','plan','undo','doctor','cost','clear','status','server','model','memory','history','exit','quit','skill-create','skill-list','skills'].includes(cmd)) {
417
+ // Run the skill prompt as a user message
418
+ const userContent = skillMatch.args
419
+ ? `[Skill: ${skillMatch.skill.name}]\n${skillMatch.prompt}`
420
+ : skillMatch.prompt;
421
+ messages.push({ role: 'user', content: userContent });
422
+ const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
423
+ rl.pause(); isProcessing = true;
424
+ process.stdout.write('\n' + dim(` ◈ Running skill: ${skillMatch.skill.name}\n\n`));
425
+ let firstToken = true;
426
+ try {
427
+ await agentLoop(currentServerUrl, fullMessages, (token) => {
428
+ if (firstToken) { process.stdout.write(' '); firstToken = false; }
429
+ process.stdout.write(token);
430
+ }, (name, args) => { printToolCall(name, args); firstToken = true; },
431
+ { sessionDir, cwd, planMode }, makeConfirmCallback(rl));
432
+ process.stdout.write('\n\n');
433
+ } catch(e) { console.error(red(` Error: ${e.message}\n`)); }
434
+ isProcessing = false; rl.resume(); rl.prompt();
435
+ return;
436
+ }
437
+
368
438
  switch (cmd) {
369
439
  case 'help':
370
440
  console.log(blue('\n Tsunami Code CLI — Commands\n'));
@@ -384,6 +454,7 @@ async function run() {
384
454
  ['/status', 'Show context size and server'],
385
455
  ['/server <url>', 'Change model server URL'],
386
456
  ['/model [name]', 'Show or change active model (default: local)'],
457
+ ['/history', 'Show recent command history'],
387
458
  ['/exit', 'Exit'],
388
459
  ];
389
460
  for (const [c, desc] of cmds) {
@@ -430,13 +501,22 @@ async function run() {
430
501
  console.log(dim(` CWD : ${cwd}\n`));
431
502
  break;
432
503
  }
433
- case 'cost':
434
- console.log(blue('\n Session Token Estimate'));
435
- console.log(dim(` Input : ~${_inputTokens.toLocaleString()}`));
436
- console.log(dim(` Output : ~${_outputTokens.toLocaleString()}`));
437
- console.log(dim(` Total : ~${(_inputTokens + _outputTokens).toLocaleString()}`));
438
- console.log(dim(' (Estimates only)\n'));
504
+ case 'cost': {
505
+ const hasReal = tokenStats.requests > 0;
506
+ console.log(blue('\n Session Token Usage'));
507
+ if (hasReal) {
508
+ console.log(dim(` Input : ${tokenStats.input.toLocaleString()} tokens (actual)`));
509
+ console.log(dim(` Output : ${tokenStats.output.toLocaleString()} tokens (actual)`));
510
+ console.log(dim(` Total : ${(tokenStats.input + tokenStats.output).toLocaleString()} tokens`));
511
+ console.log(dim(` Requests: ${tokenStats.requests}`));
512
+ } else {
513
+ console.log(dim(` Input : ~${_inputTokens.toLocaleString()} (estimated)`));
514
+ console.log(dim(` Output : ~${_outputTokens.toLocaleString()} (estimated)`));
515
+ console.log(dim(` Total : ~${(_inputTokens + _outputTokens).toLocaleString()} (estimated)`));
516
+ }
517
+ console.log();
439
518
  break;
519
+ }
440
520
  case 'clear':
441
521
  resetSession();
442
522
  console.log(green(' Session cleared.\n'));
@@ -463,6 +543,27 @@ async function run() {
463
543
  case 'memory':
464
544
  await handleMemoryCommand(rest);
465
545
  break;
546
+ case 'skills':
547
+ case 'skill-list':
548
+ console.log(dim('\n' + listSkills(skills) + '\n'));
549
+ break;
550
+ case 'skill-create': {
551
+ const [skillName, ...skillBody] = rest;
552
+ if (!skillName) { console.log(red(' Usage: /skill-create <name> <prompt...>\n')); break; }
553
+ const body = skillBody.join(' ') || 'Do the following: {{ARGS}}';
554
+ const { slug } = createSkill(cwd, skillName, body, 'project');
555
+ skills = loadSkills(cwd);
556
+ console.log(green(` Skill created: /${slug}\n`));
557
+ break;
558
+ }
559
+ case 'history': {
560
+ const recent = historyEntries.slice(0, 20);
561
+ if (recent.length === 0) { console.log(dim(' No history yet.\n')); break; }
562
+ console.log(blue(`\n Recent commands (${recent.length}):`));
563
+ recent.forEach((h, i) => console.log(dim(` ${String(i + 1).padStart(2)} ${h.slice(0, 100)}`)));
564
+ console.log();
565
+ break;
566
+ }
466
567
  case 'exit': case 'quit':
467
568
  gracefulExit(0);
468
569
  return;
@@ -479,6 +580,11 @@ async function run() {
479
580
  userContent = `[Previous session summary]\n${lastSessionSummary}\n\n---\n\n${line}`;
480
581
  }
481
582
 
583
+ // Frustration injection — tell the model to acknowledge and course-correct
584
+ if (detectFrustration(line)) {
585
+ userContent += '\n\n[system: User appears frustrated. Acknowledge the issue directly, do not repeat the same approach. Be concise and action-focused.]';
586
+ }
587
+
482
588
  const fullMessages = [
483
589
  { role: 'system', content: systemPrompt },
484
590
  ...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,16 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
234
237
  fullContent += token;
235
238
  onToken(token);
236
239
  }
240
+ // Capture real token counts + finish reason
241
+ if (parsed.usage) {
242
+ tokenStats.input += parsed.usage.prompt_tokens || 0;
243
+ tokenStats.output += parsed.usage.completion_tokens || 0;
244
+ tokenStats.requests++;
245
+ tokenStats.lastPromptTokens = parsed.usage.prompt_tokens || 0;
246
+ }
247
+ if (parsed.choices?.[0]?.finish_reason) {
248
+ tokenStats.lastFinishReason = parsed.choices[0].finish_reason;
249
+ }
237
250
  }
238
251
  }
239
252
 
@@ -278,6 +291,12 @@ export async function quickCompletion(serverUrl, systemPrompt, userMessage) {
278
291
  }
279
292
  }
280
293
 
294
+ // Self-register into tools.js so AgentTool can call back into us
295
+ // (done here at module load time to avoid circular import at parse time)
296
+ import('./tools.js').then(m => {
297
+ m.injectAgentLoop(agentLoop);
298
+ }).catch(() => {});
299
+
281
300
  export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessionInfo = null, confirmCallback = null, maxIterations = 15) {
282
301
  const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
283
302
  const currentTask = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '';
@@ -300,12 +319,39 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
300
319
 
301
320
  let memoryContext = buildMemoryContext();
302
321
 
322
+ // Max tokens recovery — mirrors Claude Code's MAX_OUTPUT_TOKENS_RECOVERY_LIMIT=3
323
+ let maxTokensRecoveryCount = 0;
324
+ const MAX_TOKENS_RECOVERY = 3;
325
+
326
+ // Token-aware auto-compact — compact at 80% of context window (4096 default)
327
+ const CONTEXT_WINDOW = 4096;
328
+ const COMPACT_THRESHOLD = Math.floor(CONTEXT_WINDOW * 0.80);
329
+
303
330
  for (let i = 0; i < maxIterations; i++) {
331
+ // Pre-flight: if last prompt consumed >80% of context, auto-compact before next call
332
+ if (tokenStats.lastPromptTokens > COMPACT_THRESHOLD && messages.length > 4) {
333
+ onToken('\n[auto-compact: context at ' + Math.round(tokenStats.lastPromptTokens / CONTEXT_WINDOW * 100) + '%]\n');
334
+ // Keep system + last 4 messages only
335
+ const sys = messages[0];
336
+ messages.length = 0;
337
+ messages.push(sys, ...messages.slice(-4));
338
+ tokenStats.lastPromptTokens = 0;
339
+ }
340
+
304
341
  const content = await streamCompletion(serverUrl, messages, onToken, memoryContext);
305
342
  const toolCalls = parseToolCalls(content);
306
343
 
307
344
  messages.push({ role: 'assistant', content });
308
345
 
346
+ // Max tokens recovery: if model hit length limit and produced no tool calls, continue
347
+ if (toolCalls.length === 0 && tokenStats.lastFinishReason === 'length' && maxTokensRecoveryCount < MAX_TOKENS_RECOVERY) {
348
+ maxTokensRecoveryCount++;
349
+ tokenStats.lastFinishReason = null;
350
+ messages.push({ role: 'user', content: 'Continue from exactly where you left off. Do not repeat anything.' });
351
+ continue;
352
+ }
353
+ maxTokensRecoveryCount = 0;
354
+
309
355
  if (toolCalls.length === 0) break;
310
356
 
311
357
  const results = [];
@@ -347,7 +393,26 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
347
393
 
348
394
  onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
349
395
  const result = await runTool(tc.name, tc.arguments, sessionInfo, sessionFiles);
350
- results.push(`[${tc.name} result]\n${String(result).slice(0, 8000)}`);
396
+ const resultStr = String(result);
397
+
398
+ // Handle Snip signal — surgically remove message indices from context
399
+ if (tc.name === 'Snip') {
400
+ try {
401
+ const sig = JSON.parse(resultStr);
402
+ if (sig.__snip__ && Array.isArray(sig.indices)) {
403
+ const toRemove = new Set(sig.indices.map(Number));
404
+ // messages[0] is system prompt, indices are 1-based user/assistant turns
405
+ const kept = messages.filter((_, i) => i === 0 || !toRemove.has(i - 1));
406
+ const removed = messages.length - kept.length;
407
+ messages.length = 0;
408
+ kept.forEach(m => messages.push(m));
409
+ results.push(`[Snip result]\nRemoved ${removed} messages from context. Reason: ${sig.reason}`);
410
+ continue;
411
+ }
412
+ } catch {}
413
+ }
414
+
415
+ results.push(`[${tc.name} result]\n${resultStr.slice(0, 8000)}`);
351
416
  }
352
417
 
353
418
  messages.push({
package/lib/prompt.js CHANGED
@@ -67,8 +67,12 @@ Available tools: Bash, Read, Write, Edit, Glob, Grep, Note, Checkpoint. Use them
67
67
  - **Note**: Save a permanent discovery to project memory (.tsunami/). Use liberally for traps, patterns, architectural decisions.
68
68
  - **Checkpoint**: Save current task progress to session memory so work is resumable if the session ends.
69
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.
70
71
  - **TodoWrite**: Manage a persistent task list (add/complete/delete/list). Use for any multi-step task.
71
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.
72
76
  </tools>
73
77
 
74
78
  <reasoning_protocol>
package/lib/skills.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Skills system — user-defined slash commands stored as markdown files.
3
+ * Mirrors Claude Code's SkillTool / commands system.
4
+ *
5
+ * Skills live in:
6
+ * ~/.tsunami-code/skills/*.md (global)
7
+ * <project>/.tsunami/skills/*.md (project-local, higher priority)
8
+ *
9
+ * File format:
10
+ * # Skill Name
11
+ * Optional description line
12
+ *
13
+ * Prompt content here. Can reference {{ARGS}} for user-supplied arguments.
14
+ */
15
+
16
+ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
17
+ import { join, basename } from 'path';
18
+ import os from 'os';
19
+
20
+ const GLOBAL_SKILLS_DIR = join(os.homedir(), '.tsunami-code', 'skills');
21
+
22
+ function ensureSkillsDir(dir) {
23
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
24
+ }
25
+
26
+ function slugify(name) {
27
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
28
+ }
29
+
30
+ function parseSkillFile(filePath, content) {
31
+ const lines = content.split('\n');
32
+ const titleLine = lines.find(l => l.startsWith('# '));
33
+ const name = titleLine ? titleLine.slice(2).trim() : basename(filePath, '.md');
34
+ const slug = slugify(name);
35
+
36
+ // Description: first non-empty line after the title that starts with lowercase
37
+ const titleIdx = lines.indexOf(titleLine);
38
+ const descLine = lines.slice(titleIdx + 1).find(l => l.trim() && !l.startsWith('#'));
39
+ const description = descLine?.trim() || `Run ${name} skill`;
40
+
41
+ // Prompt: everything after the first blank line after title
42
+ const bodyStart = lines.findIndex((l, i) => i > titleIdx && l.trim() === '') + 1;
43
+ const prompt = bodyStart > 0 ? lines.slice(bodyStart).join('\n').trim() : content;
44
+
45
+ return { name, slug, description, prompt, filePath };
46
+ }
47
+
48
+ export function loadSkills(cwd) {
49
+ const skills = new Map(); // slug → skill
50
+
51
+ // Load global skills first
52
+ ensureSkillsDir(GLOBAL_SKILLS_DIR);
53
+ for (const dir of [GLOBAL_SKILLS_DIR, join(cwd, '.tsunami', 'skills')]) {
54
+ if (!existsSync(dir)) continue;
55
+ try {
56
+ for (const file of readdirSync(dir).filter(f => f.endsWith('.md'))) {
57
+ const filePath = join(dir, file);
58
+ const content = readFileSync(filePath, 'utf8');
59
+ const skill = parseSkillFile(filePath, content);
60
+ skills.set(skill.slug, skill); // project skills override global
61
+ }
62
+ } catch {}
63
+ }
64
+
65
+ return skills;
66
+ }
67
+
68
+ export function getSkillCommand(skills, input) {
69
+ // Match /skill-slug [args...]
70
+ const parts = input.slice(1).split(' ');
71
+ const slug = parts[0].toLowerCase();
72
+ const args = parts.slice(1).join(' ');
73
+
74
+ const skill = skills.get(slug);
75
+ if (!skill) return null;
76
+
77
+ const prompt = args
78
+ ? skill.prompt.replace(/\{\{ARGS\}\}/g, args).replace(/\{\{args\}\}/g, args)
79
+ : skill.prompt;
80
+
81
+ return { skill, prompt, args };
82
+ }
83
+
84
+ export function createSkill(cwd, name, content, scope = 'project') {
85
+ const dir = scope === 'global'
86
+ ? GLOBAL_SKILLS_DIR
87
+ : join(cwd, '.tsunami', 'skills');
88
+ ensureSkillsDir(dir);
89
+ const slug = slugify(name);
90
+ const filePath = join(dir, `${slug}.md`);
91
+ const fileContent = `# ${name}\n\n${content}\n`;
92
+ writeFileSync(filePath, fileContent, 'utf8');
93
+ return { slug, filePath };
94
+ }
95
+
96
+ export function listSkills(skills) {
97
+ if (skills.size === 0) return 'No skills defined. Create one with /skill-create <name>.';
98
+ const lines = ['Available skills:'];
99
+ for (const s of skills.values()) {
100
+ lines.push(` /${s.slug.padEnd(20)} ${s.description}`);
101
+ }
102
+ return lines.join('\n');
103
+ }
package/lib/tools.js CHANGED
@@ -453,4 +453,199 @@ Do NOT use for:
453
453
  }
454
454
  };
455
455
 
456
- export const ALL_TOOLS = [BashTool, ReadTool, WriteTool, EditTool, GlobTool, GrepTool, NoteTool, CheckpointTool, WebFetchTool, TodoWriteTool, AskUserTool];
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.9.0",
3
+ "version": "3.1.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": {