tsunami-code 3.3.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 +194 -13
- package/lib/loop.js +64 -7
- package/lib/prompt.js +14 -2
- package/lib/tools.js +61 -20
- 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 } 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';
|
|
@@ -172,6 +172,8 @@ async function run() {
|
|
|
172
172
|
const cwd = process.cwd();
|
|
173
173
|
let planMode = argv.includes('--plan');
|
|
174
174
|
|
|
175
|
+
const sessionStartTime = Date.now();
|
|
176
|
+
|
|
175
177
|
// Initialize memory systems
|
|
176
178
|
const { sessionId, sessionDir } = initSession(cwd);
|
|
177
179
|
initProjectMemory(cwd);
|
|
@@ -456,7 +458,7 @@ async function run() {
|
|
|
456
458
|
|
|
457
459
|
// Skills: check if this is a skill command before built-ins
|
|
458
460
|
const skillMatch = getSkillCommand(skills, line);
|
|
459
|
-
if (skillMatch && !['help','compact','plan','undo','doctor','cost','clear','status','server','model','memory','history','exit','quit','skill-create','skill-list','skills','mcp'].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)) {
|
|
460
462
|
// Run the skill prompt as a user message
|
|
461
463
|
const userContent = skillMatch.args
|
|
462
464
|
? `[Skill: ${skillMatch.skill.name}]\n${skillMatch.prompt}`
|
|
@@ -498,6 +500,13 @@ async function run() {
|
|
|
498
500
|
['/server <url>', 'Change model server URL'],
|
|
499
501
|
['/model [name]', 'Show or change active model (default: local)'],
|
|
500
502
|
['/mcp', 'Show MCP server status and tools'],
|
|
503
|
+
['/effort <level>', 'Set reasoning effort: low/medium/high/max'],
|
|
504
|
+
['/copy', 'Copy last response to clipboard'],
|
|
505
|
+
['/btw <note>', 'Inject a note into conversation without a response'],
|
|
506
|
+
['/rewind', 'Remove last user+assistant exchange'],
|
|
507
|
+
['/diff', 'Show git diff of session file changes'],
|
|
508
|
+
['/stats', 'Session stats: lines, tokens, duration'],
|
|
509
|
+
['/export [file]', 'Export conversation to markdown file'],
|
|
501
510
|
['/history', 'Show recent command history'],
|
|
502
511
|
['/exit', 'Exit'],
|
|
503
512
|
];
|
|
@@ -518,8 +527,24 @@ async function run() {
|
|
|
518
527
|
break;
|
|
519
528
|
case 'undo': {
|
|
520
529
|
const restored = undo();
|
|
521
|
-
if (restored)
|
|
522
|
-
|
|
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`));
|
|
523
548
|
break;
|
|
524
549
|
}
|
|
525
550
|
case 'mcp': {
|
|
@@ -566,12 +591,20 @@ async function run() {
|
|
|
566
591
|
}
|
|
567
592
|
case 'cost': {
|
|
568
593
|
const hasReal = tokenStats.requests > 0;
|
|
569
|
-
console.log(blue('\n Session Token Usage'));
|
|
594
|
+
console.log(blue('\n Session Token Usage\n'));
|
|
570
595
|
if (hasReal) {
|
|
571
|
-
console.log(dim(` Input
|
|
572
|
-
console.log(dim(` Output
|
|
573
|
-
console.log(dim(` Total
|
|
574
|
-
console.log(dim(` Requests: ${tokenStats.requests}`));
|
|
596
|
+
console.log(dim(` Input : ${tokenStats.input.toLocaleString()} tokens`));
|
|
597
|
+
console.log(dim(` Output : ${tokenStats.output.toLocaleString()} tokens`));
|
|
598
|
+
console.log(dim(` Total : ${(tokenStats.input + tokenStats.output).toLocaleString()} tokens`));
|
|
599
|
+
console.log(dim(` Requests : ${tokenStats.requests}`));
|
|
600
|
+
// Per-model breakdown (mirrors cost-tracker.ts formatModelUsage)
|
|
601
|
+
const models = Object.entries(tokenStats.byModel);
|
|
602
|
+
if (models.length > 1) {
|
|
603
|
+
console.log(blue('\n By model:\n'));
|
|
604
|
+
for (const [model, usage] of models) {
|
|
605
|
+
console.log(dim(` ${model.padEnd(20)} ${usage.input.toLocaleString()} in ${usage.output.toLocaleString()} out (${usage.requests} req)`));
|
|
606
|
+
}
|
|
607
|
+
}
|
|
575
608
|
} else {
|
|
576
609
|
console.log(dim(` Input : ~${_inputTokens.toLocaleString()} (estimated)`));
|
|
577
610
|
console.log(dim(` Output : ~${_outputTokens.toLocaleString()} (estimated)`));
|
|
@@ -580,6 +613,137 @@ async function run() {
|
|
|
580
613
|
console.log();
|
|
581
614
|
break;
|
|
582
615
|
}
|
|
616
|
+
case 'copy': {
|
|
617
|
+
// Copy last assistant response to clipboard — from commands.ts copy command
|
|
618
|
+
const lastAss = [...messages].reverse().find(m => m.role === 'assistant');
|
|
619
|
+
if (!lastAss) { console.log(dim(' Nothing to copy.\n')); break; }
|
|
620
|
+
const text = typeof lastAss.content === 'string' ? lastAss.content : JSON.stringify(lastAss.content);
|
|
621
|
+
try {
|
|
622
|
+
const { execSync } = await import('child_process');
|
|
623
|
+
if (process.platform === 'darwin') {
|
|
624
|
+
execSync('pbcopy', { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
|
|
625
|
+
} else if (process.platform === 'win32') {
|
|
626
|
+
execSync('clip', { input: text, stdio: ['pipe', 'ignore', 'ignore'], shell: true });
|
|
627
|
+
} else {
|
|
628
|
+
execSync('xclip -selection clipboard 2>/dev/null || xsel --clipboard --input', { input: text, stdio: ['pipe', 'ignore', 'ignore'], shell: true });
|
|
629
|
+
}
|
|
630
|
+
console.log(green(` ✓ Copied ${text.length.toLocaleString()} chars to clipboard\n`));
|
|
631
|
+
} catch (e) {
|
|
632
|
+
console.log(red(` Copy failed: ${e.message}\n`));
|
|
633
|
+
}
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
case 'btw': {
|
|
637
|
+
// Inject a sticky note into conversation without triggering a model turn
|
|
638
|
+
// From CC's btw command — useful for mid-task context injection
|
|
639
|
+
const note = rest.join(' ');
|
|
640
|
+
if (!note) { console.log(red(' Usage: /btw <note>\n')); break; }
|
|
641
|
+
messages.push({ role: 'user', content: `[btw: ${note}]` });
|
|
642
|
+
messages.push({ role: 'assistant', content: 'Noted.' });
|
|
643
|
+
console.log(green(' ✓ Noted.\n'));
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
646
|
+
case 'rewind': {
|
|
647
|
+
// Remove last user+assistant exchange from context
|
|
648
|
+
// From CC's rewind command
|
|
649
|
+
if (messages.length < 2) { console.log(dim(' Nothing to rewind.\n')); break; }
|
|
650
|
+
// Find last assistant message
|
|
651
|
+
let lastAssIdx = -1;
|
|
652
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
653
|
+
if (messages[i].role === 'assistant') { lastAssIdx = i; break; }
|
|
654
|
+
}
|
|
655
|
+
if (lastAssIdx === -1) { console.log(dim(' Nothing to rewind.\n')); break; }
|
|
656
|
+
// Find the user message that preceded it
|
|
657
|
+
let lastUserIdx = lastAssIdx;
|
|
658
|
+
for (let i = lastAssIdx - 1; i >= 0; i--) {
|
|
659
|
+
if (messages[i].role === 'user') { lastUserIdx = i; break; }
|
|
660
|
+
}
|
|
661
|
+
const removed = messages.splice(lastUserIdx).length;
|
|
662
|
+
console.log(green(` ✓ Rewound ${removed} message(s). Context: ${messages.length} messages.\n`));
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
case 'diff': {
|
|
666
|
+
// Show git diff of file changes made this session
|
|
667
|
+
// From CC's diff command
|
|
668
|
+
try {
|
|
669
|
+
const { execSync: _exec } = await import('child_process');
|
|
670
|
+
const statArgs = rest.includes('--full') ? '' : '--stat';
|
|
671
|
+
let diffOut = '';
|
|
672
|
+
try {
|
|
673
|
+
diffOut = _exec(`git diff HEAD ${statArgs}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
674
|
+
} catch {
|
|
675
|
+
diffOut = _exec(`git diff ${statArgs}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
676
|
+
}
|
|
677
|
+
if (!diffOut.trim()) {
|
|
678
|
+
console.log(dim(' No changes detected. (Use git add to stage new files.)\n'));
|
|
679
|
+
} else {
|
|
680
|
+
console.log(blue('\n Session diff:\n'));
|
|
681
|
+
console.log(dim(diffOut.trim().split('\n').map(l => ' ' + l).join('\n')));
|
|
682
|
+
if (statArgs === '--stat') console.log(dim('\n Tip: /diff --full for full patch\n'));
|
|
683
|
+
else console.log();
|
|
684
|
+
}
|
|
685
|
+
} catch (e) {
|
|
686
|
+
console.log(dim(` No git repository or no changes. (${e.message.slice(0, 60)})\n`));
|
|
687
|
+
}
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
case 'stats': {
|
|
691
|
+
// Session statistics — mirrors cost-tracker.ts formatTotalCost pattern
|
|
692
|
+
const elapsed = Date.now() - sessionStartTime;
|
|
693
|
+
const mins = Math.floor(elapsed / 60000);
|
|
694
|
+
const secs = Math.floor((elapsed % 60000) / 1000);
|
|
695
|
+
const durationStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
|
696
|
+
const totalTok = tokenStats.input + tokenStats.output;
|
|
697
|
+
console.log(blue('\n Session Stats\n'));
|
|
698
|
+
console.log(dim(` Duration ${durationStr}`));
|
|
699
|
+
console.log(dim(` Requests ${tokenStats.requests}`));
|
|
700
|
+
console.log(dim(` Input tokens ${tokenStats.input.toLocaleString()}`));
|
|
701
|
+
console.log(dim(` Output tokens ${tokenStats.output.toLocaleString()}`));
|
|
702
|
+
console.log(dim(` Total tokens ${totalTok.toLocaleString()}`));
|
|
703
|
+
console.log(dim(` Lines added ${linesChanged.added}`));
|
|
704
|
+
console.log(dim(` Lines removed ${linesChanged.removed}`));
|
|
705
|
+
console.log(dim(` Messages ${messages.length}`));
|
|
706
|
+
const models = Object.entries(tokenStats.byModel);
|
|
707
|
+
if (models.length > 1) {
|
|
708
|
+
console.log(blue('\n By model:\n'));
|
|
709
|
+
for (const [model, usage] of models) {
|
|
710
|
+
console.log(dim(` ${model.padEnd(20)} ${usage.input.toLocaleString()} in ${usage.output.toLocaleString()} out`));
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
console.log();
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
case 'export': {
|
|
717
|
+
// Export full conversation to a markdown file
|
|
718
|
+
// From CC's export command
|
|
719
|
+
const filename = rest[0] || `tsunami-session-${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.md`;
|
|
720
|
+
const exportPath = filename.startsWith('/') || filename.startsWith('D:') || filename.startsWith('C:') ? filename : join(cwd, filename);
|
|
721
|
+
const lines = [
|
|
722
|
+
`# Tsunami Code Session`,
|
|
723
|
+
``,
|
|
724
|
+
`**Date:** ${new Date().toISOString()}`,
|
|
725
|
+
`**Directory:** ${cwd}`,
|
|
726
|
+
`**Messages:** ${messages.length}`,
|
|
727
|
+
`**Tokens:** ${(tokenStats.input + tokenStats.output).toLocaleString()}`,
|
|
728
|
+
``,
|
|
729
|
+
`---`,
|
|
730
|
+
``
|
|
731
|
+
];
|
|
732
|
+
for (const msg of messages) {
|
|
733
|
+
if (msg.role === 'system') continue;
|
|
734
|
+
const roleLabel = msg.role === 'user' ? '### User' : '### Assistant';
|
|
735
|
+
const text = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content, null, 2);
|
|
736
|
+
lines.push(roleLabel, '', text, '', '---', '');
|
|
737
|
+
}
|
|
738
|
+
try {
|
|
739
|
+
const { writeFileSync: _wfs } = await import('fs');
|
|
740
|
+
_wfs(exportPath, lines.join('\n'), 'utf8');
|
|
741
|
+
console.log(green(` ✓ Exported to: ${exportPath}\n`));
|
|
742
|
+
} catch (e) {
|
|
743
|
+
console.log(red(` Export failed: ${e.message}\n`));
|
|
744
|
+
}
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
583
747
|
case 'clear':
|
|
584
748
|
resetSession();
|
|
585
749
|
console.log(green(' Session cleared.\n'));
|
|
@@ -682,6 +846,9 @@ async function run() {
|
|
|
682
846
|
isProcessing = true;
|
|
683
847
|
process.stdout.write('\n');
|
|
684
848
|
|
|
849
|
+
// Open a new undo turn — all Write/Edit calls during this turn grouped together
|
|
850
|
+
beginUndoTurn();
|
|
851
|
+
|
|
685
852
|
let firstToken = true;
|
|
686
853
|
try {
|
|
687
854
|
await agentLoop(
|
|
@@ -716,6 +883,11 @@ async function run() {
|
|
|
716
883
|
console.log(dim(' ↯ Auto-compacted\n'));
|
|
717
884
|
}
|
|
718
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
|
+
|
|
719
891
|
process.stdout.write('\n\n');
|
|
720
892
|
} catch (e) {
|
|
721
893
|
process.stdout.write('\n');
|
|
@@ -735,9 +907,18 @@ async function run() {
|
|
|
735
907
|
if (!isProcessing) {
|
|
736
908
|
gracefulExit(0);
|
|
737
909
|
} else {
|
|
738
|
-
//
|
|
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 {}
|
|
739
920
|
pendingClose = true;
|
|
740
|
-
console.log(dim('\n (
|
|
921
|
+
console.log(dim('\n (interrupted)\n'));
|
|
741
922
|
}
|
|
742
923
|
});
|
|
743
924
|
}
|
package/lib/loop.js
CHANGED
|
@@ -44,13 +44,26 @@ function isDangerous(cmd) {
|
|
|
44
44
|
let _serverVerified = false;
|
|
45
45
|
|
|
46
46
|
// Real token tracking from API responses
|
|
47
|
-
|
|
47
|
+
// byModel mirrors cost-tracker.ts per-model usage accumulation
|
|
48
|
+
export const tokenStats = { input: 0, output: 0, requests: 0, byModel: {} };
|
|
49
|
+
|
|
50
|
+
// Token warning thresholds from query.ts calculateTokenWarningState
|
|
51
|
+
const TOKEN_WARN_LEVELS = [
|
|
52
|
+
{ pct: 0.95, label: '⚠ Context critical — run /compact now', urgent: true },
|
|
53
|
+
{ pct: 0.85, label: '⚠ Context high', urgent: true },
|
|
54
|
+
{ pct: 0.70, label: '◦ Context at', urgent: false },
|
|
55
|
+
];
|
|
48
56
|
|
|
49
57
|
// Current model identifier — changeable at runtime via /model command
|
|
50
58
|
let _currentModel = 'local';
|
|
51
59
|
export function setModel(model) { _currentModel = model; }
|
|
52
60
|
export function getModel() { return _currentModel; }
|
|
53
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
|
+
|
|
54
67
|
// Parse tool calls from any format the model might produce
|
|
55
68
|
function parseToolCalls(content) {
|
|
56
69
|
const calls = [];
|
|
@@ -224,13 +237,22 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
|
|
|
224
237
|
model: _currentModel,
|
|
225
238
|
messages: finalMessages,
|
|
226
239
|
stream: true,
|
|
227
|
-
temperature:
|
|
240
|
+
temperature: _temperature,
|
|
228
241
|
max_tokens: 4096,
|
|
229
242
|
stop: ['</tool_call>\n\nContinue']
|
|
230
243
|
})
|
|
231
244
|
});
|
|
232
245
|
|
|
233
|
-
if (!res.ok)
|
|
246
|
+
if (!res.ok) {
|
|
247
|
+
const errText = await res.text();
|
|
248
|
+
// PROMPT_TOO_LONG detection — mirrors query.ts isPromptTooLongMessage
|
|
249
|
+
if (res.status === 400 || errText.toLowerCase().includes('context') || errText.toLowerCase().includes('too long') || errText.toLowerCase().includes('tokens')) {
|
|
250
|
+
const e = new Error(`Model server: ${res.status} ${errText}`);
|
|
251
|
+
e.isPromptTooLong = true;
|
|
252
|
+
throw e;
|
|
253
|
+
}
|
|
254
|
+
throw new Error(`Model server: ${res.status} ${errText}`);
|
|
255
|
+
}
|
|
234
256
|
|
|
235
257
|
let fullContent = '';
|
|
236
258
|
let buffer = '';
|
|
@@ -253,10 +275,18 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
|
|
|
253
275
|
}
|
|
254
276
|
// Capture real token counts + finish reason
|
|
255
277
|
if (parsed.usage) {
|
|
256
|
-
|
|
257
|
-
|
|
278
|
+
const pt = parsed.usage.prompt_tokens || 0;
|
|
279
|
+
const ct = parsed.usage.completion_tokens || 0;
|
|
280
|
+
tokenStats.input += pt;
|
|
281
|
+
tokenStats.output += ct;
|
|
258
282
|
tokenStats.requests++;
|
|
259
|
-
tokenStats.lastPromptTokens =
|
|
283
|
+
tokenStats.lastPromptTokens = pt;
|
|
284
|
+
// Per-model accumulation
|
|
285
|
+
const m = _currentModel;
|
|
286
|
+
if (!tokenStats.byModel[m]) tokenStats.byModel[m] = { input: 0, output: 0, requests: 0 };
|
|
287
|
+
tokenStats.byModel[m].input += pt;
|
|
288
|
+
tokenStats.byModel[m].output += ct;
|
|
289
|
+
tokenStats.byModel[m].requests++;
|
|
260
290
|
}
|
|
261
291
|
if (parsed.choices?.[0]?.finish_reason) {
|
|
262
292
|
tokenStats.lastFinishReason = parsed.choices[0].finish_reason;
|
|
@@ -352,7 +382,34 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
|
|
|
352
382
|
tokenStats.lastPromptTokens = 0;
|
|
353
383
|
}
|
|
354
384
|
|
|
355
|
-
|
|
385
|
+
let content;
|
|
386
|
+
try {
|
|
387
|
+
content = await streamCompletion(serverUrl, messages, onToken, memoryContext);
|
|
388
|
+
} catch (e) {
|
|
389
|
+
// PROMPT_TOO_LONG recovery — compact and retry once
|
|
390
|
+
if (e.isPromptTooLong && messages.length > 4) {
|
|
391
|
+
onToken('\n[context too long — auto-compacting and retrying]\n');
|
|
392
|
+
const sys = messages[0];
|
|
393
|
+
const tail = messages.slice(-3);
|
|
394
|
+
messages.length = 0;
|
|
395
|
+
messages.push(sys, ...tail);
|
|
396
|
+
content = await streamCompletion(serverUrl, messages, onToken, memoryContext);
|
|
397
|
+
} else {
|
|
398
|
+
throw e;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Token warning thresholds (from query.ts calculateTokenWarningState)
|
|
403
|
+
if (tokenStats.lastPromptTokens > 0) {
|
|
404
|
+
const pct = tokenStats.lastPromptTokens / CONTEXT_WINDOW;
|
|
405
|
+
for (const level of TOKEN_WARN_LEVELS) {
|
|
406
|
+
if (pct >= level.pct) {
|
|
407
|
+
onToken(`\n[${level.label}${level.urgent ? '' : ` ${Math.round(pct * 100)}%`}]\n`);
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
356
413
|
const toolCalls = parseToolCalls(content);
|
|
357
414
|
|
|
358
415
|
messages.push({ role: 'assistant', content });
|
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
|
@@ -8,15 +8,59 @@ import fetch from 'node-fetch';
|
|
|
8
8
|
|
|
9
9
|
const execAsync = promisify(exec);
|
|
10
10
|
|
|
11
|
-
// ──
|
|
12
|
-
|
|
13
|
-
const
|
|
11
|
+
// ── Lines Changed Tracking ────────────────────────────────────────────────────
|
|
12
|
+
// Mirrors cost-tracker.ts addToTotalLinesChanged pattern
|
|
13
|
+
export const linesChanged = { added: 0, removed: 0 };
|
|
14
|
+
|
|
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. */
|
|
14
41
|
export function undo() {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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;
|
|
18
62
|
}
|
|
19
|
-
export function undoStackSize() { return
|
|
63
|
+
export function undoStackSize() { return _undoTurns.length + (_currentTurnFiles.length > 0 ? 1 : 0); }
|
|
20
64
|
|
|
21
65
|
// ── Session Context (set by index.js at startup) ──────────────────────────────
|
|
22
66
|
let _sessionDir = null;
|
|
@@ -108,15 +152,13 @@ export const WriteTool = {
|
|
|
108
152
|
},
|
|
109
153
|
async run({ file_path, content }) {
|
|
110
154
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
_undoStack.push({ filePath: file_path, content: readFileSync(file_path, 'utf8') });
|
|
115
|
-
if (_undoStack.length > MAX_UNDO) _undoStack.shift();
|
|
116
|
-
}
|
|
117
|
-
} catch {}
|
|
155
|
+
_pushFileSnapshot(file_path);
|
|
156
|
+
const newLineCount = content.split('\n').length;
|
|
157
|
+
const oldLineCount = existsSync(file_path) ? readFileSync(file_path, 'utf8').split('\n').length : 0;
|
|
118
158
|
writeFileSync(file_path, content, 'utf8');
|
|
119
|
-
|
|
159
|
+
linesChanged.added += Math.max(0, newLineCount - oldLineCount);
|
|
160
|
+
linesChanged.removed += Math.max(0, oldLineCount - newLineCount);
|
|
161
|
+
return `Written ${newLineCount} lines to ${file_path}`;
|
|
120
162
|
} catch (e) {
|
|
121
163
|
return `Error writing file: ${e.message}`;
|
|
122
164
|
}
|
|
@@ -145,11 +187,7 @@ export const EditTool = {
|
|
|
145
187
|
if (!existsSync(file_path)) return `Error: File not found: ${file_path}`;
|
|
146
188
|
try {
|
|
147
189
|
let content = readFileSync(file_path, 'utf8');
|
|
148
|
-
|
|
149
|
-
try {
|
|
150
|
-
_undoStack.push({ filePath: file_path, content });
|
|
151
|
-
if (_undoStack.length > MAX_UNDO) _undoStack.shift();
|
|
152
|
-
} catch {}
|
|
190
|
+
_pushFileSnapshot(file_path);
|
|
153
191
|
if (!content.includes(old_string)) return `Error: old_string not found in ${file_path}`;
|
|
154
192
|
if (!replace_all) {
|
|
155
193
|
const count = content.split(old_string).length - 1;
|
|
@@ -159,6 +197,9 @@ export const EditTool = {
|
|
|
159
197
|
? content.split(old_string).join(new_string)
|
|
160
198
|
: content.replace(old_string, new_string);
|
|
161
199
|
writeFileSync(file_path, updated, 'utf8');
|
|
200
|
+
// Track lines changed: old_string lines removed, new_string lines added
|
|
201
|
+
linesChanged.added += new_string.split('\n').length;
|
|
202
|
+
linesChanged.removed += old_string.split('\n').length;
|
|
162
203
|
return `Edited ${file_path}`;
|
|
163
204
|
} catch (e) {
|
|
164
205
|
return `Error editing file: ${e.message}`;
|