tsunami-code 3.4.0 → 3.6.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
@@ -1,17 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import readline from 'readline';
3
3
  import chalk from 'chalk';
4
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
5
5
  import { join } from 'path';
6
6
  import os from 'os';
7
- import { agentLoop, quickCompletion, setModel, getModel, tokenStats } from './lib/loop.js';
7
+ import { agentLoop, quickCompletion, setModel, getModel, tokenStats, setTemperature, getTemperature } from './lib/loop.js';
8
8
  import { injectServerContext } from './lib/tools.js';
9
9
  import { loadSkills, getSkillCommand, createSkill, listSkills } from './lib/skills.js';
10
10
  import { isCoordinatorTask, stripCoordinatorPrefix, buildCoordinatorSystemPrompt } from './lib/coordinator.js';
11
11
  import { getDueTasks, markDone, cancelTask, listTasks, formatTaskList } from './lib/kairos.js';
12
12
  import { buildSystemPrompt } from './lib/prompt.js';
13
13
  import { runPreflight, checkServer } from './lib/preflight.js';
14
- import { setSession, undo, undoStackSize, registerMcpTools, linesChanged } from './lib/tools.js';
14
+ import { setSession, undo, undoStackSize, beginUndoTurn, registerMcpTools, linesChanged } from './lib/tools.js';
15
15
  import { connectMcpServers, getMcpToolObjects, getMcpStatus, getMcpConfigPath, disconnectAll as disconnectMcp } from './lib/mcp.js';
