tsunami-code 3.5.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 +335 -7
- package/lib/hooks.js +114 -0
- package/lib/loop.js +14 -0
- package/lib/memdir.js +118 -0
- package/lib/prompt.js +4 -1
- package/package.json +1 -1
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.
|
|
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
|
|
|
@@ -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
|
-
['/
|
|
511
|
-
['/
|
|
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
|
-
|
|
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
|
}
|