tsunami-code 2.5.3 → 2.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/.tsunami/README.md +10 -0
- package/index.js +163 -17
- package/lib/loop.js +124 -8
- package/lib/memory.js +493 -0
- package/lib/prompt.js +30 -3
- package/lib/tools.js +98 -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.0';
|
|
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
|
`);
|
|
@@ -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,108 @@ 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 === 'clear') {
|
|
259
|
+
// Reinitialize session (clear session memory, keep project memory)
|
|
260
|
+
resetSession();
|
|
261
|
+
console.log(green(' Session memory cleared. Project memory preserved.\n'));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (sub === 'context') {
|
|
266
|
+
// Debug: show current session context
|
|
267
|
+
const ctx = getSessionContext(sessionDir);
|
|
268
|
+
const decisions = getRecentDecisions(sessionDir, 10);
|
|
269
|
+
console.log(blue('\n Current Session Context:'));
|
|
270
|
+
console.log(dim(' ' + (ctx || '(empty)').replace(/\n/g, '\n ')));
|
|
271
|
+
console.log(blue('\n Recent Decisions:'));
|
|
272
|
+
console.log(dim(' ' + (decisions || '(none)').replace(/\n/g, '\n ')));
|
|
273
|
+
console.log();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
console.log(red(` Unknown memory subcommand: ${sub}\n Try: /memory, /memory files, /memory view <file>, /memory clear\n`));
|
|
278
|
+
}
|
|
279
|
+
|
|
159
280
|
rl.on('line', async (input) => {
|
|
160
281
|
const line = input.trim();
|
|
161
282
|
if (!line) { rl.prompt(); return; }
|
|
162
283
|
|
|
163
284
|
if (line.startsWith('/')) {
|
|
164
|
-
const
|
|
165
|
-
|
|
285
|
+
const parts = line.slice(1).split(' ');
|
|
286
|
+
const cmd = parts[0].toLowerCase();
|
|
287
|
+
const rest = parts.slice(1);
|
|
288
|
+
|
|
289
|
+
switch (cmd) {
|
|
166
290
|
case 'help':
|
|
167
291
|
console.log(dim(`
|
|
168
292
|
Commands:
|
|
169
293
|
/clear Start new conversation
|
|
170
294
|
/status Show context size
|
|
171
295
|
/server <url> Change server URL for this session
|
|
296
|
+
/memory Show memory stats
|
|
297
|
+
/memory files List files with memory entries
|
|
298
|
+
/memory view <f> Show memory for a file
|
|
299
|
+
/memory clear Clear session memory (keep project memory)
|
|
172
300
|
/exit Exit
|
|
173
301
|
`));
|
|
174
302
|
break;
|
|
@@ -187,8 +315,12 @@ async function run() {
|
|
|
187
315
|
console.log(dim(` Current server: ${currentServerUrl}\n`));
|
|
188
316
|
}
|
|
189
317
|
break;
|
|
318
|
+
case 'memory':
|
|
319
|
+
await handleMemoryCommand(rest);
|
|
320
|
+
break;
|
|
190
321
|
case 'exit': case 'quit':
|
|
191
|
-
|
|
322
|
+
gracefulExit(0);
|
|
323
|
+
return;
|
|
192
324
|
default:
|
|
193
325
|
console.log(red(` Unknown command: /${cmd}\n`));
|
|
194
326
|
}
|
|
@@ -196,12 +328,18 @@ async function run() {
|
|
|
196
328
|
return;
|
|
197
329
|
}
|
|
198
330
|
|
|
331
|
+
// Build the user message, optionally injecting last session summary on first turn
|
|
332
|
+
let userContent = line;
|
|
333
|
+
if (messages.length === 0 && lastSessionSummary) {
|
|
334
|
+
userContent = `[Previous session summary]\n${lastSessionSummary}\n\n---\n\n${line}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
199
337
|
const fullMessages = [
|
|
200
338
|
{ role: 'system', content: systemPrompt },
|
|
201
339
|
...messages,
|
|
202
|
-
{ role: 'user', content:
|
|
340
|
+
{ role: 'user', content: userContent }
|
|
203
341
|
];
|
|
204
|
-
messages.push({ role: 'user', content:
|
|
342
|
+
messages.push({ role: 'user', content: userContent });
|
|
205
343
|
|
|
206
344
|
rl.pause();
|
|
207
345
|
isProcessing = true;
|
|
@@ -219,10 +357,15 @@ async function run() {
|
|
|
219
357
|
(toolName, toolArgs) => {
|
|
220
358
|
printToolCall(toolName, toolArgs);
|
|
221
359
|
firstToken = true;
|
|
222
|
-
}
|
|
360
|
+
},
|
|
361
|
+
{ sessionDir, cwd }
|
|
223
362
|
);
|
|
224
363
|
|
|
225
|
-
messages
|
|
364
|
+
// Update messages from the loop (fullMessages was mutated by agentLoop)
|
|
365
|
+
// Trim to rolling window: keep system + last 8 entries
|
|
366
|
+
const loopMessages = fullMessages.slice(1); // drop system, agentLoop added to fullMessages
|
|
367
|
+
messages = trimMessages([{ role: 'system', content: systemPrompt }, ...loopMessages]).slice(1);
|
|
368
|
+
|
|
226
369
|
process.stdout.write('\n\n');
|
|
227
370
|
} catch (e) {
|
|
228
371
|
process.stdout.write('\n');
|
|
@@ -231,8 +374,8 @@ async function run() {
|
|
|
231
374
|
|
|
232
375
|
isProcessing = false;
|
|
233
376
|
if (pendingClose) {
|
|
234
|
-
|
|
235
|
-
|
|
377
|
+
gracefulExit(0);
|
|
378
|
+
return;
|
|
236
379
|
}
|
|
237
380
|
rl.resume();
|
|
238
381
|
rl.prompt();
|
|
@@ -240,8 +383,11 @@ async function run() {
|
|
|
240
383
|
|
|
241
384
|
process.on('SIGINT', () => {
|
|
242
385
|
if (!isProcessing) {
|
|
243
|
-
|
|
244
|
-
|
|
386
|
+
gracefulExit(0);
|
|
387
|
+
} else {
|
|
388
|
+
// If processing, mark pending and let the loop finish
|
|
389
|
+
pendingClose = true;
|
|
390
|
+
console.log(dim('\n (finishing current operation...)\n'));
|
|
245
391
|
}
|
|
246
392
|
});
|
|
247
393
|
}
|
package/lib/loop.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import fetch from 'node-fetch';
|
|
2
2
|
import { ALL_TOOLS } from './tools.js';
|
|
3
|
+
import {
|
|
4
|
+
assembleContext,
|
|
5
|
+
extractFilePaths,
|
|
6
|
+
logFileAccess,
|
|
7
|
+
logFileChange,
|
|
8
|
+
appendDecision
|
|
9
|
+
} from './memory.js';
|
|
3
10
|
|
|
4
11
|
// Parse tool calls from any format the model might produce
|
|
5
12
|
function parseToolCalls(content) {
|
|
@@ -81,12 +88,60 @@ function normalizeArgs(args) {
|
|
|
81
88
|
return out;
|
|
82
89
|
}
|
|
83
90
|
|
|
84
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Detect key patterns in Bash commands and return a decision string, or null.
|
|
93
|
+
* We log psql, npm, pm2, git, and deploy-related commands.
|
|
94
|
+
*/
|
|
95
|
+
function bashDecisionHint(cmd) {
|
|
96
|
+
if (!cmd) return null;
|
|
97
|
+
const c = cmd.trim();
|
|
98
|
+
if (/psql\s/.test(c)) return `DB: ${c.slice(0, 120)}`;
|
|
99
|
+
if (/npm\s+(install|run|publish|build)/.test(c)) return `NPM: ${c.slice(0, 120)}`;
|
|
100
|
+
if (/pm2\s+(restart|stop|start|delete|reload)/.test(c)) return `PM2: ${c.slice(0, 120)}`;
|
|
101
|
+
if (/git\s+(commit|push|checkout|merge|rebase|reset)/.test(c)) return `GIT: ${c.slice(0, 120)}`;
|
|
102
|
+
if (/ssh\s+/.test(c)) return `SSH: ${c.slice(0, 80)}`;
|
|
103
|
+
if (/scp\s+/.test(c)) return `SCP: ${c.slice(0, 80)}`;
|
|
104
|
+
if (/rm\s+-rf|DROP TABLE|ALTER TABLE|CREATE TABLE/.test(c)) return `DESTRUCTIVE: ${c.slice(0, 120)}`;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function runTool(name, args, sessionInfo) {
|
|
85
109
|
const tool = ALL_TOOLS.find(t => t.name === name);
|
|
86
110
|
if (!tool) return `Error: Unknown tool "${name}"`;
|
|
87
111
|
try {
|
|
88
112
|
const parsed = typeof args === 'string' ? JSON.parse(args) : args;
|
|
89
|
-
|
|
113
|
+
const normalized = normalizeArgs(parsed);
|
|
114
|
+
|
|
115
|
+
// Auto-capture memory BEFORE running the tool (for access/change logging)
|
|
116
|
+
if (sessionInfo) {
|
|
117
|
+
const { sessionDir, cwd } = sessionInfo;
|
|
118
|
+
try {
|
|
119
|
+
if (name === 'Read' && normalized.file_path) {
|
|
120
|
+
logFileAccess(cwd, normalized.file_path);
|
|
121
|
+
}
|
|
122
|
+
} catch {}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const result = await tool.run(normalized);
|
|
126
|
+
|
|
127
|
+
// Auto-capture memory AFTER running the tool
|
|
128
|
+
if (sessionInfo) {
|
|
129
|
+
const { sessionDir, cwd } = sessionInfo;
|
|
130
|
+
try {
|
|
131
|
+
if (name === 'Write' && normalized.file_path) {
|
|
132
|
+
const lines = (normalized.content || '').split('\n').length;
|
|
133
|
+
logFileChange(cwd, normalized.file_path, `Written ${lines} lines`);
|
|
134
|
+
} else if (name === 'Edit' && normalized.file_path) {
|
|
135
|
+
const preview = (normalized.old_string || '').slice(0, 60).replace(/\n/g, '↵');
|
|
136
|
+
logFileChange(cwd, normalized.file_path, `Edited: replaced "${preview}"`);
|
|
137
|
+
} else if (name === 'Bash') {
|
|
138
|
+
const hint = bashDecisionHint(normalized.command);
|
|
139
|
+
if (hint) appendDecision(sessionDir, hint);
|
|
140
|
+
}
|
|
141
|
+
} catch {}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return result;
|
|
90
145
|
} catch (e) {
|
|
91
146
|
return `Error executing ${name}: ${e.message}`;
|
|
92
147
|
}
|
|
@@ -104,14 +159,30 @@ async function waitForServer(serverUrl, retries = 10, delay = 2000) {
|
|
|
104
159
|
throw new Error(`Model server not responding at ${serverUrl}`);
|
|
105
160
|
}
|
|
106
161
|
|
|
107
|
-
|
|
162
|
+
/**
|
|
163
|
+
* Stream a completion from the model server.
|
|
164
|
+
* Injects memoryContext into the system message if provided.
|
|
165
|
+
*/
|
|
166
|
+
async function streamCompletion(serverUrl, messages, onToken, memoryContext = '') {
|
|
108
167
|
await waitForServer(serverUrl, 5, 1000);
|
|
168
|
+
|
|
169
|
+
// Inject memory context into the system message (first message with role=system)
|
|
170
|
+
let finalMessages = messages;
|
|
171
|
+
if (memoryContext) {
|
|
172
|
+
finalMessages = messages.map((msg, idx) => {
|
|
173
|
+
if (idx === 0 && msg.role === 'system') {
|
|
174
|
+
return { ...msg, content: msg.content + memoryContext };
|
|
175
|
+
}
|
|
176
|
+
return msg;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
109
180
|
const res = await fetch(`${serverUrl}/v1/chat/completions`, {
|
|
110
181
|
method: 'POST',
|
|
111
182
|
headers: { 'Content-Type': 'application/json' },
|
|
112
183
|
body: JSON.stringify({
|
|
113
184
|
model: 'local',
|
|
114
|
-
messages,
|
|
185
|
+
messages: finalMessages,
|
|
115
186
|
stream: true,
|
|
116
187
|
temperature: 0.1,
|
|
117
188
|
max_tokens: 4096,
|
|
@@ -146,20 +217,65 @@ async function streamCompletion(serverUrl, messages, onToken) {
|
|
|
146
217
|
return fullContent;
|
|
147
218
|
}
|
|
148
219
|
|
|
149
|
-
|
|
220
|
+
/**
|
|
221
|
+
* Main agent loop.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} serverUrl
|
|
224
|
+
* @param {Array} messages — full message array including system prompt at index 0
|
|
225
|
+
* @param {Function} onToken
|
|
226
|
+
* @param {Function} onToolCall
|
|
227
|
+
* @param {{ sessionDir: string, cwd: string } | null} sessionInfo
|
|
228
|
+
* @param {number} maxIterations
|
|
229
|
+
*/
|
|
230
|
+
export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessionInfo = null, maxIterations = 15) {
|
|
231
|
+
// Extract the current task from the last user message for context assembly
|
|
232
|
+
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
|
|
233
|
+
const currentTask = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '';
|
|
234
|
+
|
|
150
235
|
for (let i = 0; i < maxIterations; i++) {
|
|
151
|
-
|
|
236
|
+
// Assemble memory context before each model call
|
|
237
|
+
let memoryContext = '';
|
|
238
|
+
if (sessionInfo) {
|
|
239
|
+
try {
|
|
240
|
+
const filesToConsider = extractFilePaths(currentTask);
|
|
241
|
+
memoryContext = assembleContext({
|
|
242
|
+
sessionDir: sessionInfo.sessionDir,
|
|
243
|
+
cwd: sessionInfo.cwd,
|
|
244
|
+
currentTask,
|
|
245
|
+
filesToConsider
|
|
246
|
+
});
|
|
247
|
+
} catch {}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const content = await streamCompletion(serverUrl, messages, onToken, memoryContext);
|
|
152
251
|
const toolCalls = parseToolCalls(content);
|
|
153
252
|
|
|
154
253
|
messages.push({ role: 'assistant', content });
|
|
155
254
|
|
|
156
|
-
if (toolCalls.length === 0)
|
|
255
|
+
if (toolCalls.length === 0) {
|
|
256
|
+
// Log final assistant response as a decision if it's substantive
|
|
257
|
+
if (sessionInfo && content.length > 50) {
|
|
258
|
+
try {
|
|
259
|
+
const summary = content.slice(0, 150).replace(/\n/g, ' ');
|
|
260
|
+
appendDecision(sessionInfo.sessionDir, `RESPONSE: ${summary}`);
|
|
261
|
+
} catch {}
|
|
262
|
+
}
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
157
265
|
|
|
158
266
|
const results = [];
|
|
159
267
|
for (const tc of toolCalls) {
|
|
160
268
|
onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
|
|
161
|
-
const result = await runTool(tc.name, tc.arguments);
|
|
269
|
+
const result = await runTool(tc.name, tc.arguments, sessionInfo);
|
|
162
270
|
results.push(`[${tc.name} result]\n${String(result).slice(0, 8000)}`);
|
|
271
|
+
|
|
272
|
+
// Log tool execution as a decision entry
|
|
273
|
+
if (sessionInfo) {
|
|
274
|
+
try {
|
|
275
|
+
const argsPreview = JSON.stringify(tc.arguments || {}).slice(0, 80);
|
|
276
|
+
appendDecision(sessionInfo.sessionDir, `TOOL ${tc.name}: ${argsPreview}`);
|
|
277
|
+
} catch {}
|
|
278
|
+
}
|
|
163
279
|
}
|
|
164
280
|
|
|
165
281
|
messages.push({
|
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,91 @@ 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
|
+
addFileNote(_cwd, file_path || null, note);
|
|
253
|
+
return `Note saved to project memory${file_path ? ` for ${file_path}` : ' (CODEBASE.md)'}.`;
|
|
254
|
+
} catch (e) {
|
|
255
|
+
return `Note recorded (memory write failed silently: ${e.message})`;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// ── CHECKPOINT ────────────────────────────────────────────────────────────────
|
|
261
|
+
export const CheckpointTool = {
|
|
262
|
+
name: 'Checkpoint',
|
|
263
|
+
description: `Saves current task progress to session memory so work is resumable if the session ends.
|
|
264
|
+
|
|
265
|
+
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.
|
|
266
|
+
|
|
267
|
+
WHEN TO USE:
|
|
268
|
+
- After completing a significant step in a multi-step task
|
|
269
|
+
- After discovering something important about the task structure
|
|
270
|
+
- Before starting a risky operation (so progress is preserved)
|
|
271
|
+
- When switching between different aspects of a task
|
|
272
|
+
|
|
273
|
+
The content should be a clear summary of:
|
|
274
|
+
1. What the overall task is
|
|
275
|
+
2. What has been done so far
|
|
276
|
+
3. What the next step is
|
|
277
|
+
4. Any blockers or important context
|
|
278
|
+
|
|
279
|
+
EXAMPLE:
|
|
280
|
+
Checkpoint({ content: "Task: Add dispo pipeline tracking to leads table.\n\nDone:\n- Added status column (MATCHING/CONTACTED/INTERESTED/WALKTHROUGH/CONTRACTED/CLOSED)\n- Updated shared/schema.ts with new enum\n- Route GET /api/deals/pipeline written and tested\n\nNext: Wire up the React frontend pipeline component to the new route.\n\nContext: The deals table has a lead_id FK — pipeline state lives on deals, not leads." })`,
|
|
281
|
+
input_schema: {
|
|
282
|
+
type: 'object',
|
|
283
|
+
properties: {
|
|
284
|
+
content: {
|
|
285
|
+
type: 'string',
|
|
286
|
+
description: 'Task progress summary: what was done, what is next, key context.'
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
required: ['content']
|
|
290
|
+
},
|
|
291
|
+
async run({ content }) {
|
|
292
|
+
try {
|
|
293
|
+
if (!_sessionDir) return 'Checkpoint saved (no session initialized yet)';
|
|
294
|
+
updateContext(_sessionDir, content);
|
|
295
|
+
appendDecision(_sessionDir, `CHECKPOINT: ${content.split('\n')[0]}`);
|
|
296
|
+
return 'Checkpoint saved to session memory.';
|
|
297
|
+
} catch (e) {
|
|
298
|
+
return `Checkpoint recorded (memory write failed silently: ${e.message})`;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
export const ALL_TOOLS = [BashTool, ReadTool, WriteTool, EditTool, GlobTool, GrepTool, NoteTool, CheckpointTool];
|