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 +42 -8
- package/lib/loop.js +6 -1
- package/lib/prompt.js +14 -2
- package/lib/tools.js +49 -19
- package/package.json +1 -1
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.
|
|
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)
|
|
530
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|