tsunami-code 3.5.0 โ†’ 3.6.1

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,7 +1,7 @@
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
7
  import { agentLoop, quickCompletion, setModel, getModel, tokenStats, setTemperature, getTemperature } from './lib/loop.js';
@@ -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.5.0';
29
+ const VERSION = '3.6.1';
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';
@@ -64,7 +65,7 @@ const blue = (s) => chalk.blue(s);
64
65
  function printBanner(serverUrl) {
65
66
  console.log(cyan(bold('\n ๐ŸŒŠ Tsunami Code CLI')) + dim(` v${VERSION}`));
66
67
  console.log(dim(' by Keystone World Management ยท Navy Seal Unit XI3'));
67
- console.log(dim(` Model server: ${serverUrl}\n`));
68
+ console.log(dim(' International AI Wars\n'));
68
69
  }
69
70
 
70
71
  function printToolCall(name, args) {
@@ -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
 
@@ -507,8 +699,12 @@ async function run() {
507
699
  ['/diff', 'Show git diff of session file changes'],
508
700
  ['/stats', 'Session stats: lines, tokens, duration'],
509
701
  ['/export [file]', 'Export conversation to markdown file'],
510
- ['/history', 'Show recent command history'],
511
- ['/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'],
512
708
  ];
513
709
  for (const [c, desc] of cmds) {
514
710
  console.log(` ${cyan(c.padEnd(22))} ${dim(desc)}`);
@@ -807,6 +1003,135 @@ async function run() {
807
1003
  case 'exit': case 'quit':
808
1004
  gracefulExit(0);
809
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
+
810
1135
  default:
811
1136
  console.log(red(` Unknown command: /${cmd}\n`));
812
1137
  }
@@ -850,21 +1175,24 @@ async function run() {
850
1175
  beginUndoTurn();
851
1176
 
852
1177
  let firstToken = true;
1178
+ const highlight = createHighlighter((s) => process.stdout.write(s));
853
1179
  try {
854
1180
  await agentLoop(
855
1181
  currentServerUrl,
856
1182
  fullMessages,
857
1183
  (token) => {
858
1184
  if (firstToken) { process.stdout.write(' '); firstToken = false; }
859
- process.stdout.write(token);
1185
+ highlight(token);
860
1186
  },
861
1187
  (toolName, toolArgs) => {
1188
+ flushHighlighter(highlight);
862
1189
  printToolCall(toolName, toolArgs);
863
1190
  firstToken = true;
864
1191
  },
865
1192
  { sessionDir, cwd, planMode },
866
1193
  makeConfirmCallback(rl)
867
1194
  );
1195
+ flushHighlighter(highlight);
868
1196
 
869
1197
  // Token estimation
870
1198
  const inputChars = fullMessages.reduce((s, m) => s + (typeof m.content === 'string' ? m.content.length : 0), 0);
@@ -901,7 +1229,7 @@ async function run() {
901
1229
  }
902
1230
  rl.resume();
903
1231
  rl.prompt();
904
- });
1232
+ }
905
1233
 
906
1234
  process.on('SIGINT', () => {
907
1235
  if (!isProcessing) {
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
@@ -174,8 +175,16 @@ async function runTool(name, args, sessionInfo, sessionFiles) {
174
175
  const parsed = typeof args === 'string' ? JSON.parse(args) : args;
175
176
  const normalized = normalizeArgs(parsed);
176
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
+
177
183
  const result = await tool.run(normalized);
178
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
+
179
188
  // Auto-capture: track files touched for context assembly
180
189
  if (sessionFiles && normalized.file_path) {
181
190
  sessionFiles.add(normalized.file_path);
@@ -505,4 +514,9 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
505
514
  memoryContext = buildMemoryContext();
506
515
  onToken('\n');
507
516
  }
517
+
518
+ // Stop hook โ€” fire after agent loop completes
519
+ if (sessionInfo?.cwd) {
520
+ fireHook('Stop', { turns: maxIterations }, sessionInfo.cwd).catch(() => {});
521
+ }
508
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,6 +2,7 @@ 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 {
@@ -52,6 +53,8 @@ export function buildSystemPrompt(memoryContext = '') {
52
53
 
53
54
  const gitContext = getGitContext();
54
55
 
56
+ const memdirContext = getMemdirContext();
57
+
55
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.
56
59
 
57
60
  To use a tool, output ONLY this format โ€” nothing else before or after the tool call block:
@@ -137,5 +140,5 @@ Notes persist permanently in .tsunami/memory/. Checkpoints persist for the sessi
137
140
  - Error paths as clear as success paths
138
141
  - Parameterized queries only โ€” never concatenate user input into SQL
139
142
  - Every protected route: check auth at the top, first line
140
- </code_quality>${context}${memoryContext ? `\n\n${memoryContext}` : ''}`;
143
+ </code_quality>${context}${memdirContext}${memoryContext ? `\n\n${memoryContext}` : ''}`;
141
144
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsunami-code",
3
- "version": "3.5.0",
3
+ "version": "3.6.1",
4
4
  "description": "Tsunami Code CLI โ€” AI coding agent by Keystone World Management Navy Seal Unit XI3",
5
5
  "type": "module",
6
6
  "bin": {