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 +376 -14
- package/lib/hooks.js +114 -0
- package/lib/loop.js +20 -1
- package/lib/memdir.js +118 -0
- package/lib/prompt.js +18 -3
- package/lib/tools.js +49 -19
- package/package.json +1 -1
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.
|
|
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
|
-
['/
|
|
510
|
-
['/
|
|
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)
|
|
530
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|