tsunami-code 3.4.0 → 3.5.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/index.js CHANGED
@@ -4,14 +4,14 @@ 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, quickCompletion, setModel, getModel, tokenStats } from './lib/loop.js';
7
+ import { agentLoop, quickCompletion, setModel, getModel, tokenStats, setTemperature, getTemperature } from './lib/loop.js';
8
8
  import { injectServerContext } from './lib/tools.js';
9
9
  import { loadSkills, getSkillCommand, createSkill, listSkills } from './lib/skills.js';
10
10
  import { isCoordinatorTask, stripCoordinatorPrefix, buildCoordinatorSystemPrompt } from './lib/coordinator.js';
11
11
  import { getDueTasks, markDone, cancelTask, listTasks, formatTaskList } from './lib/kairos.js';
12
12
  import { buildSystemPrompt } from './lib/prompt.js';
13
13
  import { runPreflight, checkServer } from './lib/preflight.js';
14
- import { setSession, undo, undoStackSize, registerMcpTools, linesChanged } from './lib/tools.js';
14
+ import { setSession, undo, undoStackSize, beginUndoTurn, registerMcpTools, linesChanged } from './lib/tools.js';
15
15
  import { connectMcpServers, getMcpToolObjects, getMcpStatus, getMcpConfigPath, disconnectAll as disconnectMcp } from './lib/mcp.js';
