This commit is contained in:
seb
2026-09-23 00:50:44 +02:00
parent 73e7f7e7f8
commit 5aab24998d
8 changed files with 499 additions and 16 deletions

View File

@@ -22,13 +22,13 @@
import path from 'path';
import chalk from 'chalk';
import { editor, input, select } from '@inquirer/prompts';
import { editor, input, select, confirm } from '@inquirer/prompts';
import { marked } from 'marked';
import { markedTerminal } from 'marked-terminal';
import { LLMOrbiter, OpenRouterProvider } from 'llmorbiter';
import { API_KEY, MODEL, UNDO_FILE, SUSPEND_DIR, ROOT_DIR } from './config.js';
import { API_KEY, MODEL, UNDO_FILE, SUSPEND_DIR, ROOT_DIR, COMPACT_DEBUG } from './config.js';
import { listDir } from './dir-listing.js';
import { loadUndoLog, getUndoLog } from './undo.js';
import { loadSuspends, addSuspend, getSuspends } from './suspend.js';
@@ -36,7 +36,7 @@ import { tools, toolCallNotifications, toolResultNotifications } from './tools/i
import { printToolCall, emitGenericToolResult } from './tools/notify.js';
import { autocompleteInput } from './autocomplete-input.js';
import { COMMANDS, findCommand } from './slash-commands.js';
import { compactPatchToolCalls } from './compact.js';
import { compactPatchToolCalls, invalidateStaleReadResults } from './compact.js';
// ─── Markdown → console renderer ───────────────────────────────────────────────
@@ -221,11 +221,23 @@ function printToolError(name, error) {
console.log(chalk.red(` ✖ Tool error [${name}]: ${error}`));
}
function printUsage(u) {
function printUsage(u, totals) {
const parts = [`in=${u.input}`, `out=${u.output}`];
if (u.cached) parts.push(`cached=${u.cached}`);
if (u.cost) parts.push(`cost=$${u.cost.toFixed(5)}`);
console.log(chalk.dim(` 📊 ${parts.join(' ')}`));
let line = ` 📊 ${parts.join(' ')}`;
if (totals) {
totals.calls += 1;
totals.input += u.input || 0;
totals.output += u.output || 0;
totals.cached += u.cached || 0;
totals.cost += u.cost || 0;
const acc = [`in=${totals.input}`, `out=${totals.output}`];
if (totals.cached) acc.push(`cached=${totals.cached}`);
if (totals.cost) acc.push(`cost=$${totals.cost.toFixed(5)}`);
line += ` [(${totals.calls}) ${acc.join(', ')}]`;
}
console.log(chalk.dim(line));
}
function formatWhen(iso) {
@@ -271,10 +283,20 @@ async function main() {
loadSuspends();
printBanner();
// Most recent reasoning the model streamed during the active turn. The
// provider reports reasoning cumulatively, so we keep the latest value. If a
// turn is cancelled mid-flight this is offered back to the user so it can be
// preserved in the transcript instead of being discarded.
let turnReasoning = '';
const provider = new OpenRouterProvider({ apiKey: API_KEY });
// Running totals across the session, echoed after each per-call usage line.
const usageTotals = { calls: 0, input: 0, output: 0, cached: 0, cost: 0 };
const orbiter = new LLMOrbiter(provider, {
onStatus: (s) => printStatus(s),
onReasoning: (text) => { turnReasoning = text; },
onToolCall: (c) => {
const notify = toolCallNotifications[c.name];
if (notify) notify(c.args);
@@ -287,20 +309,26 @@ async function main() {
printToolResult(r.name, r.result);
},
onToolError: (e) => printToolError(e.name, e.error),
onUsage: (u) => printUsage(u),
onUsage: (u) => printUsage(u, usageTotals),
});
// Conversation state: persisted across turns
const systemPrompt = buildSystemPrompt();
let messages = [{ role: 'system', content: systemPrompt }];
console.log(chalk.dim(' Type your request below. Type / for commands, /paste for multiline. Press Ctrl+C to exit.\n'));
console.log(chalk.dim(' Type your request below. Type / for commands, /paste for multiline.'));
console.log(chalk.dim(' Ctrl+C exits at the prompt; while the model is running it cancels the turn.\n'));
// Restored into the prompt after a Ctrl+C cancels a running turn, so the
// message can be edited and resent instead of retyped.
let draft = '';
while (true) {
let userInput;
try {
userInput = await autocompleteInput({
message: chalk.bold.cyan('You'),
default: draft,
theme: {
prefix: chalk.cyan('›'),
style: {
@@ -314,6 +342,7 @@ async function main() {
console.log(chalk.dim('\n Goodbye! 🐕\n'));
break;
}
draft = '';
if (!userInput.trim()) continue;
@@ -436,6 +465,15 @@ async function main() {
console.log('');
// No prompt owns the terminal while the model works, so a Ctrl+C would
// otherwise hit Node's default handler and kill the process. Catch it
// here instead: abort the active run and fall back through to the prompt
// (prefilled with the message) rather than exiting.
// Fresh reasoning buffer for this turn.
turnReasoning = '';
const onSigint = () => orbiter.abort('cancelled by user');
process.on('SIGINT', onSigint);
try {
const result = await orbiter.run({
prompt: userInput,
@@ -450,11 +488,61 @@ async function main() {
// Token savings: the patch tool's arguments carry whole file bodies
// (search/replace). Once applied they are dead weight, so shrink each
// patch tool call in the transcript to a head/tail excerpt.
compactPatchToolCalls(messages);
const compactedPatches = compactPatchToolCalls(messages);
// And the other half of the same problem: a read_file result carries a
// whole file body that a later patch made stale. Replace it with a
// short "file was overwritten meanwhile" marker so the old contents are
// not re-sent every turn (nor mistaken for the current file).
const invalidatedReads = invalidateStaleReadResults(messages);
if (COMPACT_DEBUG) {
console.log(chalk.dim(
` [compact] patch tool calls compacted: ${compactedPatches} · ` +
`stale read results invalidated: ${invalidatedReads}`
));
}
printAssistant(result.content);
} catch (err) {
console.log(chalk.red(`\n ✖ Error: ${err.message}\n`));
if (err && err.name === 'AbortError') {
// Ctrl+C cancelled the run — put the message back in the prompt
// so it can be edited and resent. The aborted transcript is
// discarded, so `messages` is untouched and the turn is not lost.
draft = userInput;
console.log(chalk.dim('\n ⏹ Cancelled — your message is back in the prompt; edit it and press Enter to resend.\n'));
// If the model already produced reasoning before the cancel,
// offer to keep it. Kept reasoning is added to the transcript
// exactly like normal reasoning (an assistant message carrying
// the reasoning text), tagged with a marker at the end so it's
// clear the turn was cut short.
const reasoning = turnReasoning.trim();
if (reasoning) {
let keep = false;
try {
keep = await confirm({
message: chalk.bold.cyan('Keep the reasoning the model produced before cancelling?'),
default: true,
theme: {
prefix: chalk.cyan('›'),
style: { message: chalk.bold.cyan },
},
});
} catch {
keep = false; // prompt dismissed (e.g. Ctrl+C) — don't keep
}
if (keep) {
messages.push({
role: 'assistant',
content: `${reasoning}\n---USER-CANCELLED---`,
});
console.log(chalk.dim(' ↗ Reasoning kept in the transcript.\n'));
}
}
} else {
console.log(chalk.red(`\n ✖ Error: ${err.message}\n`));
}
} finally {
process.removeListener('SIGINT', onSigint);
}
}
}