tsunami-code 2.6.0 → 2.7.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,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,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
@@ -4,10 +4,10 @@ import chalk from 'chalk';
4
4
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
5
5
  import { join } from 'path';
6
6
  import os from 'os';
7
- import { agentLoop } from './lib/loop.js';
7
+ import { agentLoop, quickCompletion } from './lib/loop.js';
8
8
  import { buildSystemPrompt } from './lib/prompt.js';
9
- import { runPreflight } from './lib/preflight.js';
10
- import { setSession } from './lib/tools.js';
9
+ import { runPreflight, checkServer } from './lib/preflight.js';
10
+ import { setSession, undo, undoStackSize } from './lib/tools.js';
11
11
  import {
12
12
  initSession,
13
13
  initProjectMemory,
@@ -20,7 +20,7 @@ import {
20
20
  getSessionContext
21
21
  } from './lib/memory.js';
22
22
 
23
- const VERSION = '2.6.0';
23
+ const VERSION = '2.7.0';
24
24
  const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
25
25
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
26
26
  const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
@@ -115,7 +115,7 @@ if (argv.includes('--help') || argv.includes('-h')) {
115
115
  }
116
116
 
117
117
  if (argv.includes('--version') || argv.includes('-v')) {
118
- console.log(`keystonecli v${VERSION}`);
118
+ console.log(`tsunami v${VERSION}`);
119
119
  process.exit(0);
120
120
  }
121
121
 
@@ -129,10 +129,29 @@ if (setServerIdx !== -1 && argv[setServerIdx + 1]) {
129
129
  process.exit(0);
130
130
  }
131
131
 
132
+ // ── Confirm Callback (dangerous command prompt) ─────────────────────────────
133
+ function makeConfirmCallback(rl) {
134
+ return async (cmd) => {
135
+ return new Promise((resolve) => {
136
+ rl.pause();
137
+ process.stdout.write(`\n ${yellow('⚠ Dangerous:')} ${dim(cmd.slice(0, 120))}\n`);
138
+ process.stdout.write(` ${yellow('Proceed?')} ${dim('(y/N) ')}`);
139
+ const handler = (data) => {
140
+ process.stdin.removeListener('data', handler);
141
+ rl.resume();
142
+ process.stdout.write('\n');
143
+ resolve(data.toString().trim().toLowerCase() === 'y');
144
+ };
145
+ process.stdin.once('data', handler);
146
+ });
147
+ };
148
+ }
149
+
132
150
  // ── Main ──────────────────────────────────────────────────────────────────────
133
151
  async function run() {
134
152
  const serverUrl = getServerUrl(argv);
135
153
  const cwd = process.cwd();
154
+ let planMode = argv.includes('--plan');
136
155
 
137
156
  // Initialize memory systems
138
157
  const { sessionId, sessionDir } = initSession(cwd);
@@ -168,6 +187,39 @@ async function run() {
168
187
  let currentServerUrl = serverUrl;
169
188
  let messages = [];
170
189
  let systemPrompt = buildSystemPrompt();
190
+ let _inputTokens = 0;
191
+ let _outputTokens = 0;
192
+
193
+ // --resume: inject last session summary
194
+ if (argv.includes('--resume')) {
195
+ const lastSummary = getLastSessionSummary(cwd);
196
+ if (lastSummary) {
197
+ console.log(dim(' Resuming from last session...\n'));
198
+ messages.push({ role: 'user', content: `[Resuming]\n\n${lastSummary}` });
199
+ messages.push({ role: 'assistant', content: 'Understood, I have the previous session context. Ready to continue.' });
200
+ }
201
+ }
202
+
203
+ // Compact messages helper
204
+ async function compactMessages(focus = '') {
205
+ if (messages.length < 3) { console.log(dim(' Nothing substantial to compact.\n')); return; }
206
+ process.stdout.write(dim(' ↯ Compacting...'));
207
+ const historyText = messages
208
+ .filter(m => !['system'].includes(m.role))
209
+ .map(m => {
210
+ const c = typeof m.content === 'string' ? m.content.slice(0, 250) : '[tool result]';
211
+ return `${m.role.toUpperCase()}: ${c}`;
212
+ }).join('\n\n');
213
+ const focusLine = focus ? ` Focus on: ${focus}.` : '';
214
+ const prompt = `Summarize this conversation in 4-6 bullet points. Preserve: goal, files changed, key decisions, current progress, next step.${focusLine}\n\n${historyText}`;
215
+ const summary = await quickCompletion(currentServerUrl, buildSystemPrompt(), prompt);
216
+ const before = messages.length;
217
+ messages = summary
218
+ ? [{ role: 'user', content: `[Compacted — ${before} messages]\n\n${summary}` }, { role: 'assistant', content: 'Understood, continuing.' }]
219
+ : messages.slice(-4);
220
+ process.stdout.write('\r' + ' '.repeat(25) + '\r');
221
+ console.log(green(' ✓ Compacted') + dim(` ${before} → ${messages.length} messages\n`));
222
+ }
171
223
 
172
224
  function resetSession() {
173
225
  messages = [];
@@ -186,7 +238,7 @@ async function run() {
186
238
  const rl = readline.createInterface({
187
239
  input: process.stdin,
188
240
  output: process.stdout,
189
- prompt: cyan('❯ '),
241
+ prompt: planMode ? yellow('❯ [plan] ') : cyan('❯ '),
190
242
  terminal: process.stdin.isTTY
191
243
  });
192
244
 
@@ -255,6 +307,19 @@ async function run() {
255
307
  return;
256
308
  }
257
309
 
310
+ if (sub === 'last') {
311
+ const { getLastSessionSummary } = await import('./lib/memory.js');
312
+ const summary = getLastSessionSummary(cwd);
313
+ if (!summary) {
314
+ console.log(dim(' No previous session summary found.\n'));
315
+ } else {
316
+ console.log(blue('\n Last Session Summary'));
317
+ console.log(dim(' ' + summary.replace(/\n/g, '\n ')));
318
+ console.log();
319
+ }
320
+ return;
321
+ }
322
+
258
323
  if (sub === 'clear') {
259
324
  // Reinitialize session (clear session memory, keep project memory)
260
325
  resetSession();
@@ -288,17 +353,74 @@ async function run() {
288
353
 
289
354
  switch (cmd) {
290
355
  case 'help':
291
- console.log(dim(`
292
- Commands:
293
- /clear Start new conversation
294
- /status Show context size
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)
300
- /exit Exit
301
- `));
356
+ console.log(blue('\n Tsunami Code CLI — Commands\n'));
357
+ {
358
+ const cmds = [
359
+ ['/compact [focus]', 'Summarize context and continue'],
360
+ ['/plan', 'Toggle read-only plan mode'],
361
+ ['/undo', 'Undo last file change'],
362
+ ['/doctor', 'Run health diagnostics'],
363
+ ['/cost', 'Show token usage estimate'],
364
+ ['/memory', 'Show project memory stats'],
365
+ ['/memory files', 'List files with memory'],
366
+ ['/memory view <f>', 'Show memory for a file'],
367
+ ['/memory last', 'Show last session summary'],
368
+ ['/memory clear', 'Clear session memory'],
369
+ ['/clear', 'Start new conversation'],
370
+ ['/status', 'Show context size and server'],
371
+ ['/server <url>', 'Change model server URL'],
372
+ ['/exit', 'Exit'],
373
+ ];
374
+ for (const [c, desc] of cmds) {
375
+ console.log(` ${cyan(c.padEnd(22))} ${dim(desc)}`);
376
+ }
377
+ console.log();
378
+ }
379
+ break;
380
+ case 'compact':
381
+ await compactMessages(rest.join(' '));
382
+ break;
383
+ case 'plan':
384
+ planMode = !planMode;
385
+ if (planMode) console.log(yellow(' Plan mode ON — read-only, no writes or execution.\n'));
386
+ else console.log(green(' Plan mode OFF — full capabilities restored.\n'));
387
+ rl.setPrompt(planMode ? yellow('❯ [plan] ') : cyan('❯ '));
388
+ break;
389
+ case 'undo': {
390
+ const restored = undo();
391
+ if (restored) console.log(green(` ✓ Restored: ${restored}\n`));
392
+ else console.log(dim(' Nothing to undo.\n'));
393
+ break;
394
+ }
395
+ case 'doctor': {
396
+ const { getRgPath } = await import('./lib/preflight.js');
397
+ const { getMemoryStats: _getMemoryStats } = await import('./lib/memory.js');
398
+
399
+ const nodeVer = process.versions.node;
400
+ const nodeMajor = parseInt(nodeVer.split('.')[0]);
401
+ const rgPath = getRgPath();
402
+ const stats = _getMemoryStats(cwd);
403
+ const hasTsunami = existsSync(join(cwd, '.tsunami'));
404
+ const hasTsunamiMd = existsSync(join(cwd, 'TSUNAMI.md'));
405
+ const serverOk = await checkServer(currentServerUrl).catch(() => false);
406
+
407
+ console.log(blue('\n 🩺 Tsunami Code Diagnostics\n'));
408
+ console.log((nodeMajor >= 18 ? green : red)(` ${nodeMajor >= 18 ? '✓' : '✗'} Node.js v${nodeVer}`));
409
+ console.log((serverOk ? green : red)(` ${serverOk ? '✓' : '✗'} Server: ${currentServerUrl}`));
410
+ console.log((rgPath ? green : yellow)(` ${rgPath ? '✓' : '⚠'} ripgrep: ${rgPath || 'not found'}`));
411
+ console.log((hasTsunami ? green : dim)(` ${hasTsunami ? '✓' : '○'} Project memory: ${hasTsunami ? `.tsunami/ (${stats.projectMemoryFiles} files, ${formatBytes(stats.projectMemorySize)})` : 'none'}`));
412
+ console.log((hasTsunamiMd ? green : yellow)(` ${hasTsunamiMd ? '✓' : '⚠'} TSUNAMI.md: ${hasTsunamiMd ? 'found' : 'not found (add one for project instructions)'}`));
413
+ console.log(dim(`\n Session : ${sessionId}`));
414
+ console.log(dim(` Version : tsunami-code v${VERSION}`));
415
+ console.log(dim(` CWD : ${cwd}\n`));
416
+ break;
417
+ }
418
+ case 'cost':
419
+ console.log(blue('\n Session Token Estimate'));
420
+ console.log(dim(` Input : ~${_inputTokens.toLocaleString()}`));
421
+ console.log(dim(` Output : ~${_outputTokens.toLocaleString()}`));
422
+ console.log(dim(` Total : ~${(_inputTokens + _outputTokens).toLocaleString()}`));
423
+ console.log(dim(' (Estimates only)\n'));
302
424
  break;
303
425
  case 'clear':
304
426
  resetSession();
@@ -358,14 +480,27 @@ async function run() {
358
480
  printToolCall(toolName, toolArgs);
359
481
  firstToken = true;
360
482
  },
361
- { sessionDir, cwd }
483
+ { sessionDir, cwd, planMode },
484
+ makeConfirmCallback(rl)
362
485
  );
363
486
 
487
+ // Token estimation
488
+ const inputChars = fullMessages.reduce((s, m) => s + (typeof m.content === 'string' ? m.content.length : 0), 0);
489
+ _inputTokens += Math.round(inputChars / 4);
490
+ const outputChars = fullMessages.filter(m => m.role === 'assistant').reduce((s, m) => s + (m.content?.length || 0), 0);
491
+ _outputTokens = Math.round(outputChars / 4);
492
+
364
493
  // Update messages from the loop (fullMessages was mutated by agentLoop)
365
494
  // Trim to rolling window: keep system + last 8 entries
366
495
  const loopMessages = fullMessages.slice(1); // drop system, agentLoop added to fullMessages
367
496
  messages = trimMessages([{ role: 'system', content: systemPrompt }, ...loopMessages]).slice(1);
368
497
 
498
+ // Auto-compact
499
+ if (messages.length > 24) {
500
+ await compactMessages();
501
+ console.log(dim(' ↯ Auto-compacted\n'));
502
+ }
503
+
369
504
  process.stdout.write('\n\n');
370
505
  } catch (e) {
371
506
  process.stdout.write('\n');
package/lib/loop.js CHANGED
@@ -3,11 +3,32 @@ import { ALL_TOOLS } from './tools.js';
3
3
  import {
4
4
  assembleContext,
5
5
  extractFilePaths,
6
- logFileAccess,
7
6
  logFileChange,
8
7
  appendDecision
9
8
  } from './memory.js';
10
9
 
10
+ // ── Dangerous command detection ──────────────────────────────────────────────
11
+ const DANGEROUS_PATTERNS = [
12
+ /rm\s+-[rf]+\s+[^-]/,
13
+ /DROP\s+TABLE/i,
14
+ /DROP\s+DATABASE/i,
15
+ /DROP\s+SCHEMA/i,
16
+ /ALTER\s+TABLE.*DROP\s+COLUMN/i,
17
+ /git\s+push\s+.*--force/,
18
+ /git\s+push\s+-f\b/,
19
+ /git\s+reset\s+--hard/,
20
+ /del\s+\/[sf]/i,
21
+ /Remove-Item.*-Recurse.*-Force/i,
22
+ /truncate\s+table/i,
23
+ /delete\s+from\s+\w+\s*;?\s*$/i,
24
+ ];
25
+ function isDangerous(cmd) {
26
+ return DANGEROUS_PATTERNS.some(p => p.test(cmd || ''));
27
+ }
28
+
29
+ // Skip waitForServer after first successful connection
30
+ let _serverVerified = false;
31
+
11
32
  // Parse tool calls from any format the model might produce
12
33
  function parseToolCalls(content) {
13
34
  const calls = [];
@@ -88,10 +109,6 @@ function normalizeArgs(args) {
88
109
  return out;
89
110
  }
90
111
 
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
112
  function bashDecisionHint(cmd) {
96
113
  if (!cmd) return null;
97
114
  const c = cmd.trim();
@@ -105,26 +122,31 @@ function bashDecisionHint(cmd) {
105
122
  return null;
106
123
  }
107
124
 
108
- async function runTool(name, args, sessionInfo) {
125
+ // Only log decisions that are actually meaningful
126
+ function meaningfulDecision(toolName, args) {
127
+ if (toolName === 'Note') return `NOTE: ${(args.note || '').slice(0, 120)}`;
128
+ if (toolName === 'Checkpoint') return `CHECKPOINT: ${(args.content || '').split('\n')[0].slice(0, 120)}`;
129
+ if (toolName === 'Write') return `WROTE: ${args.file_path} (${(args.content || '').split('\n').length} lines)`;
130
+ if (toolName === 'Edit') return `EDITED: ${args.file_path} — replaced "${(args.old_string || '').slice(0, 60).replace(/\n/g, '↵')}"`;
131
+ if (toolName === 'Bash') return bashDecisionHint(args.command);
132
+ return null;
133
+ }
134
+
135
+ async function runTool(name, args, sessionInfo, sessionFiles) {
109
136
  const tool = ALL_TOOLS.find(t => t.name === name);
110
137
  if (!tool) return `Error: Unknown tool "${name}"`;
111
138
  try {
112
139
  const parsed = typeof args === 'string' ? JSON.parse(args) : args;
113
140
  const normalized = normalizeArgs(parsed);
114
141
 
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
142
  const result = await tool.run(normalized);
126
143
 
127
- // Auto-capture memory AFTER running the tool
144
+ // Auto-capture: track files touched for context assembly
145
+ if (sessionFiles && normalized.file_path) {
146
+ sessionFiles.add(normalized.file_path);
147
+ }
148
+
149
+ // Auto-capture: write/edit → log to project memory
128
150
  if (sessionInfo) {
129
151
  const { sessionDir, cwd } = sessionInfo;
130
152
  try {
@@ -134,10 +156,10 @@ async function runTool(name, args, sessionInfo) {
134
156
  } else if (name === 'Edit' && normalized.file_path) {
135
157
  const preview = (normalized.old_string || '').slice(0, 60).replace(/\n/g, '↵');
136
158
  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
159
  }
160
+ // Log only meaningful decisions — not every Read/Glob/Grep
161
+ const decision = meaningfulDecision(name, normalized);
162
+ if (decision) appendDecision(sessionDir, decision);
141
163
  } catch {}
142
164
  }
143
165
 
@@ -147,24 +169,20 @@ async function runTool(name, args, sessionInfo) {
147
169
  }
148
170
  }
149
171
 
150
- async function waitForServer(serverUrl, retries = 10, delay = 2000) {
172
+ async function waitForServer(serverUrl, retries = 5, delay = 1000) {
151
173
  for (let i = 0; i < retries; i++) {
152
174
  try {
153
175
  const r = await fetch(`${serverUrl}/health`);
154
176
  const j = await r.json();
155
- if (j.status === 'ok') return;
177
+ if (j.status === 'ok') { _serverVerified = true; return; }
156
178
  } catch {}
157
179
  await new Promise(r => setTimeout(r, delay));
158
180
  }
159
181
  throw new Error(`Model server not responding at ${serverUrl}`);
160
182
  }
161
183
 
162
- /**
163
- * Stream a completion from the model server.
164
- * Injects memoryContext into the system message if provided.
165
- */
166
184
  async function streamCompletion(serverUrl, messages, onToken, memoryContext = '') {
167
- await waitForServer(serverUrl, 5, 1000);
185
+ if (!_serverVerified) await waitForServer(serverUrl);
168
186
 
169
187
  // Inject memory context into the system message (first message with role=system)
170
188
  let finalMessages = messages;
@@ -227,55 +245,90 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
227
245
  * @param {{ sessionDir: string, cwd: string } | null} sessionInfo
228
246
  * @param {number} maxIterations
229
247
  */
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
248
+ export async function quickCompletion(serverUrl, systemPrompt, userMessage) {
249
+ const controller = new AbortController();
250
+ const timer = setTimeout(() => controller.abort(), 12000);
251
+ try {
252
+ const res = await fetch(`${serverUrl}/v1/chat/completions`, {
253
+ method: 'POST',
254
+ signal: controller.signal,
255
+ headers: { 'Content-Type': 'application/json' },
256
+ body: JSON.stringify({
257
+ model: 'local',
258
+ messages: [
259
+ { role: 'system', content: systemPrompt },
260
+ { role: 'user', content: userMessage }
261
+ ],
262
+ stream: false,
263
+ temperature: 0.1,
264
+ max_tokens: 600
265
+ })
266
+ });
267
+ clearTimeout(timer);
268
+ const json = await res.json();
269
+ return json.choices?.[0]?.message?.content || '';
270
+ } catch {
271
+ clearTimeout(timer);
272
+ return '';
273
+ }
274
+ }
275
+
276
+ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessionInfo = null, confirmCallback = null, maxIterations = 15) {
232
277
  const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
233
278
  const currentTask = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '';
234
279
 
235
- for (let i = 0; i < maxIterations; i++) {
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
- }
280
+ // Files touched during this turn fed back into context assembly each iteration
281
+ const sessionFiles = new Set(extractFilePaths(currentTask));
282
+
283
+ // Assemble memory context once before the turn, refresh after files are touched
284
+ const buildMemoryContext = () => {
285
+ if (!sessionInfo) return '';
286
+ try {
287
+ return assembleContext({
288
+ sessionDir: sessionInfo.sessionDir,
289
+ cwd: sessionInfo.cwd,
290
+ currentTask,
291
+ filesToConsider: [...sessionFiles]
292
+ });
293
+ } catch { return ''; }
294
+ };
295
+
296
+ let memoryContext = buildMemoryContext();
249
297
 
298
+ for (let i = 0; i < maxIterations; i++) {
250
299
  const content = await streamCompletion(serverUrl, messages, onToken, memoryContext);
251
300
  const toolCalls = parseToolCalls(content);
252
301
 
253
302
  messages.push({ role: 'assistant', content });
254
303
 
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
- }
304
+ if (toolCalls.length === 0) break;
265
305
 
266
306
  const results = [];
267
307
  for (const tc of toolCalls) {
268
- onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
269
- const result = await runTool(tc.name, tc.arguments, sessionInfo);
270
- results.push(`[${tc.name} result]\n${String(result).slice(0, 8000)}`);
308
+ // Plan mode: block Write, Edit, Bash
309
+ if (sessionInfo?.planMode && ['Write', 'Edit', 'Bash'].includes(tc.name)) {
310
+ results.push(`[${tc.name} result]\n[PLAN MODE] ${tc.name} is disabled. Describe what you would do instead.`);
311
+ onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
312
+ continue;
313
+ }
271
314
 
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 {}
315
+ // Dangerous command confirmation
316
+ if (tc.name === 'Bash' && confirmCallback) {
317
+ const parsed = typeof tc.arguments === 'string' ? JSON.parse(tc.arguments) : tc.arguments;
318
+ const normalized = normalizeArgs(parsed);
319
+ if (isDangerous(normalized.command)) {
320
+ const ok = await confirmCallback(normalized.command);
321
+ if (!ok) {
322
+ onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
323
+ results.push(`[${tc.name} result]\nCommand blocked by user. Find a safer approach to accomplish this.`);
324
+ continue;
325
+ }
326
+ }
278
327
  }
328
+
329
+ onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
330
+ const result = await runTool(tc.name, tc.arguments, sessionInfo, sessionFiles);
331
+ results.push(`[${tc.name} result]\n${String(result).slice(0, 8000)}`);
279
332
  }
280
333
 
281
334
  messages.push({
@@ -283,6 +336,8 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
283
336
  content: results.join('\n\n---\n\n') + '\n\nContinue with the task.'
284
337
  });
285
338
 
339
+ // Refresh memory context now that new files may have been touched
340
+ memoryContext = buildMemoryContext();
286
341
  onToken('\n');
287
342
  }
288
343
  }
package/lib/tools.js CHANGED
@@ -7,6 +7,16 @@ import { addFileNote, updateContext, appendDecision } from './memory.js';
7
7
 
8
8
  const execAsync = promisify(exec);
9
9
 
10
+ // ── Undo Stack ───────────────────────────────────────────────────────────────
11
+ const _undoStack = [];
12
+ const MAX_UNDO = 20;
13
+ export function undo() {
14
+ if (_undoStack.length === 0) return null;
15
+ const { filePath, content } = _undoStack.pop();
16
+ try { writeFileSync(filePath, content, 'utf8'); return filePath; } catch { return null; }
17
+ }
18
+ export function undoStackSize() { return _undoStack.length; }
19
+
10
20
  // ── Session Context (set by index.js at startup) ──────────────────────────────
11
21
  let _sessionDir = null;
12
22
  let _cwd = null;
@@ -97,6 +107,13 @@ export const WriteTool = {
97
107
  },
98
108
  async run({ file_path, content }) {
99
109
  try {
110
+ // Undo stack: save previous content before overwriting
111
+ try {
112
+ if (existsSync(file_path)) {
113
+ _undoStack.push({ filePath: file_path, content: readFileSync(file_path, 'utf8') });
114
+ if (_undoStack.length > MAX_UNDO) _undoStack.shift();
115
+ }
116
+ } catch {}
100
117
  writeFileSync(file_path, content, 'utf8');
101
118
  return `Written ${content.split('\n').length} lines to ${file_path}`;
102
119
  } catch (e) {
@@ -127,6 +144,11 @@ export const EditTool = {
127
144
  if (!existsSync(file_path)) return `Error: File not found: ${file_path}`;
128
145
  try {
129
146
  let content = readFileSync(file_path, 'utf8');
147
+ // Undo stack: save current content before editing
148
+ try {
149
+ _undoStack.push({ filePath: file_path, content });
150
+ if (_undoStack.length > MAX_UNDO) _undoStack.shift();
151
+ } catch {}
130
152
  if (!content.includes(old_string)) return `Error: old_string not found in ${file_path}`;
131
153
  if (!replace_all) {
132
154
  const count = content.split(old_string).length - 1;
@@ -249,7 +271,9 @@ Set file_path to null for project-wide notes (written to CODEBASE.md).`,
249
271
  async run({ file_path, note }) {
250
272
  try {
251
273
  if (!_cwd) return 'Note saved (no project memory initialized yet)';
252
- addFileNote(_cwd, file_path || null, note);
274
+ // Normalize: model sometimes passes "null" or "undefined" as a string
275
+ const fp = (!file_path || file_path === 'null' || file_path === 'undefined') ? null : file_path;
276
+ addFileNote(_cwd, fp, note);
253
277
  return `Note saved to project memory${file_path ? ` for ${file_path}` : ' (CODEBASE.md)'}.`;
254
278
  } catch (e) {
255
279
  return `Note recorded (memory write failed silently: ${e.message})`;
@@ -277,7 +301,7 @@ The content should be a clear summary of:
277
301
  4. Any blockers or important context
278
302
 
279
303
  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." })`,
304
+ 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]" })`,
281
305
  input_schema: {
282
306
  type: 'object',
283
307
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsunami-code",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "Tsunami Code CLI — AI coding agent by Keystone World Management Navy Seal Unit XI3",
5
5
  "type": "module",
6
6
  "bin": {