16
16
  import {
17
17
  initSession,
@@ -25,7 +25,7 @@ import {
25
25
  getSessionContext
26
26
  } from './lib/memory.js';
27
27
 
28
- const VERSION = '3.4.0';
28
+ const VERSION = '3.5.0';
29
29
  const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
30
30
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
31
31
  const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
@@ -458,7 +458,7 @@ async function run() {
458
458
 
459
459
  // Skills: check if this is a skill command before built-ins
460
460
  const skillMatch = getSkillCommand(skills, line);
461
- if (skillMatch && !['help','compact','plan','undo','doctor','cost','clear','status','server','model','memory','history','exit','quit','skill-create','skill-list','skills','mcp','copy','btw','rewind','diff','stats','export'].includes(cmd)) {
461
+ if (skillMatch && !['help','compact','plan','undo','doctor','cost','clear','status','server','model','memory','history','exit','quit','skill-create','skill-list','skills','mcp','copy','btw','rewind','diff','stats','export','effort'].includes(cmd)) {
462
462
  // Run the skill prompt as a user message
463
463
  const userContent = skillMatch.args
464
464
  ? `[Skill: ${skillMatch.skill.name}]\n${skillMatch.prompt}`
@@ -500,6 +500,7 @@ async function run() {
500
500
  ['/server <url>', 'Change model server URL'],
501
501
  ['/model [name]', 'Show or change active model (default: local)'],
502
502
  ['/mcp', 'Show MCP server status and tools'],
503
+ ['/effort <level>', 'Set reasoning effort: low/medium/high/max'],
503
504
  ['/copy', 'Copy last response to clipboard'],
504
505
  ['/btw <note>', 'Inject a note into conversation without a response'],
505
506
  ['/rewind', 'Remove last user+assistant exchange'],
@@ -526,8 +527,24 @@ async function run() {
526
527
  break;
527
528
  case 'undo': {
528
529
  const restored = undo();
529
- if (restored) console.log(green(` ✓ Restored: ${restored}\n`));
530
- else console.log(dim(' Nothing to undo.\n'));
530
+ if (restored) {
531
+ if (restored.length === 1) console.log(green(` Restored: ${restored[0]}\n`));
532
+ else console.log(green(` ✓ Restored ${restored.length} files:\n`) + restored.map(f => dim(` ${f}`)).join('\n') + '\n');
533
+ } else {
534
+ console.log(dim(' Nothing to undo.\n'));
535
+ }
536
+ break;
537
+ }
538
+ case 'effort': {
539
+ const level = rest[0]?.toLowerCase();
540
+ const levels = { low: 0.5, medium: 0.2, high: 0.05, max: 0.0 };
541
+ if (!level || !levels[level] && levels[level] !== 0) {
542
+ const cur = getTemperature();
543
+ console.log(dim(` Current temperature: ${cur}\n Usage: /effort <low|medium|high|max>\n`));
544
+ break;
545
+ }
546
+ setTemperature(levels[level]);
547
+ console.log(green(` Effort: ${level} (temperature ${levels[level]})\n`));
531
548
  break;
532
549
  }
533
550
  case 'mcp': {
@@ -829,6 +846,9 @@ async function run() {
829
846
  isProcessing = true;
830
847
  process.stdout.write('\n');
831
848
 
849
+ // Open a new undo turn — all Write/Edit calls during this turn grouped together
850
+ beginUndoTurn();
851
+
832
852
  let firstToken = true;
833
853
  try {
834
854
  await agentLoop(
@@ -863,6 +883,11 @@ async function run() {
863
883
  console.log(dim(' ↯ Auto-compacted\n'));
864
884
  }
865
885
 
886
+ // getDynamicSkills — reload if model created/modified skill files this turn
887
+ // From CC's getDynamicSkills pattern in commands.ts
888
+ const newSkills = loadSkills(cwd);
889
+ if (newSkills.length !== skills.length) skills = newSkills;
890
+
866
891
  process.stdout.write('\n\n');
867
892
  } catch (e) {
868
893
  process.stdout.write('\n');
@@ -882,9 +907,18 @@ async function run() {
882
907
  if (!isProcessing) {
883
908
  gracefulExit(0);
884
909
  } else {
885
- // If processing, mark pending and let the loop finish
910
+ // Remove interrupted command from history (from history.ts removeLastFromHistory)
911
+ try {
912
+ if (existsSync(HISTORY_FILE)) {
913
+ const lines = readFileSync(HISTORY_FILE, 'utf8').trimEnd().split('\n').filter(Boolean);
914
+ if (lines.length > 0) lines.pop();
915
+ import('fs').then(({ writeFileSync: wfs }) => {
916
+ try { wfs(HISTORY_FILE, lines.join('\n') + (lines.length ? '\n' : ''), 'utf8'); } catch {}
917
+ });
918
+ }
919
+ } catch {}
886
920
  pendingClose = true;
887
- console.log(dim('\n (finishing current operation...)\n'));
921
+ console.log(dim('\n (interrupted)\n'));
888
922
  }
889
923
  });
890
924
  }
package/lib/loop.js CHANGED
@@ -59,6 +59,11 @@ let _currentModel = 'local';
59
59
  export function setModel(model) { _currentModel = model; }
60
60
  export function getModel() { return _currentModel; }
61
61
 
62
+ // Temperature — changeable via /effort command
63
+ let _temperature = 0.1;
64
+ export function setTemperature(t) { _temperature = Math.max(0, Math.min(1, t)); }
65
+ export function getTemperature() { return _temperature; }
66
+
62
67
  // Parse tool calls from any format the model might produce
63
68
  function parseToolCalls(content) {
64
69
  const calls = [];
@@ -232,7 +237,7 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
232
237
  model: _currentModel,
233
238
  messages: finalMessages,
234
239
  stream: true,
235
- temperature: 0.1,
240
+ temperature: _temperature,
236
241
  max_tokens: 4096,
237
242
  stop: ['</tool_call>\n\nContinue']
238
243
  })
package/lib/prompt.js CHANGED
@@ -8,8 +8,20 @@ function getGitContext() {
8
8
  const branch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
9
9
  const status = execSync('git status --short', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
10
10
  const log = execSync('git log --oneline -5', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
11
- const parts = [`Branch: ${branch}`];
12
- if (status) parts.push(`Changed files:\n${status}`);
11
+ // Main branch detection (from context.ts getDefaultBranch pattern)
12
+ let mainBranch = 'main';
13
+ try { mainBranch = execSync('git symbolic-ref refs/remotes/origin/HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim().replace('refs/remotes/origin/', ''); } catch {}
14
+ if (!mainBranch) try { mainBranch = execSync('git config init.defaultBranch', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch {}
15
+ // Git user name (from context.ts)
16
+ let userName = '';
17
+ try { userName = execSync('git config user.name', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch {}
18
+ // Truncate status at 2000 chars (from context.ts MAX_STATUS_CHARS)
19
+ const truncatedStatus = status.length > 2000
20
+ ? status.slice(0, 2000) + '\n... (truncated — run git status for full output)'
21
+ : status;
22
+ const parts = [`Branch: ${branch}`, `Main branch (for PRs): ${mainBranch}`];
23
+ if (userName) parts.push(`Git user: ${userName}`);
24
+ if (truncatedStatus) parts.push(`Changed files:\n${truncatedStatus}`);
13
25
  if (log) parts.push(`Recent commits:\n${log}`);
14
26
  return `\n\n<git>\n${parts.join('\n\n')}\n</git>`;
15
27
  } catch {
package/lib/tools.js CHANGED
@@ -12,15 +12,55 @@ const execAsync = promisify(exec);
12
12
  // Mirrors cost-tracker.ts addToTotalLinesChanged pattern
13
13
  export const linesChanged = { added: 0, removed: 0 };
14
14
 
15
- // ── Undo Stack ───────────────────────────────────────────────────────────────
16
- const _undoStack = [];
17
- const MAX_UNDO = 20;
15
+ // ── Undo Stack (per-turn batching) ───────────────────────────────────────────
16
+ // Mirrors CC's multi-file undo: each turn's file changes grouped together.
17
+ // /undo restores ALL files changed in the last turn at once.
18
+ const _undoTurns = []; // Array of turns: each is [{ filePath, content }]
19
+ let _currentTurnFiles = []; // Accumulates file snapshots for the active turn
20
+ const MAX_UNDO_TURNS = 10;
21
+
22
+ /** Call before each agent turn starts to open a new undo group. */
23
+ export function beginUndoTurn() {
24
+ if (_currentTurnFiles.length > 0) {
25
+ _undoTurns.push([..._currentTurnFiles]);
26
+ if (_undoTurns.length > MAX_UNDO_TURNS) _undoTurns.shift();
27
+ }
28
+ _currentTurnFiles = [];
29
+ }
30
+
31
+ function _pushFileSnapshot(filePath) {
32
+ // Only snapshot once per file per turn (first state = what to restore to)
33
+ if (_currentTurnFiles.some(f => f.filePath === filePath)) return;
34
+ try {
35
+ const content = existsSync(filePath) ? readFileSync(filePath, 'utf8') : null;
36
+ _currentTurnFiles.push({ filePath, content });
37
+ } catch {}
38
+ }
39
+
40
+ /** Undo all file changes from the most recent turn. Returns list of restored paths. */
18
41
  export function undo() {
19
- if (_undoStack.length === 0) return null;
20
- const { filePath, content } = _undoStack.pop();
21
- try { writeFileSync(filePath, content, 'utf8'); return filePath; } catch { return null; }
42
+ // Commit current turn if it has anything
43
+ if (_currentTurnFiles.length > 0) {
44
+ _undoTurns.push([..._currentTurnFiles]);
45
+ _currentTurnFiles = [];
46
+ }
47
+ if (_undoTurns.length === 0) return null;
48
+ const turn = _undoTurns.pop();
49
+ const restored = [];
50
+ for (const { filePath, content } of turn.reverse()) {
51
+ try {
52
+ if (content === null) {
53
+ // File was created this turn — remove it
54
+ import('fs').then(({ unlinkSync }) => { try { unlinkSync(filePath); } catch {} });
55
+ } else {
56
+ writeFileSync(filePath, content, 'utf8');
57
+ }
58
+ restored.push(filePath);
59
+ } catch {}
60
+ }
61
+ return restored.length ? restored : null;
22
62
  }
23
- export function undoStackSize() { return _undoStack.length; }
63
+ export function undoStackSize() { return _undoTurns.length + (_currentTurnFiles.length > 0 ? 1 : 0); }
24
64
 
25
65
  // ── Session Context (set by index.js at startup) ──────────────────────────────
26
66
  let _sessionDir = null;
@@ -112,13 +152,7 @@ export const WriteTool = {
112
152
  },
113
153
  async run({ file_path, content }) {
114
154
  try {
115
- // Undo stack: save previous content before overwriting
116
- try {
117
- if (existsSync(file_path)) {
118
- _undoStack.push({ filePath: file_path, content: readFileSync(file_path, 'utf8') });
119
- if (_undoStack.length > MAX_UNDO) _undoStack.shift();
120
- }
121
- } catch {}
155
+ _pushFileSnapshot(file_path);
122
156
  const newLineCount = content.split('\n').length;
123
157
  const oldLineCount = existsSync(file_path) ? readFileSync(file_path, 'utf8').split('\n').length : 0;
124
158
  writeFileSync(file_path, content, 'utf8');
@@ -153,11 +187,7 @@ export const EditTool = {
153
187
  if (!existsSync(file_path)) return `Error: File not found: ${file_path}`;
154
188
  try {
155
189
  let content = readFileSync(file_path, 'utf8');
156
- // Undo stack: save current content before editing
157
- try {
158
- _undoStack.push({ filePath: file_path, content });
159
- if (_undoStack.length > MAX_UNDO) _undoStack.shift();
160
- } catch {}
190
+ _pushFileSnapshot(file_path);
161
191
  if (!content.includes(old_string)) return `Error: old_string not found in ${file_path}`;
162
192
  if (!replace_all) {
163
193
  const count = content.split(old_string).length - 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsunami-code",
3
- "version": "3.4.0",
3
+ "version": "3.5.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": {