tsunami-code 2.5.3 → 2.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/.tsunami/CODEBASE.md +8 -0
- package/.tsunami/README.md +10 -0
- package/.tsunami/memory/package.json.md +8 -0
- package/.tsunami/memory/schema.ts.md +8 -0
- package/index.js +177 -18
- package/lib/loop.js +113 -10
- package/lib/memory.js +493 -0
- package/lib/prompt.js +30 -3
- package/lib/tools.js +100 -1
- package/package.json +1 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# .tsunami/ — Project Memory
|
|
2
|
+
|
|
3
|
+
Auto-managed by Tsunami Code CLI. Safe to commit.
|
|
4
|
+
|
|
5
|
+
## Structure
|
|
6
|
+
- `memory/` — Per-file notes and change history
|
|
7
|
+
- `decisions/` — Team-shared decision log by date
|
|
8
|
+
- `CODEBASE.md` — High-level project understanding
|
|
9
|
+
|
|
10
|
+
Do not edit manually unless you know what you're doing.
|
package/index.js
CHANGED
|
@@ -7,8 +7,20 @@ import os from 'os';
|
|
|
7
7
|
import { agentLoop } from './lib/loop.js';
|
|
8
8
|
import { buildSystemPrompt } from './lib/prompt.js';
|
|
9
9
|
import { runPreflight } from './lib/preflight.js';
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
import { setSession } from './lib/tools.js';
|
|
11
|
+
import {
|
|
12
|
+
initSession,
|
|
13
|
+
initProjectMemory,
|
|
14
|
+
getLastSessionSummary,
|
|
15
|
+
endSession,
|
|
16
|
+
getMemoryStats,
|
|
17
|
+
listFileMemories,
|
|
18
|
+
getFileMemory,
|
|
19
|
+
getRecentDecisions,
|
|
20
|
+
getSessionContext
|
|
21
|
+
} from './lib/memory.js';
|
|
22
|
+
|
|
23
|
+
const VERSION = '2.6.1';
|
|
12
24
|
const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
|
|
13
25
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
14
26
|
const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
|
|
@@ -27,7 +39,6 @@ function saveConfig(cfg) {
|
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
function getServerUrl(argv) {
|
|
30
|
-
// Priority: --server flag > KEYSTONE_SERVER env var > saved config > default
|
|
31
42
|
const flagIdx = argv.indexOf('--server');
|
|
32
43
|
if (flagIdx !== -1 && argv[flagIdx + 1]) return argv[flagIdx + 1];
|
|
33
44
|
if (process.env.TSUNAMI_SERVER) return process.env.TSUNAMI_SERVER;
|
|
@@ -43,6 +54,7 @@ const cyan = (s) => chalk.cyan(s);
|
|
|
43
54
|
const red = (s) => chalk.red(s);
|
|
44
55
|
const green = (s) => chalk.green(s);
|
|
45
56
|
const yellow = (s) => chalk.yellow(s);
|
|
57
|
+
const blue = (s) => chalk.blue(s);
|
|
46
58
|
|
|
47
59
|
function printBanner(serverUrl) {
|
|
48
60
|
console.log(cyan(bold('\n 🌊 Tsunami Code CLI')) + dim(` v${VERSION}`));
|
|
@@ -59,6 +71,12 @@ function printToolCall(name, args) {
|
|
|
59
71
|
process.stdout.write('\n' + dim(` ⚙ ${name}(${preview})\n`));
|
|
60
72
|
}
|
|
61
73
|
|
|
74
|
+
function formatBytes(bytes) {
|
|
75
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
76
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
77
|
+
return `${(bytes / 1024 / 1024).toFixed(2)}MB`;
|
|
78
|
+
}
|
|
79
|
+
|
|
62
80
|
// ── CLI Parsing ───────────────────────────────────────────────────────────────
|
|
63
81
|
const argv = process.argv.slice(2);
|
|
64
82
|
|
|
@@ -84,6 +102,12 @@ if (argv.includes('--help') || argv.includes('-h')) {
|
|
|
84
102
|
${bold('Global config:')}
|
|
85
103
|
~/.tsunami-code/TSUNAMI.md — applies to all projects.
|
|
86
104
|
|
|
105
|
+
${bold('Memory commands:')}
|
|
106
|
+
/memory Show memory stats
|
|
107
|
+
/memory files List files with memory entries
|
|
108
|
+
/memory view <f> Show memory for a specific file
|
|
109
|
+
/memory clear Clear session memory (keep project memory)
|
|
110
|
+
|
|
87
111
|
${bold('Settings:')}
|
|
88
112
|
${CONFIG_FILE}
|
|
89
113
|
`);
|
|
@@ -91,7 +115,7 @@ if (argv.includes('--help') || argv.includes('-h')) {
|
|
|
91
115
|
}
|
|
92
116
|
|
|
93
117
|
if (argv.includes('--version') || argv.includes('-v')) {
|
|
94
|
-
console.log(`
|
|
118
|
+
console.log(`tsunami v${VERSION}`);
|
|
95
119
|
process.exit(0);
|
|
96
120
|
}
|
|
97
121
|
|
|
@@ -108,6 +132,12 @@ if (setServerIdx !== -1 && argv[setServerIdx + 1]) {
|
|
|
108
132
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
109
133
|
async function run() {
|
|
110
134
|
const serverUrl = getServerUrl(argv);
|
|
135
|
+
const cwd = process.cwd();
|
|
136
|
+
|
|
137
|
+
// Initialize memory systems
|
|
138
|
+
const { sessionId, sessionDir } = initSession(cwd);
|
|
139
|
+
initProjectMemory(cwd);
|
|
140
|
+
setSession({ sessionDir, cwd });
|
|
111
141
|
|
|
112
142
|
printBanner(serverUrl);
|
|
113
143
|
|
|
@@ -127,7 +157,13 @@ async function run() {
|
|
|
127
157
|
process.exit(1);
|
|
128
158
|
}
|
|
129
159
|
|
|
130
|
-
console.log(
|
|
160
|
+
console.log(
|
|
161
|
+
green(' ✓ Connected') +
|
|
162
|
+
dim(` · ${sessionId} · Type your task. /help for commands. Ctrl+C to exit.\n`)
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Load last session summary if available
|
|
166
|
+
const lastSessionSummary = getLastSessionSummary(cwd);
|
|
131
167
|
|
|
132
168
|
let currentServerUrl = serverUrl;
|
|
133
169
|
let messages = [];
|
|
@@ -138,6 +174,15 @@ async function run() {
|
|
|
138
174
|
systemPrompt = buildSystemPrompt();
|
|
139
175
|
}
|
|
140
176
|
|
|
177
|
+
/** Trim messages to the last N entries (rolling window) */
|
|
178
|
+
function trimMessages(msgs, maxEntries = 8) {
|
|
179
|
+
// Always keep system message (index 0)
|
|
180
|
+
if (msgs.length <= maxEntries + 1) return msgs;
|
|
181
|
+
const system = msgs[0];
|
|
182
|
+
const rest = msgs.slice(1);
|
|
183
|
+
return [system, ...rest.slice(-maxEntries)];
|
|
184
|
+
}
|
|
185
|
+
|
|
141
186
|
const rl = readline.createInterface({
|
|
142
187
|
input: process.stdin,
|
|
143
188
|
output: process.stdout,
|
|
@@ -150,25 +195,121 @@ async function run() {
|
|
|
150
195
|
let isProcessing = false;
|
|
151
196
|
let pendingClose = false;
|
|
152
197
|
|
|
198
|
+
// ── Exit handler ─────────────────────────────────────────────────────────────
|
|
199
|
+
function gracefulExit(code = 0) {
|
|
200
|
+
try {
|
|
201
|
+
endSession(sessionDir);
|
|
202
|
+
} catch {}
|
|
203
|
+
console.log(dim('\n Goodbye.\n'));
|
|
204
|
+
process.exit(code);
|
|
205
|
+
}
|
|
206
|
+
|
|
153
207
|
rl.on('close', () => {
|
|
154
208
|
if (isProcessing) { pendingClose = true; return; }
|
|
155
|
-
|
|
156
|
-
process.exit(0);
|
|
209
|
+
gracefulExit(0);
|
|
157
210
|
});
|
|
158
211
|
|
|
212
|
+
// ── Memory commands ───────────────────────────────────────────────────────────
|
|
213
|
+
async function handleMemoryCommand(args) {
|
|
214
|
+
const sub = args[0]?.toLowerCase();
|
|
215
|
+
|
|
216
|
+
if (!sub) {
|
|
217
|
+
// /memory — show stats
|
|
218
|
+
const stats = getMemoryStats(cwd);
|
|
219
|
+
console.log(blue('\n Living Memory Stats'));
|
|
220
|
+
console.log(dim(` Project memory files : ${stats.projectMemoryFiles}`));
|
|
221
|
+
console.log(dim(` Project memory size : ${formatBytes(stats.projectMemorySize)}`));
|
|
222
|
+
console.log(dim(` CODEBASE.md : ${stats.hasCODEBASE ? 'exists' : 'none'}`));
|
|
223
|
+
console.log(dim(` Sessions this dir : ${stats.sessionCount}`));
|
|
224
|
+
console.log(dim(` Current session : ${sessionId}`));
|
|
225
|
+
console.log();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (sub === 'files') {
|
|
230
|
+
const files = listFileMemories(cwd);
|
|
231
|
+
if (files.length === 0) {
|
|
232
|
+
console.log(dim(' No file memories yet.\n'));
|
|
233
|
+
} else {
|
|
234
|
+
console.log(blue(`\n Files with memory (${files.length}):`));
|
|
235
|
+
for (const f of files) console.log(dim(` ${f}`));
|
|
236
|
+
console.log();
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (sub === 'view') {
|
|
242
|
+
const filePath = args.slice(1).join(' ');
|
|
243
|
+
if (!filePath) {
|
|
244
|
+
console.log(red(' Usage: /memory view <filepath>\n'));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const mem = getFileMemory(cwd, filePath);
|
|
248
|
+
if (!mem) {
|
|
249
|
+
console.log(dim(` No memory for: ${filePath}\n`));
|
|
250
|
+
} else {
|
|
251
|
+
console.log(blue(`\n Memory: ${filePath}`));
|
|
252
|
+
console.log(dim(' ' + mem.replace(/\n/g, '\n ')));
|
|
253
|
+
console.log();
|
|
254
|
+
}
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (sub === 'last') {
|
|
259
|
+
const { getLastSessionSummary } = await import('./lib/memory.js');
|
|
260
|
+
const summary = getLastSessionSummary(cwd);
|
|
261
|
+
if (!summary) {
|
|
262
|
+
console.log(dim(' No previous session summary found.\n'));
|
|
263
|
+
} else {
|
|
264
|
+
console.log(blue('\n Last Session Summary'));
|
|
265
|
+
console.log(dim(' ' + summary.replace(/\n/g, '\n ')));
|
|
266
|
+
console.log();
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (sub === 'clear') {
|
|
272
|
+
// Reinitialize session (clear session memory, keep project memory)
|
|
273
|
+
resetSession();
|
|
274
|
+
console.log(green(' Session memory cleared. Project memory preserved.\n'));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (sub === 'context') {
|
|
279
|
+
// Debug: show current session context
|
|
280
|
+
const ctx = getSessionContext(sessionDir);
|
|
281
|
+
const decisions = getRecentDecisions(sessionDir, 10);
|
|
282
|
+
console.log(blue('\n Current Session Context:'));
|
|
283
|
+
console.log(dim(' ' + (ctx || '(empty)').replace(/\n/g, '\n ')));
|
|
284
|
+
console.log(blue('\n Recent Decisions:'));
|
|
285
|
+
console.log(dim(' ' + (decisions || '(none)').replace(/\n/g, '\n ')));
|
|
286
|
+
console.log();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
console.log(red(` Unknown memory subcommand: ${sub}\n Try: /memory, /memory files, /memory view <file>, /memory clear\n`));
|
|
291
|
+
}
|
|
292
|
+
|
|
159
293
|
rl.on('line', async (input) => {
|
|
160
294
|
const line = input.trim();
|
|
161
295
|
if (!line) { rl.prompt(); return; }
|
|
162
296
|
|
|
163
297
|
if (line.startsWith('/')) {
|
|
164
|
-
const
|
|
165
|
-
|
|
298
|
+
const parts = line.slice(1).split(' ');
|
|
299
|
+
const cmd = parts[0].toLowerCase();
|
|
300
|
+
const rest = parts.slice(1);
|
|
301
|
+
|
|
302
|
+
switch (cmd) {
|
|
166
303
|
case 'help':
|
|
167
304
|
console.log(dim(`
|
|
168
305
|
Commands:
|
|
169
306
|
/clear Start new conversation
|
|
170
307
|
/status Show context size
|
|
171
308
|
/server <url> Change server URL for this session
|
|
309
|
+
/memory Show memory stats
|
|
310
|
+
/memory files List files with memory entries
|
|
311
|
+
/memory view <f> Show memory for a file
|
|
312
|
+
/memory clear Clear session memory (keep project memory)
|
|
172
313
|
/exit Exit
|
|
173
314
|
`));
|
|
174
315
|
break;
|
|
@@ -187,8 +328,12 @@ async function run() {
|
|
|
187
328
|
console.log(dim(` Current server: ${currentServerUrl}\n`));
|
|
188
329
|
}
|
|
189
330
|
break;
|
|
331
|
+
case 'memory':
|
|
332
|
+
await handleMemoryCommand(rest);
|
|
333
|
+
break;
|
|
190
334
|
case 'exit': case 'quit':
|
|
191
|
-
|
|
335
|
+
gracefulExit(0);
|
|
336
|
+
return;
|
|
192
337
|
default:
|
|
193
338
|
console.log(red(` Unknown command: /${cmd}\n`));
|
|
194
339
|
}
|
|
@@ -196,12 +341,18 @@ async function run() {
|
|
|
196
341
|
return;
|
|
197
342
|
}
|
|
198
343
|
|
|
344
|
+
// Build the user message, optionally injecting last session summary on first turn
|
|
345
|
+
let userContent = line;
|
|
346
|
+
if (messages.length === 0 && lastSessionSummary) {
|
|
347
|
+
userContent = `[Previous session summary]\n${lastSessionSummary}\n\n---\n\n${line}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
199
350
|
const fullMessages = [
|
|
200
351
|
{ role: 'system', content: systemPrompt },
|
|
201
352
|
...messages,
|
|
202
|
-
{ role: 'user', content:
|
|
353
|
+
{ role: 'user', content: userContent }
|
|
203
354
|
];
|
|
204
|
-
messages.push({ role: 'user', content:
|
|
355
|
+
messages.push({ role: 'user', content: userContent });
|
|
205
356
|
|
|
206
357
|
rl.pause();
|
|
207
358
|
isProcessing = true;
|
|
@@ -219,10 +370,15 @@ async function run() {
|
|
|
219
370
|
(toolName, toolArgs) => {
|
|
220
371
|
printToolCall(toolName, toolArgs);
|
|
221
372
|
firstToken = true;
|
|
222
|
-
}
|
|
373
|
+
},
|
|
374
|
+
{ sessionDir, cwd }
|
|
223
375
|
);
|
|
224
376
|
|
|
225
|
-
messages
|
|
377
|
+
// Update messages from the loop (fullMessages was mutated by agentLoop)
|
|
378
|
+
// Trim to rolling window: keep system + last 8 entries
|
|
379
|
+
const loopMessages = fullMessages.slice(1); // drop system, agentLoop added to fullMessages
|
|
380
|
+
messages = trimMessages([{ role: 'system', content: systemPrompt }, ...loopMessages]).slice(1);
|
|
381
|
+
|
|
226
382
|
process.stdout.write('\n\n');
|
|
227
383
|
} catch (e) {
|
|
228
384
|
process.stdout.write('\n');
|
|
@@ -231,8 +387,8 @@ async function run() {
|
|
|
231
387
|
|
|
232
388
|
isProcessing = false;
|
|
233
389
|
if (pendingClose) {
|
|
234
|
-
|
|
235
|
-
|
|
390
|
+
gracefulExit(0);
|
|
391
|
+
return;
|
|
236
392
|
}
|
|
237
393
|
rl.resume();
|
|
238
394
|
rl.prompt();
|
|
@@ -240,8 +396,11 @@ async function run() {
|
|
|
240
396
|
|
|
241
397
|
process.on('SIGINT', () => {
|
|
242
398
|
if (!isProcessing) {
|
|
243
|
-
|
|
244
|
-
|
|
399
|
+
gracefulExit(0);
|
|
400
|
+
} else {
|
|
401
|
+
// If processing, mark pending and let the loop finish
|
|
402
|
+
pendingClose = true;
|
|
403
|
+
console.log(dim('\n (finishing current operation...)\n'));
|
|
245
404
|
}
|
|
246
405
|
});
|
|
247
406
|
}
|
package/lib/loop.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import fetch from 'node-fetch';
|
|
2
2
|
import { ALL_TOOLS } from './tools.js';
|
|
3
|
+
import {
|
|
4
|
+
assembleContext,
|
|
5
|
+
extractFilePaths,
|
|
6
|
+
logFileChange,
|
|
7
|
+
appendDecision
|
|
8
|
+
} from './memory.js';
|
|
9
|
+
|
|
10
|
+
// Skip waitForServer after first successful connection
|
|
11
|
+
let _serverVerified = false;
|
|
3
12
|
|
|
4
13
|
// Parse tool calls from any format the model might produce
|
|
5
14
|
function parseToolCalls(content) {
|
|
@@ -81,37 +90,98 @@ function normalizeArgs(args) {
|
|
|
81
90
|
return out;
|
|
82
91
|
}
|
|
83
92
|
|
|
84
|
-
|
|
93
|
+
function bashDecisionHint(cmd) {
|
|
94
|
+
if (!cmd) return null;
|
|
95
|
+
const c = cmd.trim();
|
|
96
|
+
if (/psql\s/.test(c)) return `DB: ${c.slice(0, 120)}`;
|
|
97
|
+
if (/npm\s+(install|run|publish|build)/.test(c)) return `NPM: ${c.slice(0, 120)}`;
|
|
98
|
+
if (/pm2\s+(restart|stop|start|delete|reload)/.test(c)) return `PM2: ${c.slice(0, 120)}`;
|
|
99
|
+
if (/git\s+(commit|push|checkout|merge|rebase|reset)/.test(c)) return `GIT: ${c.slice(0, 120)}`;
|
|
100
|
+
if (/ssh\s+/.test(c)) return `SSH: ${c.slice(0, 80)}`;
|
|
101
|
+
if (/scp\s+/.test(c)) return `SCP: ${c.slice(0, 80)}`;
|
|
102
|
+
if (/rm\s+-rf|DROP TABLE|ALTER TABLE|CREATE TABLE/.test(c)) return `DESTRUCTIVE: ${c.slice(0, 120)}`;
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Only log decisions that are actually meaningful
|
|
107
|
+
function meaningfulDecision(toolName, args) {
|
|
108
|
+
if (toolName === 'Note') return `NOTE: ${(args.note || '').slice(0, 120)}`;
|
|
109
|
+
if (toolName === 'Checkpoint') return `CHECKPOINT: ${(args.content || '').split('\n')[0].slice(0, 120)}`;
|
|
110
|
+
if (toolName === 'Write') return `WROTE: ${args.file_path} (${(args.content || '').split('\n').length} lines)`;
|
|
111
|
+
if (toolName === 'Edit') return `EDITED: ${args.file_path} — replaced "${(args.old_string || '').slice(0, 60).replace(/\n/g, '↵')}"`;
|
|
112
|
+
if (toolName === 'Bash') return bashDecisionHint(args.command);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function runTool(name, args, sessionInfo, sessionFiles) {
|
|
85
117
|
const tool = ALL_TOOLS.find(t => t.name === name);
|
|
86
118
|
if (!tool) return `Error: Unknown tool "${name}"`;
|
|
87
119
|
try {
|
|
88
120
|
const parsed = typeof args === 'string' ? JSON.parse(args) : args;
|
|
89
|
-
|
|
121
|
+
const normalized = normalizeArgs(parsed);
|
|
122
|
+
|
|
123
|
+
const result = await tool.run(normalized);
|
|
124
|
+
|
|
125
|
+
// Auto-capture: track files touched for context assembly
|
|
126
|
+
if (sessionFiles && normalized.file_path) {
|
|
127
|
+
sessionFiles.add(normalized.file_path);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Auto-capture: write/edit → log to project memory
|
|
131
|
+
if (sessionInfo) {
|
|
132
|
+
const { sessionDir, cwd } = sessionInfo;
|
|
133
|
+
try {
|
|
134
|
+
if (name === 'Write' && normalized.file_path) {
|
|
135
|
+
const lines = (normalized.content || '').split('\n').length;
|
|
136
|
+
logFileChange(cwd, normalized.file_path, `Written ${lines} lines`);
|
|
137
|
+
} else if (name === 'Edit' && normalized.file_path) {
|
|
138
|
+
const preview = (normalized.old_string || '').slice(0, 60).replace(/\n/g, '↵');
|
|
139
|
+
logFileChange(cwd, normalized.file_path, `Edited: replaced "${preview}"`);
|
|
140
|
+
}
|
|
141
|
+
// Log only meaningful decisions — not every Read/Glob/Grep
|
|
142
|
+
const decision = meaningfulDecision(name, normalized);
|
|
143
|
+
if (decision) appendDecision(sessionDir, decision);
|
|
144
|
+
} catch {}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return result;
|
|
90
148
|
} catch (e) {
|
|
91
149
|
return `Error executing ${name}: ${e.message}`;
|
|
92
150
|
}
|
|
93
151
|
}
|
|
94
152
|
|
|
95
|
-
async function waitForServer(serverUrl, retries =
|
|
153
|
+
async function waitForServer(serverUrl, retries = 5, delay = 1000) {
|
|
96
154
|
for (let i = 0; i < retries; i++) {
|
|
97
155
|
try {
|
|
98
156
|
const r = await fetch(`${serverUrl}/health`);
|
|
99
157
|
const j = await r.json();
|
|
100
|
-
if (j.status === 'ok') return;
|
|
158
|
+
if (j.status === 'ok') { _serverVerified = true; return; }
|
|
101
159
|
} catch {}
|
|
102
160
|
await new Promise(r => setTimeout(r, delay));
|
|
103
161
|
}
|
|
104
162
|
throw new Error(`Model server not responding at ${serverUrl}`);
|
|
105
163
|
}
|
|
106
164
|
|
|
107
|
-
async function streamCompletion(serverUrl, messages, onToken) {
|
|
108
|
-
await waitForServer(serverUrl
|
|
165
|
+
async function streamCompletion(serverUrl, messages, onToken, memoryContext = '') {
|
|
166
|
+
if (!_serverVerified) await waitForServer(serverUrl);
|
|
167
|
+
|
|
168
|
+
// Inject memory context into the system message (first message with role=system)
|
|
169
|
+
let finalMessages = messages;
|
|
170
|
+
if (memoryContext) {
|
|
171
|
+
finalMessages = messages.map((msg, idx) => {
|
|
172
|
+
if (idx === 0 && msg.role === 'system') {
|
|
173
|
+
return { ...msg, content: msg.content + memoryContext };
|
|
174
|
+
}
|
|
175
|
+
return msg;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
109
179
|
const res = await fetch(`${serverUrl}/v1/chat/completions`, {
|
|
110
180
|
method: 'POST',
|
|
111
181
|
headers: { 'Content-Type': 'application/json' },
|
|
112
182
|
body: JSON.stringify({
|
|
113
183
|
model: 'local',
|
|
114
|
-
messages,
|
|
184
|
+
messages: finalMessages,
|
|
115
185
|
stream: true,
|
|
116
186
|
temperature: 0.1,
|
|
117
187
|
max_tokens: 4096,
|
|
@@ -146,9 +216,40 @@ async function streamCompletion(serverUrl, messages, onToken) {
|
|
|
146
216
|
return fullContent;
|
|
147
217
|
}
|
|
148
218
|
|
|
149
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Main agent loop.
|
|
221
|
+
*
|
|
222
|
+
* @param {string} serverUrl
|
|
223
|
+
* @param {Array} messages — full message array including system prompt at index 0
|
|
224
|
+
* @param {Function} onToken
|
|
225
|
+
* @param {Function} onToolCall
|
|
226
|
+
* @param {{ sessionDir: string, cwd: string } | null} sessionInfo
|
|
227
|
+
* @param {number} maxIterations
|
|
228
|
+
*/
|
|
229
|
+
export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessionInfo = null, maxIterations = 15) {
|
|
230
|
+
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
|
|
231
|
+
const currentTask = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '';
|
|
232
|
+
|
|
233
|
+
// Files touched during this turn — fed back into context assembly each iteration
|
|
234
|
+
const sessionFiles = new Set(extractFilePaths(currentTask));
|
|
235
|
+
|
|
236
|
+
// Assemble memory context once before the turn, refresh after files are touched
|
|
237
|
+
const buildMemoryContext = () => {
|
|
238
|
+
if (!sessionInfo) return '';
|
|
239
|
+
try {
|
|
240
|
+
return assembleContext({
|
|
241
|
+
sessionDir: sessionInfo.sessionDir,
|
|
242
|
+
cwd: sessionInfo.cwd,
|
|
243
|
+
currentTask,
|
|
244
|
+
filesToConsider: [...sessionFiles]
|
|
245
|
+
});
|
|
246
|
+
} catch { return ''; }
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
let memoryContext = buildMemoryContext();
|
|
250
|
+
|
|
150
251
|
for (let i = 0; i < maxIterations; i++) {
|
|
151
|
-
const content = await streamCompletion(serverUrl, messages, onToken);
|
|
252
|
+
const content = await streamCompletion(serverUrl, messages, onToken, memoryContext);
|
|
152
253
|
const toolCalls = parseToolCalls(content);
|
|
153
254
|
|
|
154
255
|
messages.push({ role: 'assistant', content });
|
|
@@ -158,7 +259,7 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, maxIte
|
|
|
158
259
|
const results = [];
|
|
159
260
|
for (const tc of toolCalls) {
|
|
160
261
|
onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
|
|
161
|
-
const result = await runTool(tc.name, tc.arguments);
|
|
262
|
+
const result = await runTool(tc.name, tc.arguments, sessionInfo, sessionFiles);
|
|
162
263
|
results.push(`[${tc.name} result]\n${String(result).slice(0, 8000)}`);
|
|
163
264
|
}
|
|
164
265
|
|
|
@@ -167,6 +268,8 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, maxIte
|
|
|
167
268
|
content: results.join('\n\n---\n\n') + '\n\nContinue with the task.'
|
|
168
269
|
});
|
|
169
270
|
|
|
271
|
+
// Refresh memory context now that new files may have been touched
|
|
272
|
+
memoryContext = buildMemoryContext();
|
|
170
273
|
onToken('\n');
|
|
171
274
|
}
|
|
172
275
|
}
|
package/lib/memory.js
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Living Memory Engine — Tsunami Code CLI v2.6.0
|
|
3
|
+
*
|
|
4
|
+
* Three-layer persistent memory that eliminates the context window problem.
|
|
5
|
+
* All writes are silent-fail — memory must never crash the agent.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs';
|
|
9
|
+
import { join, relative, dirname, sep } from 'path';
|
|
10
|
+
import { createHash } from 'crypto';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
|
|
13
|
+
const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
|
|
14
|
+
const SESSIONS_DIR = join(CONFIG_DIR, 'sessions');
|
|
15
|
+
|
|
16
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function safeRead(filePath, fallback = '') {
|
|
19
|
+
try {
|
|
20
|
+
if (!existsSync(filePath)) return fallback;
|
|
21
|
+
return readFileSync(filePath, 'utf8');
|
|
22
|
+
} catch {
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function safeWrite(filePath, content) {
|
|
28
|
+
try {
|
|
29
|
+
const dir = dirname(filePath);
|
|
30
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
31
|
+
writeFileSync(filePath, content, 'utf8');
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function safeAppend(filePath, line) {
|
|
39
|
+
try {
|
|
40
|
+
const dir = dirname(filePath);
|
|
41
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
42
|
+
const existing = safeRead(filePath, '');
|
|
43
|
+
writeFileSync(filePath, existing + line, 'utf8');
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Normalize a file path to a relative, forward-slash key safe for filenames */
|
|
51
|
+
function normalizeFilePath(cwd, filePath) {
|
|
52
|
+
try {
|
|
53
|
+
const rel = relative(cwd, filePath).split(sep).join('/');
|
|
54
|
+
// Strip leading ../ — if truly outside cwd, use the basename
|
|
55
|
+
return rel.startsWith('../') ? filePath.split(sep).pop() : rel;
|
|
56
|
+
} catch {
|
|
57
|
+
return filePath.split(sep).pop() || filePath;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Convert a relative path into a safe filesystem path segment */
|
|
62
|
+
function safePathSegment(relPath) {
|
|
63
|
+
// Replace path separators and special chars with underscores
|
|
64
|
+
return relPath.replace(/[\/\\:*?"<>|]/g, '_');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function cwdHash(cwd) {
|
|
68
|
+
return createHash('sha1').update(cwd).digest('hex').slice(0, 12);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function dateId() {
|
|
72
|
+
const now = new Date();
|
|
73
|
+
const d = now.toISOString().split('T')[0];
|
|
74
|
+
const t = now.toTimeString().slice(0, 8).replace(/:/g, '');
|
|
75
|
+
return `${d}_${t}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function now() {
|
|
79
|
+
return new Date().toISOString();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Truncate a string to approximately maxChars, preserving whole lines */
|
|
83
|
+
function truncate(str, maxChars) {
|
|
84
|
+
if (str.length <= maxChars) return str;
|
|
85
|
+
// Keep the last maxChars characters (most recent content is most relevant)
|
|
86
|
+
const truncated = str.slice(str.length - maxChars);
|
|
87
|
+
const firstNewline = truncated.indexOf('\n');
|
|
88
|
+
return firstNewline > 0 ? '...[truncated]\n' + truncated.slice(firstNewline + 1) : '...[truncated]\n' + truncated;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Layer 2: Session Memory ───────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Initialize a new session for the given working directory.
|
|
95
|
+
* Creates: ~/.tsunami-code/sessions/[cwd-hash]/[date-id]/
|
|
96
|
+
* Returns { sessionId, sessionDir }
|
|
97
|
+
*/
|
|
98
|
+
export function initSession(cwd) {
|
|
99
|
+
try {
|
|
100
|
+
const hash = cwdHash(cwd);
|
|
101
|
+
const id = dateId();
|
|
102
|
+
const sessionId = `${hash}-${id}`;
|
|
103
|
+
const sessionDir = join(SESSIONS_DIR, hash, id);
|
|
104
|
+
if (!existsSync(sessionDir)) mkdirSync(sessionDir, { recursive: true });
|
|
105
|
+
// Write initial context placeholder
|
|
106
|
+
safeWrite(join(sessionDir, 'context.md'), `# Session Context\nStarted: ${now()}\nCWD: ${cwd}\n\n_No checkpoint yet._\n`);
|
|
107
|
+
safeWrite(join(sessionDir, 'decisions.log'), `# Decisions Log\nSession: ${sessionId}\nStarted: ${now()}\n\n`);
|
|
108
|
+
return { sessionId, sessionDir };
|
|
109
|
+
} catch (e) {
|
|
110
|
+
// Fallback: use a temp session dir that won't crash anything
|
|
111
|
+
const fallback = join(os.tmpdir(), `tsunami-session-${Date.now()}`);
|
|
112
|
+
try { mkdirSync(fallback, { recursive: true }); } catch {}
|
|
113
|
+
return { sessionId: `fallback-${Date.now()}`, sessionDir: fallback };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Update the current session's context.md with task progress */
|
|
118
|
+
export function updateContext(sessionDir, content) {
|
|
119
|
+
try {
|
|
120
|
+
const header = `# Session Context\nUpdated: ${now()}\n\n`;
|
|
121
|
+
safeWrite(join(sessionDir, 'context.md'), header + content);
|
|
122
|
+
} catch {}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Append a decision entry (append-only, timestamped) */
|
|
126
|
+
export function appendDecision(sessionDir, decision) {
|
|
127
|
+
try {
|
|
128
|
+
safeAppend(join(sessionDir, 'decisions.log'), `[${now()}] ${decision}\n`);
|
|
129
|
+
} catch {}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Read the current session context */
|
|
133
|
+
export function getSessionContext(sessionDir) {
|
|
134
|
+
return safeRead(join(sessionDir, 'context.md'), '');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Read the last N decisions from the log */
|
|
138
|
+
export function getRecentDecisions(sessionDir, n = 5) {
|
|
139
|
+
const raw = safeRead(join(sessionDir, 'decisions.log'), '');
|
|
140
|
+
const lines = raw.split('\n').filter(l => l.startsWith('['));
|
|
141
|
+
return lines.slice(-n).join('\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Get the most recent session summary from a prior session for this cwd.
|
|
146
|
+
* Returns empty string if none exists.
|
|
147
|
+
*/
|
|
148
|
+
export function getLastSessionSummary(cwd) {
|
|
149
|
+
try {
|
|
150
|
+
const hash = cwdHash(cwd);
|
|
151
|
+
const hashDir = join(SESSIONS_DIR, hash);
|
|
152
|
+
if (!existsSync(hashDir)) return '';
|
|
153
|
+
|
|
154
|
+
const sessions = readdirSync(hashDir)
|
|
155
|
+
.filter(d => existsSync(join(hashDir, d, 'summary.md')))
|
|
156
|
+
.sort()
|
|
157
|
+
.reverse();
|
|
158
|
+
|
|
159
|
+
if (sessions.length === 0) return '';
|
|
160
|
+
return safeRead(join(hashDir, sessions[0], 'summary.md'), '');
|
|
161
|
+
} catch {
|
|
162
|
+
return '';
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* End a session: auto-generate summary.md from decisions.log + context.md.
|
|
168
|
+
* Produces useful output even with zero model cooperation.
|
|
169
|
+
*/
|
|
170
|
+
export function endSession(sessionDir) {
|
|
171
|
+
try {
|
|
172
|
+
const context = safeRead(join(sessionDir, 'context.md'), '');
|
|
173
|
+
const decisions = safeRead(join(sessionDir, 'decisions.log'), '');
|
|
174
|
+
|
|
175
|
+
const decisionLines = decisions.split('\n').filter(l => l.startsWith('['));
|
|
176
|
+
const decisionCount = decisionLines.length;
|
|
177
|
+
|
|
178
|
+
// Extract last checkpoint content (between first and second # heading or all)
|
|
179
|
+
const contextBody = context.replace(/^# Session Context\n.*\n\n/, '').trim();
|
|
180
|
+
|
|
181
|
+
const summary = [
|
|
182
|
+
`# Session Summary`,
|
|
183
|
+
`Generated: ${now()}`,
|
|
184
|
+
``,
|
|
185
|
+
`## Task State`,
|
|
186
|
+
contextBody || '_No checkpoint was saved this session._',
|
|
187
|
+
``,
|
|
188
|
+
`## Decisions Made (${decisionCount} total)`,
|
|
189
|
+
decisionCount > 0
|
|
190
|
+
? decisionLines.slice(-20).join('\n')
|
|
191
|
+
: '_No decisions logged._',
|
|
192
|
+
``,
|
|
193
|
+
`## Session End`,
|
|
194
|
+
`Ended: ${now()}`,
|
|
195
|
+
].join('\n');
|
|
196
|
+
|
|
197
|
+
safeWrite(join(sessionDir, 'summary.md'), summary);
|
|
198
|
+
} catch {}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Layer 3: Project Memory ───────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/** Initialize .tsunami/ project memory structure. Idempotent. */
|
|
204
|
+
export function initProjectMemory(cwd) {
|
|
205
|
+
try {
|
|
206
|
+
const tsunamiDir = join(cwd, '.tsunami');
|
|
207
|
+
const memoryDir = join(tsunamiDir, 'memory');
|
|
208
|
+
const decisionsDir = join(tsunamiDir, 'decisions');
|
|
209
|
+
|
|
210
|
+
for (const dir of [tsunamiDir, memoryDir, decisionsDir]) {
|
|
211
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const readmePath = join(tsunamiDir, 'README.md');
|
|
215
|
+
if (!existsSync(readmePath)) {
|
|
216
|
+
safeWrite(readmePath, [
|
|
217
|
+
`# .tsunami/ — Project Memory`,
|
|
218
|
+
``,
|
|
219
|
+
`Auto-managed by Tsunami Code CLI. Safe to commit.`,
|
|
220
|
+
``,
|
|
221
|
+
`## Structure`,
|
|
222
|
+
`- \`memory/\` — Per-file notes and change history`,
|
|
223
|
+
`- \`decisions/\` — Team-shared decision log by date`,
|
|
224
|
+
`- \`CODEBASE.md\` — High-level project understanding`,
|
|
225
|
+
``,
|
|
226
|
+
`Do not edit manually unless you know what you're doing.`,
|
|
227
|
+
].join('\n'));
|
|
228
|
+
}
|
|
229
|
+
} catch {}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Get the path to a file's memory file in .tsunami/memory/ */
|
|
233
|
+
function fileMemoryPath(cwd, filePath) {
|
|
234
|
+
const rel = normalizeFilePath(cwd, filePath);
|
|
235
|
+
const segment = safePathSegment(rel);
|
|
236
|
+
return join(cwd, '.tsunami', 'memory', `${segment}.md`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Add a permanent note about a file to project memory.
|
|
241
|
+
* filePath: absolute or relative. Use null/undefined for project-wide notes (→ CODEBASE.md).
|
|
242
|
+
*/
|
|
243
|
+
export function addFileNote(cwd, filePath, note) {
|
|
244
|
+
try {
|
|
245
|
+
if (!filePath) {
|
|
246
|
+
// Project-wide note → CODEBASE.md
|
|
247
|
+
const codebasePath = join(cwd, '.tsunami', 'CODEBASE.md');
|
|
248
|
+
const existing = safeRead(codebasePath, '# Project Notes\n\n');
|
|
249
|
+
safeWrite(codebasePath, existing + `\n## [${now()}]\n${note}\n`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const memPath = fileMemoryPath(cwd, filePath);
|
|
254
|
+
const rel = normalizeFilePath(cwd, filePath);
|
|
255
|
+
let existing = safeRead(memPath, '');
|
|
256
|
+
|
|
257
|
+
if (!existing) {
|
|
258
|
+
existing = `# Memory: ${rel}\n\n## Notes\n\n## Changes\n\n## Access Log\n`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Insert note after ## Notes heading
|
|
262
|
+
const updated = existing.replace(
|
|
263
|
+
/## Notes\n/,
|
|
264
|
+
`## Notes\n\n### [${now()}]\n${note}\n`
|
|
265
|
+
);
|
|
266
|
+
safeWrite(memPath, compressIfNeeded(updated));
|
|
267
|
+
} catch {}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Log a file change to project memory */
|
|
271
|
+
export function logFileChange(cwd, filePath, desc) {
|
|
272
|
+
try {
|
|
273
|
+
const memPath = fileMemoryPath(cwd, filePath);
|
|
274
|
+
const rel = normalizeFilePath(cwd, filePath);
|
|
275
|
+
let existing = safeRead(memPath, '');
|
|
276
|
+
|
|
277
|
+
if (!existing) {
|
|
278
|
+
existing = `# Memory: ${rel}\n\n## Notes\n\n## Changes\n\n## Access Log\n`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const entry = `- [${now()}] ${desc}`;
|
|
282
|
+
const updated = existing.replace(
|
|
283
|
+
/## Changes\n/,
|
|
284
|
+
`## Changes\n${entry}\n`
|
|
285
|
+
);
|
|
286
|
+
safeWrite(memPath, compressIfNeeded(updated));
|
|
287
|
+
|
|
288
|
+
// Also log to today's team decisions
|
|
289
|
+
const today = new Date().toISOString().split('T')[0];
|
|
290
|
+
const decisionsPath = join(cwd, '.tsunami', 'decisions', `${today}.log`);
|
|
291
|
+
safeAppend(decisionsPath, `[${now()}] CHANGE ${rel}: ${desc}\n`);
|
|
292
|
+
} catch {}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Log a lightweight file access (read) to project memory */
|
|
296
|
+
export function logFileAccess(cwd, filePath) {
|
|
297
|
+
try {
|
|
298
|
+
const memPath = fileMemoryPath(cwd, filePath);
|
|
299
|
+
const rel = normalizeFilePath(cwd, filePath);
|
|
300
|
+
let existing = safeRead(memPath, '');
|
|
301
|
+
|
|
302
|
+
if (!existing) {
|
|
303
|
+
existing = `# Memory: ${rel}\n\n## Notes\n\n## Changes\n\n## Access Log\n`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Only log access if this is the first time or add to existing access log (lightweight)
|
|
307
|
+
const entry = `- [${now()}] Read`;
|
|
308
|
+
const updated = existing.replace(/## Access Log\n/, `## Access Log\n${entry}\n`);
|
|
309
|
+
safeWrite(memPath, compressIfNeeded(updated));
|
|
310
|
+
} catch {}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Read memory for a specific file. Returns '' if none exists. */
|
|
314
|
+
export function getFileMemory(cwd, filePath) {
|
|
315
|
+
try {
|
|
316
|
+
return safeRead(fileMemoryPath(cwd, filePath), '');
|
|
317
|
+
} catch {
|
|
318
|
+
return '';
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Compress file memory when it exceeds 3KB:
|
|
324
|
+
* Keep all Notes sections, keep last 5 Changes, drop older access log entries.
|
|
325
|
+
*/
|
|
326
|
+
function compressIfNeeded(content) {
|
|
327
|
+
const MAX_BYTES = 3 * 1024;
|
|
328
|
+
if (content.length <= MAX_BYTES) return content;
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
// Split into sections
|
|
332
|
+
const notesMatch = content.match(/(## Notes[\s\S]*?)(## Changes)/);
|
|
333
|
+
const changesMatch = content.match(/(## Changes\n)([\s\S]*?)(## Access Log)/);
|
|
334
|
+
const header = content.match(/^(# Memory:.*\n)/)?.[1] || '';
|
|
335
|
+
|
|
336
|
+
const notesSection = notesMatch ? notesMatch[1] : '## Notes\n\n';
|
|
337
|
+
let changesContent = changesMatch ? changesMatch[2] : '';
|
|
338
|
+
const accessSection = '## Access Log\n- [compressed] older entries removed\n';
|
|
339
|
+
|
|
340
|
+
// Keep only last 5 change entries
|
|
341
|
+
const changeLines = changesContent.split('\n').filter(l => l.startsWith('- ['));
|
|
342
|
+
const keptChanges = changeLines.slice(-5).join('\n') + '\n';
|
|
343
|
+
|
|
344
|
+
return `${header}\n${notesSection}## Changes\n${keptChanges}\n${accessSection}`;
|
|
345
|
+
} catch {
|
|
346
|
+
// If compression fails, just hard-truncate
|
|
347
|
+
return content.slice(0, MAX_BYTES);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── Context Assembly ──────────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Extract file paths from text using multiple heuristics.
|
|
355
|
+
* Returns an array of unique paths.
|
|
356
|
+
*/
|
|
357
|
+
export function extractFilePaths(text) {
|
|
358
|
+
if (!text) return [];
|
|
359
|
+
const paths = new Set();
|
|
360
|
+
|
|
361
|
+
// Absolute paths (/foo/bar.ts, C:\foo\bar.ts)
|
|
362
|
+
const absUnix = /(?:^|[\s"'`(])(\/?[\w.-]+(?:\/[\w.-]+)+\.\w+)/gm;
|
|
363
|
+
const absWin = /[A-Za-z]:\\[\w\\.-]+\.\w+/g;
|
|
364
|
+
// Relative paths (./foo.ts, ../foo/bar.js)
|
|
365
|
+
const rel = /(?:^|[\s"'`(])(\.{1,2}\/[\w./\\-]+\.\w+)/gm;
|
|
366
|
+
|
|
367
|
+
let m;
|
|
368
|
+
while ((m = absUnix.exec(text)) !== null) {
|
|
369
|
+
const p = m[1].trim();
|
|
370
|
+
if (p.length > 3 && p.includes('.')) paths.add(p);
|
|
371
|
+
}
|
|
372
|
+
while ((m = absWin.exec(text)) !== null) {
|
|
373
|
+
paths.add(m[0].trim());
|
|
374
|
+
}
|
|
375
|
+
while ((m = rel.exec(text)) !== null) {
|
|
376
|
+
paths.add(m[1].trim());
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return [...paths].slice(0, 20);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Assemble the context string injected into every model call.
|
|
384
|
+
* Hard budget: stays under ~2,500 tokens (approx 10,000 chars).
|
|
385
|
+
*
|
|
386
|
+
* @param {Object} opts
|
|
387
|
+
* @param {string} opts.sessionDir
|
|
388
|
+
* @param {string} opts.cwd
|
|
389
|
+
* @param {string} [opts.currentTask]
|
|
390
|
+
* @param {string[]} [opts.filesToConsider]
|
|
391
|
+
* @returns {string}
|
|
392
|
+
*/
|
|
393
|
+
export function assembleContext({ sessionDir, cwd, currentTask = '', filesToConsider = [] }) {
|
|
394
|
+
try {
|
|
395
|
+
const parts = [];
|
|
396
|
+
|
|
397
|
+
// Session context (~150 tokens / ~600 chars)
|
|
398
|
+
const sessionCtx = truncate(getSessionContext(sessionDir), 600);
|
|
399
|
+
if (sessionCtx && !sessionCtx.includes('_No checkpoint yet._')) {
|
|
400
|
+
parts.push(`<session_context>\n${sessionCtx}\n</session_context>`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Recent decisions (~100 tokens / ~400 chars)
|
|
404
|
+
const decisions = getRecentDecisions(sessionDir, 5);
|
|
405
|
+
if (decisions) {
|
|
406
|
+
parts.push(`<recent_decisions>\n${decisions}\n</recent_decisions>`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// CODEBASE.md truncated (~300 tokens / ~1,200 chars)
|
|
410
|
+
const codebasePath = join(cwd, '.tsunami', 'CODEBASE.md');
|
|
411
|
+
const codebase = truncate(safeRead(codebasePath, ''), 1200);
|
|
412
|
+
if (codebase && codebase !== '# Project Notes\n\n') {
|
|
413
|
+
parts.push(`<codebase_knowledge>\n${codebase}\n</codebase_knowledge>`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// File memories for files relevant to current task (max 4 files × ~1,200 chars = 4,800)
|
|
417
|
+
const allCandidates = [
|
|
418
|
+
...filesToConsider,
|
|
419
|
+
...extractFilePaths(currentTask)
|
|
420
|
+
];
|
|
421
|
+
const uniqueFiles = [...new Set(allCandidates)].slice(0, 4);
|
|
422
|
+
|
|
423
|
+
const fileMemories = [];
|
|
424
|
+
for (const fp of uniqueFiles) {
|
|
425
|
+
const mem = getFileMemory(cwd, fp);
|
|
426
|
+
if (mem && mem.trim()) {
|
|
427
|
+
const rel = normalizeFilePath(cwd, fp);
|
|
428
|
+
fileMemories.push(`### ${rel}\n${truncate(mem, 1200)}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (fileMemories.length > 0) {
|
|
432
|
+
parts.push(`<file_memories>\n${fileMemories.join('\n\n')}\n</file_memories>`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (parts.length === 0) return '';
|
|
436
|
+
|
|
437
|
+
return `\n<current_memory>\n${parts.join('\n\n')}\n</current_memory>`;
|
|
438
|
+
} catch {
|
|
439
|
+
return '';
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ── Memory Stats & Listing ────────────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Returns stats about project memory.
|
|
447
|
+
* @returns {{ projectMemoryFiles: number, projectMemorySize: number, hasCODEBASE: boolean, sessionCount: number }}
|
|
448
|
+
*/
|
|
449
|
+
export function getMemoryStats(cwd) {
|
|
450
|
+
try {
|
|
451
|
+
const memDir = join(cwd, '.tsunami', 'memory');
|
|
452
|
+
const codebasePath = join(cwd, '.tsunami', 'CODEBASE.md');
|
|
453
|
+
const hash = cwdHash(cwd);
|
|
454
|
+
const hashDir = join(SESSIONS_DIR, hash);
|
|
455
|
+
|
|
456
|
+
let projectMemoryFiles = 0;
|
|
457
|
+
let projectMemorySize = 0;
|
|
458
|
+
if (existsSync(memDir)) {
|
|
459
|
+
const files = readdirSync(memDir).filter(f => f.endsWith('.md'));
|
|
460
|
+
projectMemoryFiles = files.length;
|
|
461
|
+
for (const f of files) {
|
|
462
|
+
try { projectMemorySize += statSync(join(memDir, f)).size; } catch {}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const hasCODEBASE = existsSync(codebasePath);
|
|
467
|
+
|
|
468
|
+
let sessionCount = 0;
|
|
469
|
+
if (existsSync(hashDir)) {
|
|
470
|
+
sessionCount = readdirSync(hashDir).length;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return { projectMemoryFiles, projectMemorySize, hasCODEBASE, sessionCount };
|
|
474
|
+
} catch {
|
|
475
|
+
return { projectMemoryFiles: 0, projectMemorySize: 0, hasCODEBASE: false, sessionCount: 0 };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* List all files that have memory entries.
|
|
481
|
+
* @returns {string[]}
|
|
482
|
+
*/
|
|
483
|
+
export function listFileMemories(cwd) {
|
|
484
|
+
try {
|
|
485
|
+
const memDir = join(cwd, '.tsunami', 'memory');
|
|
486
|
+
if (!existsSync(memDir)) return [];
|
|
487
|
+
return readdirSync(memDir)
|
|
488
|
+
.filter(f => f.endsWith('.md'))
|
|
489
|
+
.map(f => f.slice(0, -3).replace(/_/g, '/'));
|
|
490
|
+
} catch {
|
|
491
|
+
return [];
|
|
492
|
+
}
|
|
493
|
+
}
|
package/lib/prompt.js
CHANGED
|
@@ -15,7 +15,11 @@ function loadContextFile() {
|
|
|
15
15
|
return '';
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Build the system prompt.
|
|
20
|
+
* @param {string} [memoryContext] — assembled memory string from assembleContext()
|
|
21
|
+
*/
|
|
22
|
+
export function buildSystemPrompt(memoryContext = '') {
|
|
19
23
|
const cwd = process.cwd();
|
|
20
24
|
const context = loadContextFile();
|
|
21
25
|
|
|
@@ -27,7 +31,7 @@ To use a tool, output ONLY this format — nothing else before or after the tool
|
|
|
27
31
|
</tool_call>
|
|
28
32
|
|
|
29
33
|
After receiving a tool result, continue your response naturally.
|
|
30
|
-
Available tools: Bash, Read, Write, Edit, Glob, Grep. Use them autonomously to complete tasks without asking permission.
|
|
34
|
+
Available tools: Bash, Read, Write, Edit, Glob, Grep, Note, Checkpoint. Use them autonomously to complete tasks without asking permission.
|
|
31
35
|
|
|
32
36
|
<environment>
|
|
33
37
|
- Working directory: ${cwd}
|
|
@@ -43,6 +47,8 @@ Available tools: Bash, Read, Write, Edit, Glob, Grep. Use them autonomously to c
|
|
|
43
47
|
- **Edit**: Precise string replacements. Preferred for modifying files.
|
|
44
48
|
- **Glob**: Find files by pattern.
|
|
45
49
|
- **Grep**: Search file contents by regex. Always use instead of grep in Bash.
|
|
50
|
+
- **Note**: Save a permanent discovery to project memory (.tsunami/). Use liberally for traps, patterns, architectural decisions.
|
|
51
|
+
- **Checkpoint**: Save current task progress to session memory so work is resumable if the session ends.
|
|
46
52
|
</tools>
|
|
47
53
|
|
|
48
54
|
<reasoning_protocol>
|
|
@@ -58,6 +64,27 @@ When something breaks:
|
|
|
58
64
|
4. Change one thing at a time
|
|
59
65
|
</reasoning_protocol>
|
|
60
66
|
|
|
67
|
+
<memory_protocol>
|
|
68
|
+
You have persistent memory across sessions. Use it actively.
|
|
69
|
+
|
|
70
|
+
**Note tool** — Call this when you discover anything future sessions should know:
|
|
71
|
+
- Traps and footguns: "this column stores seconds not minutes"
|
|
72
|
+
- Schema quirks: "two tables with similar names serve different purposes"
|
|
73
|
+
- Architectural decisions: "portal auth uses portal_sessions, NOT req.session"
|
|
74
|
+
- Patterns: "all routes follow this exact shape"
|
|
75
|
+
- Anything surprising you had to learn the hard way
|
|
76
|
+
|
|
77
|
+
Examples of excellent notes:
|
|
78
|
+
Note({ file_path: "/app/server/db.ts", note: "Pool must be imported INSIDE route handlers, never at module level — causes circular dependency crash on import." })
|
|
79
|
+
Note({ file_path: null, note: "CODEBASE: Two auth systems. Dashboard: req.session.userId. Portal: portal_sessions table. Never mix them." })
|
|
80
|
+
Note({ file_path: "/app/shared/schema.ts", note: "break_duration is INTEGER SECONDS not minutes. time_entries.status is 'closed' not 'clocked_out'." })
|
|
81
|
+
|
|
82
|
+
**Checkpoint tool** — Call this after each major step to preserve progress:
|
|
83
|
+
Checkpoint({ content: "Task: Add pipeline to deals.\n\nDone: schema updated, route written\nNext: wire React component\nContext: pipeline state on deals table, not leads" })
|
|
84
|
+
|
|
85
|
+
Notes persist permanently in .tsunami/memory/. Checkpoints persist for the session in ~/.tsunami-code/sessions/.
|
|
86
|
+
</memory_protocol>
|
|
87
|
+
|
|
61
88
|
<behavior>
|
|
62
89
|
- Complete tasks fully without stopping to ask unless genuinely blocked
|
|
63
90
|
- Short user messages = full autonomy, proceed immediately
|
|
@@ -74,5 +101,5 @@ When something breaks:
|
|
|
74
101
|
- Error paths as clear as success paths
|
|
75
102
|
- Parameterized queries only — never concatenate user input into SQL
|
|
76
103
|
- Every protected route: check auth at the top, first line
|
|
77
|
-
</code_quality>${context}`;
|
|
104
|
+
</code_quality>${context}${memoryContext ? `\n\n${memoryContext}` : ''}`;
|
|
78
105
|
}
|
package/lib/tools.js
CHANGED
|
@@ -3,9 +3,19 @@ import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
|
3
3
|
import { glob } from 'glob';
|
|
4
4
|
import { promisify } from 'util';
|
|
5
5
|
import { getRgPath } from './preflight.js';
|
|
6
|
+
import { addFileNote, updateContext, appendDecision } from './memory.js';
|
|
6
7
|
|
|
7
8
|
const execAsync = promisify(exec);
|
|
8
9
|
|
|
10
|
+
// ── Session Context (set by index.js at startup) ──────────────────────────────
|
|
11
|
+
let _sessionDir = null;
|
|
12
|
+
let _cwd = null;
|
|
13
|
+
|
|
14
|
+
export function setSession({ sessionDir, cwd }) {
|
|
15
|
+
_sessionDir = sessionDir;
|
|
16
|
+
_cwd = cwd;
|
|
17
|
+
}
|
|
18
|
+
|
|
9
19
|
// ── BASH ──────────────────────────────────────────────────────────────────────
|
|
10
20
|
export const BashTool = {
|
|
11
21
|
name: 'Bash',
|
|
@@ -203,4 +213,93 @@ export const GrepTool = {
|
|
|
203
213
|
}
|
|
204
214
|
};
|
|
205
215
|
|
|
206
|
-
|
|
216
|
+
// ── NOTE ──────────────────────────────────────────────────────────────────────
|
|
217
|
+
export const NoteTool = {
|
|
218
|
+
name: 'Note',
|
|
219
|
+
description: `Saves a permanent discovery to project memory (.tsunami/). Use this liberally whenever you find something future sessions must know.
|
|
220
|
+
|
|
221
|
+
WHEN TO USE (use aggressively):
|
|
222
|
+
- You discover a trap or footgun: "pool import must be inside handler, not module level — causes crash on import cycle"
|
|
223
|
+
- You find a schema quirk: "break_duration column is in SECONDS not minutes — displaying as minutes causes 60x error"
|
|
224
|
+
- You learn an architectural decision: "Two separate tables: app_settings (key/value) and settings (user prefs) — wrong one = silent fail"
|
|
225
|
+
- You understand a pattern: "All portal routes use portal_sessions table, NOT req.session — completely separate auth"
|
|
226
|
+
- You spot a gotcha: "static sitemap.xml in dist/ silently shadows the dynamic /sitemap.xml route after every build"
|
|
227
|
+
- Any decision you made that future Claude should know about
|
|
228
|
+
|
|
229
|
+
EXAMPLES of excellent notes:
|
|
230
|
+
Note({ file_path: "server/db.ts", note: "Pool import must be INSIDE each route handler function, never at module level. Import at module level causes circular dependency crash on server start." })
|
|
231
|
+
Note({ file_path: null, note: "CODEBASE: Two auth systems coexist. Internal dashboard uses req.session.userId. Buyer portal uses portal_sessions table. Never mix them." })
|
|
232
|
+
Note({ file_path: "shared/schema.ts", note: "time_entries.status uses 'closed' not 'clocked_out'. Querying for clocked_out returns nothing. break_duration is INTEGER SECONDS, not minutes." })
|
|
233
|
+
|
|
234
|
+
Set file_path to null for project-wide notes (written to CODEBASE.md).`,
|
|
235
|
+
input_schema: {
|
|
236
|
+
type: 'object',
|
|
237
|
+
properties: {
|
|
238
|
+
file_path: {
|
|
239
|
+
type: ['string', 'null'],
|
|
240
|
+
description: 'Absolute path to the file this note is about. null for project-wide notes.'
|
|
241
|
+
},
|
|
242
|
+
note: {
|
|
243
|
+
type: 'string',
|
|
244
|
+
description: 'The note content. Be specific and concrete — future sessions depend on this.'
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
required: ['note']
|
|
248
|
+
},
|
|
249
|
+
async run({ file_path, note }) {
|
|
250
|
+
try {
|
|
251
|
+
if (!_cwd) return 'Note saved (no project memory initialized yet)';
|
|
252
|
+
// Normalize: model sometimes passes "null" or "undefined" as a string
|
|
253
|
+
const fp = (!file_path || file_path === 'null' || file_path === 'undefined') ? null : file_path;
|
|
254
|
+
addFileNote(_cwd, fp, note);
|
|
255
|
+
return `Note saved to project memory${file_path ? ` for ${file_path}` : ' (CODEBASE.md)'}.`;
|
|
256
|
+
} catch (e) {
|
|
257
|
+
return `Note recorded (memory write failed silently: ${e.message})`;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// ── CHECKPOINT ────────────────────────────────────────────────────────────────
|
|
263
|
+
export const CheckpointTool = {
|
|
264
|
+
name: 'Checkpoint',
|
|
265
|
+
description: `Saves current task progress to session memory so work is resumable if the session ends.
|
|
266
|
+
|
|
267
|
+
Call this after completing each major step. If the user closes and reopens Tsunami Code, the next session will see exactly where you left off.
|
|
268
|
+
|
|
269
|
+
WHEN TO USE:
|
|
270
|
+
- After completing a significant step in a multi-step task
|
|
271
|
+
- After discovering something important about the task structure
|
|
272
|
+
- Before starting a risky operation (so progress is preserved)
|
|
273
|
+
- When switching between different aspects of a task
|
|
274
|
+
|
|
275
|
+
The content should be a clear summary of:
|
|
276
|
+
1. What the overall task is
|
|
277
|
+
2. What has been done so far
|
|
278
|
+
3. What the next step is
|
|
279
|
+
4. Any blockers or important context
|
|
280
|
+
|
|
281
|
+
EXAMPLE:
|
|
282
|
+
Checkpoint({ content: "Task: [what the user asked for]\n\nDone:\n- [steps completed so far]\n\nNext: [exact next step]\n\nContext: [any gotchas or decisions made that affect what comes next]" })`,
|
|
283
|
+
input_schema: {
|
|
284
|
+
type: 'object',
|
|
285
|
+
properties: {
|
|
286
|
+
content: {
|
|
287
|
+
type: 'string',
|
|
288
|
+
description: 'Task progress summary: what was done, what is next, key context.'
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
required: ['content']
|
|
292
|
+
},
|
|
293
|
+
async run({ content }) {
|
|
294
|
+
try {
|
|
295
|
+
if (!_sessionDir) return 'Checkpoint saved (no session initialized yet)';
|
|
296
|
+
updateContext(_sessionDir, content);
|
|
297
|
+
appendDecision(_sessionDir, `CHECKPOINT: ${content.split('\n')[0]}`);
|
|
298
|
+
return 'Checkpoint saved to session memory.';
|
|
299
|
+
} catch (e) {
|
|
300
|
+
return `Checkpoint recorded (memory write failed silently: ${e.message})`;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
export const ALL_TOOLS = [BashTool, ReadTool, WriteTool, EditTool, GlobTool, GrepTool, NoteTool, CheckpointTool];
|