tsunami-code 2.5.2 → 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.
@@ -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
- const VERSION = '2.5.2';
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(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,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
- 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 === '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 [cmd, ...rest] = line.slice(1).split(' ');
165
- switch (cmd.toLowerCase()) {
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
- process.exit(0);
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: line }
340
+ { role: 'user', content: userContent }
203
341
  ];
204
- messages.push({ role: 'user', content: line });
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 = fullMessages.slice(1);
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
- console.log(dim(' Goodbye.\n'));
235
- process.exit(0);
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
- console.log(dim('\n Goodbye.\n'));
244
- process.exit(0);
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
- async function runTool(name, args) {
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
- return await tool.run(normalizeArgs(parsed));
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
- async function streamCompletion(serverUrl, messages, onToken) {
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
- export async function agentLoop(serverUrl, messages, onToken, onToolCall, maxIterations = 15) {
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
- const content = await streamCompletion(serverUrl, messages, onToken);
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) break;
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({