u
This commit is contained in:
@@ -13,8 +13,8 @@ TAVILY_API_KEY=tvly-dev-...
|
|||||||
# Optional: File to persist the undo log (relative to workspace root)
|
# Optional: File to persist the undo log (relative to workspace root)
|
||||||
UNDO_FILE=.fido-undo.json
|
UNDO_FILE=.fido-undo.json
|
||||||
|
|
||||||
# Optional: File to persist /suspend snapshots (relative to workspace root)
|
# Optional: Directory for /suspend snapshots, one JSON file each (relative to workspace root)
|
||||||
SUSPEND_FILE=.fido-suspends.json
|
SUSPEND_DIR=.fido-suspends
|
||||||
|
|
||||||
# Optional: Workspace root directory (defaults to cwd when running fido)
|
# Optional: Workspace root directory (defaults to cwd when running fido)
|
||||||
# ROOT_DIR=.
|
# ROOT_DIR=.
|
||||||
|
|||||||
94
src/compact.js
Normal file
94
src/compact.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transcript compaction — token-saving rewrites applied to the persisted
|
||||||
|
* conversation history between turns.
|
||||||
|
*
|
||||||
|
* The `patch` tool's arguments embed entire file bodies: the `search` and
|
||||||
|
* `replace` text of every operation. Once a patch has been applied those bodies
|
||||||
|
* are pure dead weight — they are re-sent to the model on every later turn but
|
||||||
|
* are never needed again. `compactPatchToolCalls` rewrites each `patch` tool
|
||||||
|
* call in the transcript so its long text fields keep only a head/tail excerpt,
|
||||||
|
* with a marker noting how many lines were elided.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Lines kept from the top and bottom of each long text field.
|
||||||
|
const HEAD_LINES = 5;
|
||||||
|
const TAIL_LINES = 5;
|
||||||
|
|
||||||
|
// Explanatory text carried by the omission marker. The marker is inserted by
|
||||||
|
// the memory-management system when long tool-call text is elided between turns
|
||||||
|
// to save tokens. It is NOT written by the model, and the note says so
|
||||||
|
// explicitly so the model does not mistake it for its own earlier output.
|
||||||
|
const OMISSION_NOTE =
|
||||||
|
'omitted from memory to save tokens — note to self: the Fido Agent did NOT ' +
|
||||||
|
'write this note; the memory management system did';
|
||||||
|
|
||||||
|
/** Escape a string so it can be embedded literally inside a RegExp. */
|
||||||
|
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
|
||||||
|
// Matches the omission marker line emitted below, so an already-compacted field
|
||||||
|
// can be recognised and left alone (keeping repeated passes idempotent). Built
|
||||||
|
// from OMISSION_NOTE so the pattern can never drift from the emitted marker.
|
||||||
|
const OMISSION_MARKER_RE = new RegExp(`^… \\[\\d+ lines? ${escapeRegExp(OMISSION_NOTE)}\\] …$`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the first `head` and last `tail` lines of `text`, inserting a marker that
|
||||||
|
* notes how many lines were omitted. Text that is already short enough
|
||||||
|
* (<= head + tail lines), or that already carries an omission marker, is
|
||||||
|
* returned unchanged.
|
||||||
|
*/
|
||||||
|
export function excerptLines(text, head = HEAD_LINES, tail = TAIL_LINES) {
|
||||||
|
if (typeof text !== 'string') return text;
|
||||||
|
const lines = text.split('\n');
|
||||||
|
if (lines.length <= head + tail) return text;
|
||||||
|
// Already compacted — don't re-truncate (the marker itself would otherwise
|
||||||
|
// be counted as an extra line and trimmed away on the next pass).
|
||||||
|
if (lines.some((l) => OMISSION_MARKER_RE.test(l))) return text;
|
||||||
|
const omitted = lines.length - head - tail;
|
||||||
|
return [
|
||||||
|
...lines.slice(0, head),
|
||||||
|
`… [${omitted} line${omitted === 1 ? '' : 's'} ${OMISSION_NOTE}] …`,
|
||||||
|
...lines.slice(-tail),
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite every `patch` tool call in `messages` in place, replacing the long
|
||||||
|
* `search` and `replace` fields of each operation with a head/tail excerpt.
|
||||||
|
* Malformed arguments are left untouched. Returns the number of tool calls that
|
||||||
|
* were actually compacted.
|
||||||
|
*/
|
||||||
|
export function compactPatchToolCalls(messages) {
|
||||||
|
if (!Array.isArray(messages)) return 0;
|
||||||
|
let compacted = 0;
|
||||||
|
for (const m of messages) {
|
||||||
|
if (!m || m.role !== 'assistant' || !Array.isArray(m.tool_calls)) continue;
|
||||||
|
for (const tc of m.tool_calls) {
|
||||||
|
if (tc?.function?.name !== 'patch' || typeof tc.function.arguments !== 'string') continue;
|
||||||
|
let args;
|
||||||
|
try {
|
||||||
|
args = JSON.parse(tc.function.arguments);
|
||||||
|
} catch {
|
||||||
|
continue; // leave malformed arguments untouched
|
||||||
|
}
|
||||||
|
if (!args || !Array.isArray(args.operations)) continue;
|
||||||
|
let changed = false;
|
||||||
|
for (const op of args.operations) {
|
||||||
|
if (!op || typeof op !== 'object') continue;
|
||||||
|
for (const field of ['search', 'replace']) {
|
||||||
|
const next = excerptLines(op[field]);
|
||||||
|
if (next !== op[field]) {
|
||||||
|
op[field] = next;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
tc.function.arguments = JSON.stringify(args);
|
||||||
|
compacted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return compacted;
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
* OPENROUTER_API_KEY=sk-or-...
|
* OPENROUTER_API_KEY=sk-or-...
|
||||||
* MODEL=xiaomi/mimo-v2.6-flash
|
* MODEL=xiaomi/mimo-v2.6-flash
|
||||||
* UNDO_FILE=.fido-undo.json
|
* UNDO_FILE=.fido-undo.json
|
||||||
* SUSPEND_FILE=.fido-suspends.json (optional, stores /suspend snapshots)
|
* SUSPEND_DIR=.fido-suspends (optional, directory of /suspend snapshot files)
|
||||||
* TAVILY_API_KEY=tvly-dev-... (optional, enables the websearch and browse_url tools)
|
* TAVILY_API_KEY=tvly-dev-... (optional, enables the websearch and browse_url tools)
|
||||||
* ROOT_DIR=. (optional, defaults to cwd)
|
* ROOT_DIR=. (optional, defaults to cwd)
|
||||||
*
|
*
|
||||||
@@ -29,7 +29,7 @@ dotenv.config({ path: path.resolve(process.cwd(), '.env') });
|
|||||||
export const API_KEY = process.env.OPENROUTER_API_KEY;
|
export const API_KEY = process.env.OPENROUTER_API_KEY;
|
||||||
export const MODEL = process.env.MODEL || 'xiaomi/mimo-v2.6-flash';
|
export const MODEL = process.env.MODEL || 'xiaomi/mimo-v2.6-flash';
|
||||||
export const UNDO_FILE = process.env.UNDO_FILE || '.fido-undo.json';
|
export const UNDO_FILE = process.env.UNDO_FILE || '.fido-undo.json';
|
||||||
export const SUSPEND_FILE = process.env.SUSPEND_FILE || '.fido-suspends.json';
|
export const SUSPEND_DIR = process.env.SUSPEND_DIR || '.fido-suspends';
|
||||||
export const TAVILY_API_KEY = process.env.TAVILY_API_KEY;
|
export const TAVILY_API_KEY = process.env.TAVILY_API_KEY;
|
||||||
|
|
||||||
// Root dir: first positional arg wins, then env ROOT_DIR, then cwd
|
// Root dir: first positional arg wins, then env ROOT_DIR, then cwd
|
||||||
|
|||||||
11
src/fido.js
11
src/fido.js
@@ -28,7 +28,7 @@ import { markedTerminal } from 'marked-terminal';
|
|||||||
|
|
||||||
import { LLMOrbiter, OpenRouterProvider } from 'llmorbiter';
|
import { LLMOrbiter, OpenRouterProvider } from 'llmorbiter';
|
||||||
|
|
||||||
import { API_KEY, MODEL, UNDO_FILE, SUSPEND_FILE, ROOT_DIR } from './config.js';
|
import { API_KEY, MODEL, UNDO_FILE, SUSPEND_DIR, ROOT_DIR } from './config.js';
|
||||||
import { listDir } from './dir-listing.js';
|
import { listDir } from './dir-listing.js';
|
||||||
import { loadUndoLog, getUndoLog } from './undo.js';
|
import { loadUndoLog, getUndoLog } from './undo.js';
|
||||||
import { loadSuspends, addSuspend, getSuspends } from './suspend.js';
|
import { loadSuspends, addSuspend, getSuspends } from './suspend.js';
|
||||||
@@ -36,6 +36,7 @@ import { tools, toolCallNotifications, toolResultNotifications } from './tools/i
|
|||||||
import { printToolCall, emitGenericToolResult } from './tools/notify.js';
|
import { printToolCall, emitGenericToolResult } from './tools/notify.js';
|
||||||
import { autocompleteInput } from './autocomplete-input.js';
|
import { autocompleteInput } from './autocomplete-input.js';
|
||||||
import { COMMANDS, findCommand } from './slash-commands.js';
|
import { COMMANDS, findCommand } from './slash-commands.js';
|
||||||
|
import { compactPatchToolCalls } from './compact.js';
|
||||||
|
|
||||||
// ─── Markdown → console renderer ───────────────────────────────────────────────
|
// ─── Markdown → console renderer ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -141,7 +142,7 @@ function printBanner() {
|
|||||||
console.log(chalk.dim(` Workspace: ${ROOT_DIR}`));
|
console.log(chalk.dim(` Workspace: ${ROOT_DIR}`));
|
||||||
console.log(chalk.dim(` Model: ${MODEL}`));
|
console.log(chalk.dim(` Model: ${MODEL}`));
|
||||||
console.log(chalk.dim(` Undo log: ${path.resolve(ROOT_DIR, UNDO_FILE)}`));
|
console.log(chalk.dim(` Undo log: ${path.resolve(ROOT_DIR, UNDO_FILE)}`));
|
||||||
console.log(chalk.dim(` Suspends: ${path.resolve(ROOT_DIR, SUSPEND_FILE)}`));
|
console.log(chalk.dim(` Suspends: ${path.resolve(ROOT_DIR, SUSPEND_DIR)}`));
|
||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,7 +392,7 @@ async function main() {
|
|||||||
addSuspend(description, messages);
|
addSuspend(description, messages);
|
||||||
const count = getSuspends().length;
|
const count = getSuspends().length;
|
||||||
console.log(chalk.green(`\n ⏸ Suspended: ${description}`));
|
console.log(chalk.green(`\n ⏸ Suspended: ${description}`));
|
||||||
console.log(chalk.dim(` ${count} snapshot${count === 1 ? '' : 's'} in ${SUSPEND_FILE}\n`));
|
console.log(chalk.dim(` ${count} snapshot${count === 1 ? '' : 's'} in ${SUSPEND_DIR}\n`));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,6 +447,10 @@ async function main() {
|
|||||||
|
|
||||||
// Update conversation history for next turn
|
// Update conversation history for next turn
|
||||||
messages = result.messages;
|
messages = result.messages;
|
||||||
|
// 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);
|
||||||
|
|
||||||
printAssistant(result.content);
|
printAssistant(result.content);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,49 +1,108 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suspend snapshots — JSON array, newest-first, persisted on every save.
|
* Suspend snapshots — one JSON file per snapshot, stored in a directory.
|
||||||
*
|
*
|
||||||
* `/suspend <description>` captures the current conversation state under a
|
* `/suspend <description>` captures the current conversation state under a
|
||||||
* short description so it can be listed and restored later via `/recall`.
|
* short description so it can be listed and restored later via `/recall`.
|
||||||
* Each entry is:
|
* Each snapshot is written to its own file in `SUSPEND_DIR`, named with a
|
||||||
* { description, createdAt, messages }
|
* sortable timestamp and a slug of the description so it can be opened and
|
||||||
|
* edited individually:
|
||||||
|
*
|
||||||
|
* .fido-suspends/20240115-103000-fix-the-parser.json
|
||||||
|
*
|
||||||
|
* Each file contains: { description, createdAt, messages }
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { ROOT_DIR, SUSPEND_FILE } from './config.js';
|
import { ROOT_DIR, SUSPEND_DIR } from './config.js';
|
||||||
|
|
||||||
let snapshots = [];
|
let snapshots = [];
|
||||||
|
|
||||||
function suspendPath() {
|
function suspendDir() {
|
||||||
return path.resolve(ROOT_DIR, SUSPEND_FILE);
|
return path.resolve(ROOT_DIR, SUSPEND_DIR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Filesystem-safe, sortable local timestamp: `YYYYMMDD-HHMMSS`. */
|
||||||
|
function timestamp(date = new Date()) {
|
||||||
|
const p = (n) => String(n).padStart(2, '0');
|
||||||
|
return (
|
||||||
|
`${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}` +
|
||||||
|
`-${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn a description into a short, lowercase, filesystem-safe slug. */
|
||||||
|
function slugify(text) {
|
||||||
|
const slug = String(text ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 60)
|
||||||
|
.replace(/-+$/g, '');
|
||||||
|
return slug || 'suspend';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-colliding `<dir>/<base>.json` path (adds `-2`, `-3`, … if needed). */
|
||||||
|
function uniquePath(dir, base) {
|
||||||
|
let candidate = path.join(dir, `${base}.json`);
|
||||||
|
let n = 2;
|
||||||
|
while (fs.existsSync(candidate)) {
|
||||||
|
candidate = path.join(dir, `${base}-${n}.json`);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load every snapshot file from `SUSPEND_DIR`, newest first. */
|
||||||
export function loadSuspends() {
|
export function loadSuspends() {
|
||||||
const p = suspendPath();
|
|
||||||
if (fs.existsSync(p)) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
||||||
snapshots = Array.isArray(data) ? data : [];
|
|
||||||
} catch {
|
|
||||||
snapshots = [];
|
snapshots = [];
|
||||||
|
const dir = suspendDir();
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
|
||||||
|
let names;
|
||||||
|
try {
|
||||||
|
names = fs.readdirSync(dir);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loaded = [];
|
||||||
|
for (const name of names) {
|
||||||
|
if (!name.toLowerCase().endsWith('.json')) continue;
|
||||||
|
const full = path.join(dir, name);
|
||||||
|
try {
|
||||||
|
if (!fs.statSync(full).isFile()) continue;
|
||||||
|
const data = JSON.parse(fs.readFileSync(full, 'utf8'));
|
||||||
|
if (data && typeof data === 'object') loaded.push(data);
|
||||||
|
} catch {
|
||||||
|
// Skip unreadable or malformed snapshot files.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveSuspends() {
|
loaded.sort((a, b) =>
|
||||||
fs.writeFileSync(suspendPath(), JSON.stringify(snapshots, null, 2) + '\n', 'utf8');
|
String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')),
|
||||||
|
);
|
||||||
|
snapshots = loaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Capture `messages` under `description` (newest first) and persist. */
|
/**
|
||||||
|
* Capture `messages` under `description` (newest first) and write it to its own
|
||||||
|
* file in `SUSPEND_DIR`. Returns the stored entry.
|
||||||
|
*/
|
||||||
export function addSuspend(description, messages) {
|
export function addSuspend(description, messages) {
|
||||||
|
const now = new Date();
|
||||||
const entry = {
|
const entry = {
|
||||||
description,
|
description,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: now.toISOString(),
|
||||||
messages,
|
messages,
|
||||||
};
|
};
|
||||||
|
const dir = suspendDir();
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const file = uniquePath(dir, `${timestamp(now)}-${slugify(description)}`);
|
||||||
|
fs.writeFileSync(file, JSON.stringify(entry, null, 2) + '\n', 'utf8');
|
||||||
snapshots.unshift(entry);
|
snapshots.unshift(entry);
|
||||||
saveSuspends();
|
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,16 @@
|
|||||||
* - Child processes run with color and interactivity disabled (see
|
* - Child processes run with color and interactivity disabled (see
|
||||||
* NONINTERACTIVE_ENV) and with stdin closed, so a command can never block
|
* NONINTERACTIVE_ENV) and with stdin closed, so a command can never block
|
||||||
* on a terminal prompt.
|
* on a terminal prompt.
|
||||||
* - Every command requires interactive approval via the same inquirer/chalk
|
* - Every command requires interactive approval via a select menu (run once,
|
||||||
* styling used by the ask_user tool.
|
* allow for the rest of this session, reject, or reject with a comment),
|
||||||
|
* using the same inquirer/chalk styling as the ask_user tool.
|
||||||
* - A hard timeout kills the whole process group; output is trimmed to the
|
* - A hard timeout kills the whole process group; output is trimmed to the
|
||||||
* last `tail_lines` lines before being returned to the model.
|
* last `tail_lines` lines before being returned to the model.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { confirm } from '@inquirer/prompts';
|
import { input, select } from '@inquirer/prompts';
|
||||||
import { defineTool, ToolError } from 'llmorbiter';
|
import { defineTool, ToolError } from 'llmorbiter';
|
||||||
import { ROOT_DIR } from '../config.js';
|
import { ROOT_DIR } from '../config.js';
|
||||||
import { emitToolCall, emitToolResult, clip } from './notify.js';
|
import { emitToolCall, emitToolResult, clip } from './notify.js';
|
||||||
@@ -80,6 +81,10 @@ function tailLines(text, maxLines) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Session-scoped approval: once the user answers "a", every later command runs
|
||||||
|
// without prompting until fido exits.
|
||||||
|
let sessionApproved = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ask the user to approve a command before it is executed.
|
* Ask the user to approve a command before it is executed.
|
||||||
* Throws ToolError when the user declines or when no TTY is available.
|
* Throws ToolError when the user declines or when no TTY is available.
|
||||||
@@ -99,26 +104,74 @@ async function requestApproval(command, timeoutMs) {
|
|||||||
console.log(chalk.white(' ' + command.replace(/\r?\n/g, '\n ')));
|
console.log(chalk.white(' ' + command.replace(/\r?\n/g, '\n ')));
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
let approved = false;
|
// A prior "yes for this session" skips the prompt entirely.
|
||||||
|
if (sessionApproved) {
|
||||||
|
console.log(chalk.dim(' ⚡ Auto-approved (allowed for this session).'));
|
||||||
|
console.log('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let choice;
|
||||||
try {
|
try {
|
||||||
approved = await confirm({
|
choice = await select({
|
||||||
message: chalk.bold.yellow('Approve running this command?'),
|
message: chalk.bold.yellow('Approve running this command?'),
|
||||||
default: false,
|
choices: [
|
||||||
|
{ name: 'Yes — run it once', value: 'Y' },
|
||||||
|
{ name: 'Yes — allow for the rest of this session', value: 'a' },
|
||||||
|
{ name: 'No — skip this command', value: 'n' },
|
||||||
|
{ name: 'No — skip and leave a comment', value: 'c' },
|
||||||
|
],
|
||||||
|
default: 'Y',
|
||||||
prefix: chalk.yellow('›'),
|
prefix: chalk.yellow('›'),
|
||||||
theme: {
|
theme: {
|
||||||
prefix: chalk.yellow('›'),
|
prefix: chalk.yellow('›'),
|
||||||
style: { message: chalk.bold.yellow },
|
style: { message: chalk.bold.yellow, highlight: chalk.yellow.bold },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Ctrl+C / EOF during the prompt → treat as a rejection.
|
// Ctrl+C / EOF during the prompt → treat as a rejection.
|
||||||
approved = false;
|
choice = 'n';
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
if (!approved) {
|
|
||||||
throw new ToolError('Command rejected by the user.');
|
if (choice === 'Y') {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (choice === 'a') {
|
||||||
|
sessionApproved = true;
|
||||||
|
console.log(chalk.dim(' ✓ Allowed — shell commands will run without prompting for the rest of this session.'));
|
||||||
|
console.log('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (choice === 'c') {
|
||||||
|
let comment = '';
|
||||||
|
try {
|
||||||
|
comment = (
|
||||||
|
await input({
|
||||||
|
message: chalk.bold.yellow('Reason (optional comment)'),
|
||||||
|
theme: {
|
||||||
|
prefix: chalk.yellow('›'),
|
||||||
|
style: { answer: chalk.white, message: chalk.bold.yellow },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).trim();
|
||||||
|
} catch {
|
||||||
|
// Ctrl+C / EOF during the comment prompt → reject with no comment.
|
||||||
|
comment = '';
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
throw new ToolError(
|
||||||
|
comment
|
||||||
|
? `Command rejected by the user. Comment: ${comment}`
|
||||||
|
: 'Command rejected by the user.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'n' or anything unexpected
|
||||||
|
throw new ToolError('Command rejected by the user.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user