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.
@@ -0,0 +1,8 @@
1
+ # Project Notes
2
+
3
+
4
+ ## [2026-04-03T06:20:55.390Z]
5
+ Project version is 2.6.0
6
+
7
+ ## [2026-04-03T06:24:24.305Z]
8
+ schema.ts file not found
@@ -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.
@@ -0,0 +1,8 @@
1
+ # Memory: package.json
2
+
3
+ ## Notes
4
+
5
+ ## Changes
6
+
7
+ ## Access Log
8
+ - [2026-04-03T06:20:04.011Z] Read
@@ -0,0 +1,8 @@
1
+ # Memory: schema.ts
2
+
3
+ ## Notes
4
+
5
+ ## Changes
6
+
7
+ ## Access Log
8
+ - [2026-04-03T06:23:17.357Z] Read
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
- const VERSION = '2.5.3';
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(`keystonecli v${VERSION}`);
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(green(' ✓ Connected') + dim(' · Type your task. /help for commands. Ctrl+C to exit.\n'));
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
- console.log(dim('\n Goodbye.\n'));
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 [cmd, ...rest] = line.slice(1).split(' ');
165
- switch (cmd.toLowerCase()) {
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
- process.exit(0);
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: line }
353
+ { role: 'user', content: userContent }
203
354
  ];
204
- messages.push({ role: 'user', content: line });
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 = fullMessages.slice(1);
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
- console.log(dim(' Goodbye.\n'));
235
- process.exit(0);
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
- console.log(dim('\n Goodbye.\n'));
244
- process.exit(0);
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
- async function runTool(name, args) {
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
- return await tool.run(normalizeArgs(parsed));
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 = 10, delay = 2000) {
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, 5, 1000);
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
- export async function agentLoop(serverUrl, messages, onToken, onToolCall, maxIterations = 15) {
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
- export function buildSystemPrompt() {
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
- export const ALL_TOOLS = [BashTool, ReadTool, WriteTool, EditTool, GlobTool, GrepTool];
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];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsunami-code",
3
- "version": "2.5.3",
3
+ "version": "2.6.1",
4
4
  "description": "Tsunami Code CLI — AI coding agent by Keystone World Management Navy Seal Unit XI3",
5
5
  "type": "module",
6
6
  "bin": {