tsunami-code 2.9.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.9.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';
@@ -172,8 +173,31 @@ async function run() {
172
173
  initProjectMemory(cwd);
173
174
  setSession({ sessionDir, cwd });
174
175
 
176
+ // Inject server context into AgentTool
177
+ injectServerContext(serverUrl, buildSystemPrompt());
178
+
175
179
  printBanner(serverUrl);
176
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
+
177
201
  // Preflight checks
178
202
  process.stdout.write(dim(' Checking server connection...'));
179
203
  const { errors, warnings } = await runPreflight(serverUrl);
@@ -356,10 +380,28 @@ async function run() {
356
380
  console.log(red(` Unknown memory subcommand: ${sub}\n Try: /memory, /memory files, /memory view <file>, /memory clear\n`));
357
381
  }
358
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
+
359
395
  rl.on('line', async (input) => {
360
396
  const line = input.trim();
361
397
  if (!line) { rl.prompt(); return; }
362
398
 
399
+ // Append to persistent history
400
+ if (!line.startsWith('/')) {
401
+ appendHistory(line);
402
+ historyIdx = -1;
403
+ }
404
+
363
405
  if (line.startsWith('/')) {
364
406
  const parts = line.slice(1).split(' ');
365
407
  const cmd = parts[0].toLowerCase();
@@ -384,6 +426,7 @@ async function run() {
384
426
  ['/status', 'Show context size and server'],
385
427
  ['/server <url>', 'Change model server URL'],
386
428
  ['/model [name]', 'Show or change active model (default: local)'],
429
+ ['/history', 'Show recent command history'],
387
430
  ['/exit', 'Exit'],
388
431
  ];
389
432
  for (const [c, desc] of cmds) {
@@ -430,13 +473,22 @@ async function run() {
430
473
  console.log(dim(` CWD : ${cwd}\n`));
431
474
  break;
432
475
  }
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'));
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();
439
490
  break;
491
+ }
440
492
  case 'clear':
441
493
  resetSession();
442
494
  console.log(green(' Session cleared.\n'));
@@ -463,6 +515,14 @@ async function run() {
463
515
  case 'memory':
464
516
  await handleMemoryCommand(rest);
465
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
+ }
466
526
  case 'exit': case 'quit':
467
527
  gracefulExit(0);
468
528
  return;
@@ -479,6 +539,11 @@ async function run() {
479
539
  userContent = `[Previous session summary]\n${lastSessionSummary}\n\n---\n\n${line}`;
480
540
  }
481
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
+
482
547
  const fullMessages = [
483
548
  { role: 'system', content: systemPrompt },
484
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 : '';
@@ -347,7 +362,26 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
347
362
 
348
363
  onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
349
364
  const result = await runTool(tc.name, tc.arguments, sessionInfo, sessionFiles);
350
- 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)}`);
351
385
  }
352
386
 
353
387
  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/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.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": {