16
16
  import {
17
17
  initSession,
@@ -24,8 +24,9 @@ import {
24
24
  getRecentDecisions,
25
25
  getSessionContext
26
26
  } from './lib/memory.js';
27
+ import { listMemories, readMemory, saveMemory, deleteMemory, getMemdirPath } from './lib/memdir.js';
27
28
 
28
- const VERSION = '3.4.0';
29
+ const VERSION = '3.6.0';
29
30
  const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
30
31
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
31
32
  const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
@@ -82,6 +83,72 @@ function formatBytes(bytes) {
82
83
  return `${(bytes / 1024 / 1024).toFixed(2)}MB`;
83
84
  }
84
85
 
86
+ // ── Syntax highlighter — line-buffered, applied to streaming model output ──────
87
+ // Buffers tokens until newlines, then applies chalk markup per line.
88
+ // createHighlighter(write) returns an onToken(token) function.
89
+ function createHighlighter(write) {
90
+ let buf = '';
91
+ let inFence = false;
92
+ let fenceMark = '```';
93
+
94
+ function renderLine(line) {
95
+ if (inFence) {
96
+ // Exit fence?
97
+ if (line.trimStart().startsWith(fenceMark[0].repeat(3))) {
98
+ inFence = false;
99
+ write(chalk.dim(line) + '\n');
100
+ return;
101
+ }
102
+ write(chalk.green(line) + '\n');
103
+ return;
104
+ }
105
+
106
+ // Enter fence?
107
+ const fenceM = line.trimStart().match(/^(`{3,}|~{3,})/);
108
+ if (fenceM) {
109
+ inFence = true;
110
+ fenceMark = fenceM[1][0].repeat(3);
111
+ write(chalk.dim(line) + '\n');
112
+ return;
113
+ }
114
+
115
+ // Heading
116
+ if (/^#{1,6} /.test(line)) {
117
+ write(chalk.bold.cyan(line) + '\n');
118
+ return;
119
+ }
120
+
121
+ // Horizontal rule
122
+ if (/^[-*_]{3,}$/.test(line.trim())) {
123
+ write(chalk.dim(line) + '\n');
124
+ return;
125
+ }
126
+
127
+ // Inline markup (bold + inline code only — keep it fast)
128
+ const styled = line
129
+ .replace(/\*\*([^*\n]+)\*\*/g, (_, t) => chalk.bold(t))
130
+ .replace(/__([^_\n]+)__/g, (_, t) => chalk.bold(t))
131
+ .replace(/`([^`\n]+)`/g, (_, t) => chalk.yellow('`' + t + '`'));
132
+
133
+ write(styled + '\n');
134
+ }
135
+
136
+ return function onToken(token) {
137
+ buf += token;
138
+ let nl;
139
+ while ((nl = buf.indexOf('\n')) !== -1) {
140
+ renderLine(buf.slice(0, nl));
141
+ buf = buf.slice(nl + 1);
142
+ }
143
+ };
144
+ }
145
+
146
+ // Flush remaining buffer (call at end of response)
147
+ function flushHighlighter(highlighter) {
148
+ // inject a trailing newline so any buffered content gets rendered
149
+ highlighter('\n');
150
+ }
151
+
85
152
  // ── CLI Parsing ───────────────────────────────────────────────────────────────
86
153
  const argv = process.argv.slice(2);
87
154
 
@@ -172,6 +239,60 @@ async function run() {
172
239
  const cwd = process.cwd();
173
240
  let planMode = argv.includes('--plan');
174
241
 
242
+ // ── --print mode: non-interactive single-shot, pipe-friendly ────────────────
243
+ // Usage: tsunami --print "fix the bug in server.js"
244
+ // tsunami --server http://... --print "your prompt"
245
+ const printIdx = argv.indexOf('--print');
246
+ if (printIdx !== -1) {
247
+ const printPrompt = argv.slice(printIdx + 1).join(' ').trim();
248
+ if (!printPrompt) {
249
+ process.stderr.write('Usage: tsunami --print "your prompt"\n');
250
+ process.exit(1);
251
+ }
252
+
253
+ const { errors } = await runPreflight(serverUrl);
254
+ if (errors.length) {
255
+ for (const e of errors) process.stderr.write(`Error: ${e}\n`);
256
+ process.exit(1);
257
+ }
258
+
259
+ const { sessionId, sessionDir } = initSession(cwd);
260
+ initProjectMemory(cwd);
261
+ setSession({ sessionDir, cwd });
262
+
263
+ const sp = buildSystemPrompt();
264
+ injectServerContext(serverUrl, sp);
265
+
266
+ const msgs = [
267
+ { role: 'system', content: sp },
268
+ { role: 'user', content: printPrompt }
269
+ ];
270
+
271
+ const highlight = createHighlighter((s) => process.stdout.write(s));
272
+ try {
273
+ await agentLoop(
274
+ serverUrl, msgs,
275
+ (token) => highlight(token),
276
+ (name, args) => {
277
+ let parsed = {};
278
+ try { parsed = JSON.parse(args); } catch {}
279
+ const preview = Object.entries(parsed)
280
+ .map(([k, v]) => `${k}=${JSON.stringify(String(v).slice(0, 40))}`).join(', ');
281
+ process.stderr.write(`\n[${name}(${preview})]\n`);
282
+ },
283
+ { sessionDir, cwd, planMode: false },
284
+ null, 15
285
+ );
286
+ } catch (e) {
287
+ process.stderr.write(`Error: ${e.message}\n`);
288
+ process.exit(1);
289
+ }
290
+ flushHighlighter(highlight);
291
+ process.stdout.write('\n');
292
+ process.exit(0);
293
+ return;
294
+ }
295
+
175
296
  const sessionStartTime = Date.now();
176
297
 
177
298
  // Initialize memory systems
@@ -323,11 +444,31 @@ async function run() {
323
444
  return [system, ...rest.slice(-maxEntries)];
324
445
  }
325
446
 
447
+ // Slash command list for tab completion — keep in sync with switch cases + skills
448
+ const SLASH_COMMANDS = [
449
+ '/help', '/compact', '/plan', '/undo', '/doctor', '/cost', '/memory', '/clear',
450
+ '/status', '/server', '/model', '/mcp', '/effort', '/copy', '/btw', '/rewind',
451
+ '/diff', '/stats', '/export', '/history', '/exit', '/quit', '/kairos', '/skills',
452
+ '/skill-create', '/skill-list', '/init', '/memdir',
453
+ ];
454
+
455
+ function tabCompleter(line) {
456
+ if (line.startsWith('/')) {
457
+ // Supplement with loaded skill names
458
+ const skillCmds = skills.map(s => `/${s.slug}`);
459
+ const all = [...new Set([...SLASH_COMMANDS, ...skillCmds])].sort();
460
+ const hits = all.filter(c => c.startsWith(line));
461
+ return [hits.length ? hits : all, line];
462
+ }
463
+ return [[], line];
464
+ }
465
+
326
466
  const rl = readline.createInterface({
327
467
  input: process.stdin,
328
468
  output: process.stdout,
329
469
  prompt: planMode ? yellow('❯ [plan] ') : cyan('❯ '),
330
- terminal: process.stdin.isTTY
470
+ terminal: process.stdin.isTTY,
471
+ completer: tabCompleter,
331
472
  });
332
473
 
333
474
  rl.prompt();
@@ -441,7 +582,58 @@ async function run() {
441
582
  return FRUSTRATION_PATTERNS.some(p => p.test(text));
442
583
  }
443
584
 
585
+ // ── Multiline input state ───────────────────────────────────────────────────
586
+ // Mode 1 — backslash continuation: line ending with \
587
+ // Mode 2 — heredoc block: """ on a line by itself opens/closes
588
+ let mlBuffer = [];
589
+ let mlHeredoc = false;
590
+
444
591
  rl.on('line', async (input) => {
592
+ // ── Multiline: heredoc mode (""" toggle) ──────────────────────────────────
593
+ if (input.trimStart() === '"""' || input.trimStart() === "'''") {
594
+ if (!mlHeredoc) {
595
+ mlHeredoc = true;
596
+ rl.setPrompt(dim('··· '));
597
+ rl.prompt();
598
+ return;
599
+ } else {
600
+ // Close heredoc — submit accumulated buffer
601
+ mlHeredoc = false;
602
+ const assembled = mlBuffer.join('\n');
603
+ mlBuffer = [];
604
+ rl.setPrompt(planMode ? yellow('❯ [plan] ') : cyan('❯ '));
605
+ // Fall through with assembled as the input
606
+ return _handleInput(assembled);
607
+ }
608
+ }
609
+
610
+ if (mlHeredoc) {
611
+ mlBuffer.push(input);
612
+ rl.prompt();
613
+ return;
614
+ }
615
+
616
+ // ── Multiline: backslash continuation ─────────────────────────────────────
617
+ if (input.endsWith('\\')) {
618
+ mlBuffer.push(input.slice(0, -1));
619
+ rl.setPrompt(dim('··· '));
620
+ rl.prompt();
621
+ return;
622
+ }
623
+
624
+ if (mlBuffer.length > 0) {
625
+ // Final line of a backslash continuation
626
+ mlBuffer.push(input);
627
+ const assembled = mlBuffer.join('\n');
628
+ mlBuffer = [];
629
+ rl.setPrompt(planMode ? yellow('❯ [plan] ') : cyan('❯ '));
630
+ return _handleInput(assembled);
631
+ }
632
+
633
+ return _handleInput(input);
634
+ });
635
+
636
+ async function _handleInput(input) {
445
637
  const line = input.trim();
446
638
  if (!line) { rl.prompt(); return; }
447
639
 
@@ -458,7 +650,7 @@ async function run() {
458
650
 
459
651
  // Skills: check if this is a skill command before built-ins
460
652
  const skillMatch = getSkillCommand(skills, line);
461
- if (skillMatch && !['help','compact','plan','undo','doctor','cost','clear','status','server','model','memory','history','exit','quit','skill-create','skill-list','skills','mcp','copy','btw','rewind','diff','stats','export'].includes(cmd)) {
653
+ if (skillMatch && !['help','compact','plan','undo','doctor','cost','clear','status','server','model','memory','history','exit','quit','skill-create','skill-list','skills','mcp','copy','btw','rewind','diff','stats','export','effort'].includes(cmd)) {
462
654
  // Run the skill prompt as a user message
463
655
  const userContent = skillMatch.args
464
656
  ? `[Skill: ${skillMatch.skill.name}]\n${skillMatch.prompt}`
@@ -500,14 +692,19 @@ async function run() {
500
692
  ['/server <url>', 'Change model server URL'],
501
693
  ['/model [name]', 'Show or change active model (default: local)'],
502
694
  ['/mcp', 'Show MCP server status and tools'],
695
+ ['/effort <level>', 'Set reasoning effort: low/medium/high/max'],
503
696
  ['/copy', 'Copy last response to clipboard'],
504
697
  ['/btw <note>', 'Inject a note into conversation without a response'],
505
698
  ['/rewind', 'Remove last user+assistant exchange'],
506
699
  ['/diff', 'Show git diff of session file changes'],
507
700
  ['/stats', 'Session stats: lines, tokens, duration'],
508
701
  ['/export [file]', 'Export conversation to markdown file'],
509
- ['/history', 'Show recent command history'],
510
- ['/exit', 'Exit'],
702
+ ['/init [--force]', 'Generate TSUNAMI.md from codebase analysis'],
703
+ ['/memdir', 'List persistent cross-session memories'],
704
+ ['/memdir add <n>', 'Add a memory entry'],
705
+ ['/memdir delete', 'Delete a memory entry'],
706
+ ['/history', 'Show recent command history'],
707
+ ['/exit', 'Exit'],
511
708
  ];
512
709
  for (const [c, desc] of cmds) {
513
710
  console.log(` ${cyan(c.padEnd(22))} ${dim(desc)}`);
@@ -526,8 +723,24 @@ async function run() {
526
723
  break;
527
724
  case 'undo': {
528
725
  const restored = undo();
529
- if (restored) console.log(green(` ✓ Restored: ${restored}\n`));
530
- else console.log(dim(' Nothing to undo.\n'));
726
+ if (restored) {
727
+ if (restored.length === 1) console.log(green(` Restored: ${restored[0]}\n`));
728
+ else console.log(green(` ✓ Restored ${restored.length} files:\n`) + restored.map(f => dim(` ${f}`)).join('\n') + '\n');
729
+ } else {
730
+ console.log(dim(' Nothing to undo.\n'));
731
+ }
732
+ break;
733
+ }
734
+ case 'effort': {
735
+ const level = rest[0]?.toLowerCase();
736
+ const levels = { low: 0.5, medium: 0.2, high: 0.05, max: 0.0 };
737
+ if (!level || !levels[level] && levels[level] !== 0) {
738
+ const cur = getTemperature();
739
+ console.log(dim(` Current temperature: ${cur}\n Usage: /effort <low|medium|high|max>\n`));
740
+ break;
741
+ }
742
+ setTemperature(levels[level]);
743
+ console.log(green(` Effort: ${level} (temperature ${levels[level]})\n`));
531
744
  break;
532
745
  }
533
746
  case 'mcp': {
@@ -790,6 +1003,135 @@ async function run() {
790
1003
  case 'exit': case 'quit':
791
1004
  gracefulExit(0);
792
1005
  return;
1006
+
1007
+ case 'init': {
1008
+ // Analyze codebase and generate TSUNAMI.md project instructions
1009
+ const tsunamiPath = join(cwd, 'TSUNAMI.md');
1010
+ if (existsSync(tsunamiPath) && !rest.includes('--force')) {
1011
+ console.log(yellow(` TSUNAMI.md already exists. Use /init --force to regenerate.\n`));
1012
+ break;
1013
+ }
1014
+ process.stdout.write(dim(' ↯ Analyzing codebase...\n'));
1015
+
1016
+ // Gather context: file tree, package.json, git log
1017
+ let initCtx = `Working directory: ${cwd}\n\n`;
1018
+ try {
1019
+ const { execSync: _e } = await import('child_process');
1020
+ // File tree (top-level + src/)
1021
+ try {
1022
+ const tree = _e('git ls-files --others --cached --exclude-standard | head -60', { cwd, encoding: 'utf8', stdio: ['pipe','pipe','ignore'] });
1023
+ initCtx += `Files (git-tracked):\n${tree}\n`;
1024
+ } catch {
1025
+ try {
1026
+ const { readdirSync: _r } = await import('fs');
1027
+ const top = _r(cwd).slice(0, 40).join('\n');
1028
+ initCtx += `Files (top-level):\n${top}\n`;
1029
+ } catch {}
1030
+ }
1031
+ // package.json
1032
+ const pkgPath = join(cwd, 'package.json');
1033
+ if (existsSync(pkgPath)) {
1034
+ initCtx += `\npackage.json:\n${readFileSync(pkgPath, 'utf8').slice(0, 1000)}\n`;
1035
+ }
1036
+ // Git log
1037
+ try {
1038
+ const log = _e('git log --oneline -10', { cwd, encoding: 'utf8', stdio: ['pipe','pipe','ignore'] });
1039
+ initCtx += `\nRecent git history:\n${log}\n`;
1040
+ } catch {}
1041
+ // README
1042
+ for (const rname of ['README.md', 'README']) {
1043
+ const rp = join(cwd, rname);
1044
+ if (existsSync(rp)) {
1045
+ initCtx += `\nREADME:\n${readFileSync(rp, 'utf8').slice(0, 800)}\n`;
1046
+ break;
1047
+ }
1048
+ }
1049
+ } catch {}
1050
+
1051
+ const initSystemPrompt = 'You are an expert developer analyzing a codebase to write project instructions for an AI coding agent CLI. Be concise, direct, and specific.';
1052
+ const initUserPrompt = `Analyze this codebase context and generate a TSUNAMI.md project instruction file for an AI coding agent.
1053
+
1054
+ The file should contain:
1055
+ 1. A short description of the project (2-3 sentences)
1056
+ 2. Tech stack (languages, frameworks, key libraries)
1057
+ 3. Project structure (key directories and their purpose)
1058
+ 4. Critical patterns and conventions (naming, file organization, auth patterns, etc.)
1059
+ 5. Common pitfalls to avoid (traps, gotchas, things to check first)
1060
+ 6. How to build/run/test the project
1061
+ 7. Key files to read before making changes
1062
+
1063
+ Keep each section tight. Use bullet points. This will be injected into an AI agent's context on every turn.
1064
+
1065
+ Codebase context:
1066
+ ${initCtx}
1067
+
1068
+ Output ONLY the TSUNAMI.md content, starting with "# Project: <name>"`;
1069
+
1070
+ const initContent = await quickCompletion(currentServerUrl, initSystemPrompt, initUserPrompt);
1071
+ if (!initContent || initContent.length < 50) {
1072
+ console.log(red(' Failed to generate TSUNAMI.md (model returned empty response).\n'));
1073
+ break;
1074
+ }
1075
+ writeFileSync(tsunamiPath, initContent, 'utf8');
1076
+ systemPrompt = buildSystemPrompt(); // reload with new TSUNAMI.md
1077
+ console.log(green(` ✓ Created TSUNAMI.md (${initContent.length} chars)\n`));
1078
+ console.log(dim(' Tip: review and edit it to add project-specific traps.\n'));
1079
+ break;
1080
+ }
1081
+
1082
+ case 'memdir': {
1083
+ // Persistent cross-session auto-memory management
1084
+ const sub = rest[0]?.toLowerCase();
1085
+
1086
+ if (!sub || sub === 'list') {
1087
+ const mems = listMemories();
1088
+ if (mems.length === 0) {
1089
+ console.log(dim(`\n No auto-memories yet.\n Path: ${getMemdirPath()}\n`));
1090
+ } else {
1091
+ console.log(blue(`\n Auto-memory (${mems.length} entries) — ${getMemdirPath()}\n`));
1092
+ for (const m of mems) {
1093
+ console.log(` ${cyan(m.name.padEnd(20))} ${dim(m.preview)}`);
1094
+ }
1095
+ console.log();
1096
+ }
1097
+ break;
1098
+ }
1099
+
1100
+ if (sub === 'view' && rest[1]) {
1101
+ const name = rest.slice(1).join('-');
1102
+ const content = readMemory(name);
1103
+ if (!content) {
1104
+ console.log(red(` No memory named "${name}"\n`));
1105
+ } else {
1106
+ console.log(blue(`\n Memory: ${name}\n`));
1107
+ console.log(dim(' ' + content.replace(/\n/g, '\n ')));
1108
+ console.log();
1109
+ }
1110
+ break;
1111
+ }
1112
+
1113
+ if (sub === 'add' && rest[1]) {
1114
+ const name = rest[1];
1115
+ const content = rest.slice(2).join(' ');
1116
+ if (!content) { console.log(red(' Usage: /memdir add <name> <content>\n')); break; }
1117
+ const path = saveMemory(name, content);
1118
+ if (path) console.log(green(` ✓ Saved: ${path}\n`));
1119
+ else console.log(red(' Failed to save memory.\n'));
1120
+ break;
1121
+ }
1122
+
1123
+ if (sub === 'delete' && rest[1]) {
1124
+ const name = rest.slice(1).join('-');
1125
+ const ok = deleteMemory(name);
1126
+ if (ok) console.log(green(` ✓ Deleted: ${name}\n`));
1127
+ else console.log(red(` No memory named "${name}"\n`));
1128
+ break;
1129
+ }
1130
+
1131
+ console.log(dim(' Usage: /memdir [list|view <name>|add <name> <content>|delete <name>]\n'));
1132
+ break;
1133
+ }
1134
+
793
1135
  default:
794
1136
  console.log(red(` Unknown command: /${cmd}\n`));
795
1137
  }
@@ -829,22 +1171,28 @@ async function run() {
829
1171
  isProcessing = true;
830
1172
  process.stdout.write('\n');
831
1173
 
1174
+ // Open a new undo turn — all Write/Edit calls during this turn grouped together
1175
+ beginUndoTurn();
1176
+
832
1177
  let firstToken = true;
1178
+ const highlight = createHighlighter((s) => process.stdout.write(s));
833
1179
  try {
834
1180
  await agentLoop(
835
1181
  currentServerUrl,
836
1182
  fullMessages,
837
1183
  (token) => {
838
1184
  if (firstToken) { process.stdout.write(' '); firstToken = false; }
839
- process.stdout.write(token);
1185
+ highlight(token);
840
1186
  },
841
1187
  (toolName, toolArgs) => {
1188
+ flushHighlighter(highlight);
842
1189
  printToolCall(toolName, toolArgs);
843
1190
  firstToken = true;
844
1191
  },
845
1192
  { sessionDir, cwd, planMode },
846
1193
  makeConfirmCallback(rl)
847
1194
  );
1195
+ flushHighlighter(highlight);
848
1196
 
849
1197
  // Token estimation
850
1198
  const inputChars = fullMessages.reduce((s, m) => s + (typeof m.content === 'string' ? m.content.length : 0), 0);
@@ -863,6 +1211,11 @@ async function run() {
863
1211
  console.log(dim(' ↯ Auto-compacted\n'));
864
1212
  }
865
1213
 
1214
+ // getDynamicSkills — reload if model created/modified skill files this turn
1215
+ // From CC's getDynamicSkills pattern in commands.ts
1216
+ const newSkills = loadSkills(cwd);
1217
+ if (newSkills.length !== skills.length) skills = newSkills;
1218
+
866
1219
  process.stdout.write('\n\n');
867
1220
  } catch (e) {
868
1221
  process.stdout.write('\n');
@@ -876,15 +1229,24 @@ async function run() {
876
1229
  }
877
1230
  rl.resume();
878
1231
  rl.prompt();
879
- });
1232
+ }
880
1233
 
881
1234
  process.on('SIGINT', () => {
882
1235
  if (!isProcessing) {
883
1236
  gracefulExit(0);
884
1237
  } else {
885
- // If processing, mark pending and let the loop finish
1238
+ // Remove interrupted command from history (from history.ts removeLastFromHistory)
1239
+ try {
1240
+ if (existsSync(HISTORY_FILE)) {
1241
+ const lines = readFileSync(HISTORY_FILE, 'utf8').trimEnd().split('\n').filter(Boolean);
1242
+ if (lines.length > 0) lines.pop();
1243
+ import('fs').then(({ writeFileSync: wfs }) => {
1244
+ try { wfs(HISTORY_FILE, lines.join('\n') + (lines.length ? '\n' : ''), 'utf8'); } catch {}
1245
+ });
1246
+ }
1247
+ } catch {}
886
1248
  pendingClose = true;
887
- console.log(dim('\n (finishing current operation...)\n'));
1249
+ console.log(dim('\n (interrupted)\n'));
888
1250
  }
889
1251
  });
890
1252
  }
package/lib/hooks.js ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Hooks system — lifecycle hooks fired around tool execution.
3
+ *
4
+ * Config locations (merged, project overrides global):
5
+ * ~/.tsunami-code/hooks.json — global
6
+ * .tsunami/hooks.json — project-level
7
+ *
8
+ * Format:
9
+ * {
10
+ * "hooks": {
11
+ * "PreToolUse": [{ "matcher": "Bash", "command": "echo $HOOK_TOOL" }],
12
+ * "PostToolUse": [{ "command": "..." }],
13
+ * "Stop": [{ "command": "notify-send Done" }],
14
+ * "Notification":[{ "command": "..." }]
15
+ * }
16
+ * }
17
+ *
18
+ * PreToolUse: non-zero exit code blocks the tool; return value from fireHook is false.
19
+ * All others: errors are silently ignored.
20
+ * Env vars set for every hook: HOOK_EVENT, HOOK_TOOL, HOOK_FILE, HOOK_COMMAND, HOOK_DATA (JSON).
21
+ */
22
+
23
+ import { existsSync, readFileSync } from 'fs';
24
+ import { join } from 'path';
25
+ import { execSync } from 'child_process';
26
+ import os from 'os';
27
+
28
+ const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
29
+
30
+ function loadConfig(cwd) {
31
+ let merged = { hooks: {} };
32
+
33
+ function merge(src) {
34
+ for (const [event, handlers] of Object.entries(src.hooks || {})) {
35
+ if (!Array.isArray(handlers)) continue;
36
+ merged.hooks[event] = [...(merged.hooks[event] || []), ...handlers];
37
+ }
38
+ }
39
+
40
+ const globalPath = join(CONFIG_DIR, 'hooks.json');
41
+ if (existsSync(globalPath)) {
42
+ try { merge(JSON.parse(readFileSync(globalPath, 'utf8'))); } catch {}
43
+ }
44
+
45
+ if (cwd) {
46
+ const projectPath = join(cwd, '.tsunami', 'hooks.json');
47
+ if (existsSync(projectPath)) {
48
+ try { merge(JSON.parse(readFileSync(projectPath, 'utf8'))); } catch {}
49
+ }
50
+ }
51
+
52
+ return merged;
53
+ }
54
+
55
+ /**
56
+ * Fire a hook event.
57
+ *
58
+ * @param {'PreToolUse'|'PostToolUse'|'Stop'|'Notification'} event
59
+ * @param {object} data — { tool?, args?, result? }
60
+ * @param {string} [cwd]
61
+ * @returns {Promise<boolean>} false only when PreToolUse blocks execution
62
+ */
63
+ export async function fireHook(event, data, cwd) {
64
+ const config = loadConfig(cwd);
65
+ const handlers = config.hooks[event];
66
+ if (!handlers || handlers.length === 0) return true;
67
+
68
+ const dataJson = JSON.stringify(data);
69
+ const env = {
70
+ ...process.env,
71
+ HOOK_EVENT: event,
72
+ HOOK_DATA: dataJson,
73
+ HOOK_TOOL: data.tool || '',
74
+ HOOK_FILE: data.args?.file_path || data.args?.path || '',
75
+ HOOK_COMMAND: data.args?.command || '',
76
+ };
77
+
78
+ const shell = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh';
79
+
80
+ for (const handler of handlers) {
81
+ if (!handler.command) continue;
82
+
83
+ // matcher: regex against tool name (case-insensitive)
84
+ if (handler.matcher && data.tool) {
85
+ try {
86
+ if (!new RegExp(handler.matcher, 'i').test(data.tool)) continue;
87
+ } catch {
88
+ if (handler.matcher !== data.tool) continue;
89
+ }
90
+ }
91
+
92
+ try {
93
+ execSync(handler.command, {
94
+ env,
95
+ cwd: cwd || process.cwd(),
96
+ stdio: ['ignore', 'ignore', 'ignore'],
97
+ timeout: 5000,
98
+ shell,
99
+ });
100
+ } catch (e) {
101
+ // PreToolUse: non-zero exit = block
102
+ if (event === 'PreToolUse') return false;
103
+ // All other hooks: ignore errors
104
+ }
105
+ }
106
+
107
+ return true;
108
+ }
109
+
110
+ export function hasHooks(cwd) {
111
+ const globalPath = join(CONFIG_DIR, 'hooks.json');
112
+ const projectPath = cwd ? join(cwd, '.tsunami', 'hooks.json') : null;
113
+ return existsSync(globalPath) || Boolean(projectPath && existsSync(projectPath));
114
+ }
package/lib/loop.js CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  logFileChange,
7
7
  appendDecision
8
8
  } from './memory.js';
9
+ import { fireHook } from './hooks.js';
9
10
 
10
11
  // ── Tool result summarization (generateToolUseSummary pattern) ───────────────
11
12
  const SUMMARY_THRESHOLD = 2000; // chars — results larger than this get compressed
@@ -59,6 +60,11 @@ let _currentModel = 'local';
59
60
  export function setModel(model) { _currentModel = model; }
60
61
  export function getModel() { return _currentModel; }
61
62
 
63
+ // Temperature — changeable via /effort command
64
+ let _temperature = 0.1;
65
+ export function setTemperature(t) { _temperature = Math.max(0, Math.min(1, t)); }
66
+ export function getTemperature() { return _temperature; }
67
+
62
68
  // Parse tool calls from any format the model might produce
63
69
  function parseToolCalls(content) {
64
70
  const calls = [];
@@ -169,8 +175,16 @@ async function runTool(name, args, sessionInfo, sessionFiles) {
169
175
  const parsed = typeof args === 'string' ? JSON.parse(args) : args;
170
176
  const normalized = normalizeArgs(parsed);
171
177
 
178
+ // PreToolUse hook — non-zero exit blocks the tool
179
+ const cwd = sessionInfo?.cwd;
180
+ const allowed = await fireHook('PreToolUse', { tool: name, args: normalized }, cwd);
181
+ if (!allowed) return `[${name} blocked by PreToolUse hook]`;
182
+
172
183
  const result = await tool.run(normalized);
173
184
 
185
+ // PostToolUse hook (fire-and-forget — errors ignored)
186
+ fireHook('PostToolUse', { tool: name, args: normalized, result: String(result).slice(0, 500) }, cwd).catch(() => {});
187
+
174
188
  // Auto-capture: track files touched for context assembly
175
189
  if (sessionFiles && normalized.file_path) {
176
190
  sessionFiles.add(normalized.file_path);
@@ -232,7 +246,7 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
232
246
  model: _currentModel,
233
247
  messages: finalMessages,
234
248
  stream: true,
235
- temperature: 0.1,
249
+ temperature: _temperature,
236
250
  max_tokens: 4096,
237
251
  stop: ['</tool_call>\n\nContinue']
238
252
  })
@@ -500,4 +514,9 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
500
514
  memoryContext = buildMemoryContext();
501
515
  onToken('\n');
502
516
  }
517
+
518
+ // Stop hook — fire after agent loop completes
519
+ if (sessionInfo?.cwd) {
520
+ fireHook('Stop', { turns: maxIterations }, sessionInfo.cwd).catch(() => {});
521
+ }
503
522
  }
package/lib/memdir.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Auto-memory (memdir) — persistent cross-session, cross-project memory.
3
+ *
4
+ * Storage: ~/.tsunami-code/memory/<slug>.md
5
+ * Auto-injected into every system prompt as <auto-memory> block.
6
+ * Model writes via the Memory tool (registered dynamically in tools.js).
7
+ * User manages via /memdir commands in index.js.
8
+ */
9
+
10
+ import {
11
+ existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync, statSync
12
+ } from 'fs';
13
+ import { join, basename } from 'path';
14
+ import os from 'os';
15
+
16
+ const MEMDIR = join(os.homedir(), '.tsunami-code', 'memory');
17
+
18
+ function ensure() {
19
+ if (!existsSync(MEMDIR)) mkdirSync(MEMDIR, { recursive: true });
20
+ }
21
+
22
+ function slug(name) {
23
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'note';
24
+ }
25
+
26
+ /**
27
+ * Load all memory entries and return a formatted context string.
28
+ * Returns '' if no entries exist.
29
+ */
30
+ export function getMemdirContext() {
31
+ ensure();
32
+ try {
33
+ const files = readdirSync(MEMDIR).filter(f => f.endsWith('.md')).sort();
34
+ if (files.length === 0) return '';
35
+
36
+ const parts = [];
37
+ for (const file of files) {
38
+ try {
39
+ const content = readFileSync(join(MEMDIR, file), 'utf8').trim();
40
+ if (content) parts.push(`### ${basename(file, '.md')}\n${content}`);
41
+ } catch {}
42
+ }
43
+
44
+ if (parts.length === 0) return '';
45
+ return `\n\n<auto-memory>\n${parts.join('\n\n')}\n</auto-memory>`;
46
+ } catch {
47
+ return '';
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Save or overwrite a memory entry.
53
+ * @param {string} name — human name (will be slugified)
54
+ * @param {string} content
55
+ * @returns {string|null} path on success, null on failure
56
+ */
57
+ export function saveMemory(name, content) {
58
+ ensure();
59
+ const path = join(MEMDIR, `${slug(name)}.md`);
60
+ try {
61
+ writeFileSync(path, content.trim() + '\n', 'utf8');
62
+ return path;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * List all memory entries with a short preview.
70
+ * @returns {{ name: string, path: string, preview: string, size: number }[]}
71
+ */
72
+ export function listMemories() {
73
+ ensure();
74
+ try {
75
+ return readdirSync(MEMDIR)
76
+ .filter(f => f.endsWith('.md'))
77
+ .sort()
78
+ .map(f => {
79
+ const path = join(MEMDIR, f);
80
+ let preview = '';
81
+ let size = 0;
82
+ try {
83
+ const content = readFileSync(path, 'utf8').trim();
84
+ preview = content.slice(0, 80).replace(/\n/g, ' ');
85
+ size = content.length;
86
+ } catch {}
87
+ return { name: basename(f, '.md'), path, preview, size };
88
+ });
89
+ } catch {
90
+ return [];
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Read a single memory entry's full content.
96
+ * @param {string} name
97
+ * @returns {string|null}
98
+ */
99
+ export function readMemory(name) {
100
+ const path = join(MEMDIR, `${slug(name)}.md`);
101
+ if (!existsSync(path)) return null;
102
+ try { return readFileSync(path, 'utf8').trim(); } catch { return null; }
103
+ }
104
+
105
+ /**
106
+ * Delete a memory entry.
107
+ * @param {string} name
108
+ * @returns {boolean}
109
+ */
110
+ export function deleteMemory(name) {
111
+ const path = join(MEMDIR, `${slug(name)}.md`);
112
+ try {
113
+ if (existsSync(path)) { unlinkSync(path); return true; }
114
+ } catch {}
115
+ return false;
116
+ }
117
+
118
+ export function getMemdirPath() { return MEMDIR; }
package/lib/prompt.js CHANGED
@@ -2,14 +2,27 @@ import { existsSync, readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import os from 'os';
4
4
  import { execSync } from 'child_process';
5
+ import { getMemdirContext } from './memdir.js';
5
6
 
6
7
  function getGitContext() {
7
8
  try {
8
9
  const branch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
9
10
  const status = execSync('git status --short', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
10
11
  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}`);
12
+ // Main branch detection (from context.ts getDefaultBranch pattern)
13
+ let mainBranch = 'main';
14
+ try { mainBranch = execSync('git symbolic-ref refs/remotes/origin/HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim().replace('refs/remotes/origin/', ''); } catch {}
15
+ if (!mainBranch) try { mainBranch = execSync('git config init.defaultBranch', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch {}
16
+ // Git user name (from context.ts)
17
+ let userName = '';
18
+ try { userName = execSync('git config user.name', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch {}
19
+ // Truncate status at 2000 chars (from context.ts MAX_STATUS_CHARS)
20
+ const truncatedStatus = status.length > 2000
21
+ ? status.slice(0, 2000) + '\n... (truncated — run git status for full output)'
22
+ : status;
23
+ const parts = [`Branch: ${branch}`, `Main branch (for PRs): ${mainBranch}`];
24
+ if (userName) parts.push(`Git user: ${userName}`);
25
+ if (truncatedStatus) parts.push(`Changed files:\n${truncatedStatus}`);
13
26
  if (log) parts.push(`Recent commits:\n${log}`);
14
27
  return `\n\n<git>\n${parts.join('\n\n')}\n</git>`;
15
28
  } catch {
@@ -40,6 +53,8 @@ export function buildSystemPrompt(memoryContext = '') {
40
53
 
41
54
  const gitContext = getGitContext();
42
55
 
56
+ const memdirContext = getMemdirContext();
57
+
43
58
  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.
44
59
 
45
60
  To use a tool, output ONLY this format — nothing else before or after the tool call block:
@@ -125,5 +140,5 @@ Notes persist permanently in .tsunami/memory/. Checkpoints persist for the sessi
125
140
  - Error paths as clear as success paths
126
141
  - Parameterized queries only — never concatenate user input into SQL
127
142
  - Every protected route: check auth at the top, first line
128
- </code_quality>${context}${memoryContext ? `\n\n${memoryContext}` : ''}`;
143
+ </code_quality>${context}${memdirContext}${memoryContext ? `\n\n${memoryContext}` : ''}`;
129
144
  }
package/lib/tools.js CHANGED
@@ -12,15 +12,55 @@ const execAsync = promisify(exec);
12
12
  // Mirrors cost-tracker.ts addToTotalLinesChanged pattern
13
13
  export const linesChanged = { added: 0, removed: 0 };
14
14
 
15
- // ── Undo Stack ───────────────────────────────────────────────────────────────
16
- const _undoStack = [];
17
- const MAX_UNDO = 20;
15
+ // ── Undo Stack (per-turn batching) ───────────────────────────────────────────
16
+ // Mirrors CC's multi-file undo: each turn's file changes grouped together.
17
+ // /undo restores ALL files changed in the last turn at once.
18
+ const _undoTurns = []; // Array of turns: each is [{ filePath, content }]
19
+ let _currentTurnFiles = []; // Accumulates file snapshots for the active turn
20
+ const MAX_UNDO_TURNS = 10;
21
+
22
+ /** Call before each agent turn starts to open a new undo group. */
23
+ export function beginUndoTurn() {
24
+ if (_currentTurnFiles.length > 0) {
25
+ _undoTurns.push([..._currentTurnFiles]);
26
+ if (_undoTurns.length > MAX_UNDO_TURNS) _undoTurns.shift();
27
+ }
28
+ _currentTurnFiles = [];
29
+ }
30
+
31
+ function _pushFileSnapshot(filePath) {
32
+ // Only snapshot once per file per turn (first state = what to restore to)
33
+ if (_currentTurnFiles.some(f => f.filePath === filePath)) return;
34
+ try {
35
+ const content = existsSync(filePath) ? readFileSync(filePath, 'utf8') : null;
36
+ _currentTurnFiles.push({ filePath, content });
37
+ } catch {}
38
+ }
39
+
40
+ /** Undo all file changes from the most recent turn. Returns list of restored paths. */
18
41
  export function undo() {
19
- if (_undoStack.length === 0) return null;
20
- const { filePath, content } = _undoStack.pop();
21
- try { writeFileSync(filePath, content, 'utf8'); return filePath; } catch { return null; }
42
+ // Commit current turn if it has anything
43
+ if (_currentTurnFiles.length > 0) {
44
+ _undoTurns.push([..._currentTurnFiles]);
45
+ _currentTurnFiles = [];
46
+ }
47
+ if (_undoTurns.length === 0) return null;
48
+ const turn = _undoTurns.pop();
49
+ const restored = [];
50
+ for (const { filePath, content } of turn.reverse()) {
51
+ try {
52
+ if (content === null) {
53
+ // File was created this turn — remove it
54
+ import('fs').then(({ unlinkSync }) => { try { unlinkSync(filePath); } catch {} });
55
+ } else {
56
+ writeFileSync(filePath, content, 'utf8');
57
+ }
58
+ restored.push(filePath);
59
+ } catch {}
60
+ }
61
+ return restored.length ? restored : null;
22
62
  }
23
- export function undoStackSize() { return _undoStack.length; }
63
+ export function undoStackSize() { return _undoTurns.length + (_currentTurnFiles.length > 0 ? 1 : 0); }
24
64
 
25
65
  // ── Session Context (set by index.js at startup) ──────────────────────────────
26
66
  let _sessionDir = null;
@@ -112,13 +152,7 @@ export const WriteTool = {
112
152
  },
113
153
  async run({ file_path, content }) {
114
154
  try {
115
- // Undo stack: save previous content before overwriting
116
- try {
117
- if (existsSync(file_path)) {
118
- _undoStack.push({ filePath: file_path, content: readFileSync(file_path, 'utf8') });
119
- if (_undoStack.length > MAX_UNDO) _undoStack.shift();
120
- }
121
- } catch {}
155
+ _pushFileSnapshot(file_path);
122
156
  const newLineCount = content.split('\n').length;
123
157
  const oldLineCount = existsSync(file_path) ? readFileSync(file_path, 'utf8').split('\n').length : 0;
124
158
  writeFileSync(file_path, content, 'utf8');
@@ -153,11 +187,7 @@ export const EditTool = {
153
187
  if (!existsSync(file_path)) return `Error: File not found: ${file_path}`;
154
188
  try {
155
189
  let content = readFileSync(file_path, 'utf8');
156
- // Undo stack: save current content before editing
157
- try {
158
- _undoStack.push({ filePath: file_path, content });
159
- if (_undoStack.length > MAX_UNDO) _undoStack.shift();
160
- } catch {}
190
+ _pushFileSnapshot(file_path);
161
191
  if (!content.includes(old_string)) return `Error: old_string not found in ${file_path}`;
162
192
  if (!replace_all) {
163
193
  const count = content.split(old_string).length - 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsunami-code",
3
- "version": "3.4.0",
3
+ "version": "3.6.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": {