Merge origin/main into omp-506
This commit is contained in:
Generated
+3
-3
@@ -1245,9 +1245,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "html-to-markdown-rs"
|
||||
version = "2.28.6"
|
||||
version = "2.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6869b5e058b5ebb8c176269406b692d0695b4b19c36e532b56a2c355590978ae"
|
||||
checksum = "9013679b8c3600142e5a8f742748c3c38c49d9fc50675dad62f8f1721090a85a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"astral-tl",
|
||||
@@ -2114,7 +2114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-natives"
|
||||
version = "13.14.2"
|
||||
version = "13.15.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"ast-grep-core",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ exclude = ["crates/brush-core-vendored", "crates/brush-builtins-vendored"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "13.14.2"
|
||||
version = "13.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Can Boluk"]
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"noUnusedImports": "error",
|
||||
"noVoidTypeReturn": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off",
|
||||
"useConst": "error",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"packages/agent": {
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "13.14.2",
|
||||
"version": "13.15.0",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "workspace:*",
|
||||
"@oh-my-pi/pi-utils": "workspace:*",
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "13.14.2",
|
||||
"version": "13.15.0",
|
||||
"bin": {
|
||||
"pi-ai": "./src/cli.ts",
|
||||
},
|
||||
@@ -51,7 +51,7 @@
|
||||
},
|
||||
"packages/coding-agent": {
|
||||
"name": "@oh-my-pi/pi-coding-agent",
|
||||
"version": "13.14.2",
|
||||
"version": "13.15.0",
|
||||
"bin": {
|
||||
"omp": "src/cli.ts",
|
||||
},
|
||||
@@ -80,7 +80,7 @@
|
||||
},
|
||||
"packages/natives": {
|
||||
"name": "@oh-my-pi/pi-natives",
|
||||
"version": "13.14.2",
|
||||
"version": "13.15.0",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-utils": "workspace:*",
|
||||
},
|
||||
@@ -114,7 +114,7 @@
|
||||
},
|
||||
"packages/stats": {
|
||||
"name": "@oh-my-pi/omp-stats",
|
||||
"version": "13.14.2",
|
||||
"version": "13.15.0",
|
||||
"bin": {
|
||||
"omp-stats": "./src/index.ts",
|
||||
},
|
||||
@@ -139,7 +139,7 @@
|
||||
},
|
||||
"packages/swarm-extension": {
|
||||
"name": "@oh-my-pi/swarm-extension",
|
||||
"version": "13.14.2",
|
||||
"version": "13.15.0",
|
||||
"bin": {
|
||||
"omp-swarm": "src/cli.ts",
|
||||
},
|
||||
@@ -152,7 +152,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@oh-my-pi/pi-tui",
|
||||
"version": "13.14.2",
|
||||
"version": "13.15.0",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "workspace:*",
|
||||
"@oh-my-pi/pi-utils": "workspace:*",
|
||||
@@ -165,7 +165,7 @@
|
||||
},
|
||||
"packages/utils": {
|
||||
"name": "@oh-my-pi/pi-utils",
|
||||
"version": "13.14.2",
|
||||
"version": "13.15.0",
|
||||
"dependencies": {
|
||||
"beautiful-mermaid": "^1.1",
|
||||
"winston": "^3.19",
|
||||
@@ -467,21 +467,21 @@
|
||||
|
||||
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
|
||||
|
||||
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260321.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260321.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260321.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260321.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260321.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260321.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260321.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260321.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-uScJZRWRxyi1l4EWwOtuO88Gh8sUTi0itcI4oKlyNtXkqik4Y7EHfs1sfYPDuAEJO3cvW6bqohHjGx3mcXSZzQ=="],
|
||||
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260322.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260322.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260322.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260322.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260322.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260322.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260322.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260322.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-CmzQTKvesYHmz3g92G+XPDis25ocvHqa/gK8m98w+bML99KJLEWQKVlvkLrYA85JiJEK+XBIiz+6lCgUqRkWXA=="],
|
||||
|
||||
"@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260321.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3LQP363bDCF/pmXqzhSCSkKXr1PpNl2elC167YFRPKRyJdrETiIwj3YAB8A6esn9D30pas5VLzfmeK/tUOf+6g=="],
|
||||
"@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260322.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5wSilxwLGX5fMKJgsUkCBwOfW9GMG3WF5j77CVBOdFI7miFaR3JQaPzTA+uyHDMNIIeSDo1KtV77GT48Y/d0Xg=="],
|
||||
|
||||
"@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260321.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-gCoKiv415CROgl0K8hEV8Lw/zvbYriWWmD7VxvpiQiTRqQmHppVXhLtb2OrGaPcsqpoBdYeCJQHN4wnohAkNLA=="],
|
||||
"@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260322.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-G806SrfxkYNAgZ9Xk53+OvbmIg9iD5hjaiD2QhDQL2aZjzy10D4MhcdaZEOoMfw0OI/PoJPYOiPD+9/x2kw3Lg=="],
|
||||
|
||||
"@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260321.1", "", { "os": "linux", "cpu": "arm" }, "sha512-QuAFR9eFQzuqtKTIaJ5XkNR4i5Q55b1SE7fUcIAS528aY9j+5P1cMpvJa8aOBCuRKxfMgV5UtamolZKGWWzaMw=="],
|
||||
"@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260322.1", "", { "os": "linux", "cpu": "arm" }, "sha512-0a12pp19ELiNHMqTglfQQQNMsxvtzpjAa4qf12oMJoGyy+UnguKEmaaaCHdp75KvBXGDzlssfDAdiy+NirN19A=="],
|
||||
|
||||
"@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260321.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-15z7UWt0PG870ktcUbaa0NogAjXIYT4pSFWlsc95u8+1aITrBTMQgqRih5qUH8bHke3eeYwbpjfXaU4gNmexvw=="],
|
||||
"@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260322.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+FyomEEt3K8TBO//n3Ijr61SDM2F7cxZCVqGt+Wk3rLcOCQ2i+8+p64gdsZCmImy3CyP0hBnxPydEbyNkZLtvg=="],
|
||||
|
||||
"@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260321.1", "", { "os": "linux", "cpu": "x64" }, "sha512-8yuzwkxQnNSpXjXK43Y5Pn6rBfNbJVIcd3Qh9n3Tzhgtr+lcoGgwgMvn8axnqaazkxIUB3PZuiGRcqr6XIq3LA=="],
|
||||
"@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260322.1", "", { "os": "linux", "cpu": "x64" }, "sha512-MviQe5x4WqQGv/Vhu4hcv2A0qTW/BTaZPbOLYCtvhuovNFO6D++ZmJAbHvA0h/bJEaNTgxKZdZPHMpCfSEKfjA=="],
|
||||
|
||||
"@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260321.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-fCUk/VElUjMFmE6iFAtsy5r7kLxeLggEHOTWuR0HGYIUQze6EyAdDFqMPFFxvbzpUyFQFpRfUa0I/Fa5tqKh8g=="],
|
||||
"@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260322.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-ibnMaXDJPSgMXKC61NHiFlww/xjAEINgc1mcn2ntTfuGHwduU4P9Bi038TxXg95Wmu3v6xIPIorXXsBOdE+p3Q=="],
|
||||
|
||||
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260321.1", "", { "os": "win32", "cpu": "x64" }, "sha512-CWGyck7+sbNwOhcL+ObHhtKZe2/+Y6OZlEdWX2mHjpv8ef7ohUbPCdS94p+e7jbVY56w8NAce2Xx7ppn/C1Ucg=="],
|
||||
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260322.1", "", { "os": "win32", "cpu": "x64" }, "sha512-O+r1RToWBbGkK7NXC7DpraLObSWyxvSqRiSfr/BlZ351Cdq1q3121zCGzVtqERGeRtVoEMRrzS5ITOd6On/pCw=="],
|
||||
|
||||
"@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="],
|
||||
|
||||
@@ -941,7 +941,7 @@
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
||||
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
|
||||
@@ -576,12 +576,18 @@ pub(crate) async fn invoke_command_in_subshell_and_get_output(
|
||||
rt.block_on(run_substitution_command(subshell, params, s))
|
||||
});
|
||||
|
||||
// Extract output.
|
||||
let output_str = std::io::read_to_string(reader)?;
|
||||
// Read subshell output on a blocking thread to avoid stalling the
|
||||
// async runtime when the pipe stays open (e.g. a hung child process).
|
||||
let output_join_handle = tokio::task::spawn_blocking(move || {
|
||||
std::io::read_to_string(reader)
|
||||
});
|
||||
|
||||
// Now observe the command's completion.
|
||||
let run_result = cmd_join_handle.await?;
|
||||
let cmd_result = run_result?;
|
||||
// Wait for both the output reader and the command to complete.
|
||||
let (output_result, cmd_result) = tokio::join!(output_join_handle, cmd_join_handle);
|
||||
let output_str = output_result
|
||||
.map_err(|e| std::io::Error::other(e))??;
|
||||
let cmd_result = cmd_result
|
||||
.map_err(|e| std::io::Error::other(e))??;
|
||||
|
||||
// Store the status.
|
||||
*shell.last_exit_status_mut() = cmd_result.exit_code.into();
|
||||
|
||||
@@ -5,15 +5,15 @@ Use it for any merge: single file, feature branch, or full release sync.
|
||||
|
||||
## Last Sync Point
|
||||
|
||||
**Commit:** `1feccfedcb1eeeca91be0b9d389e8e5a9daee505`
|
||||
**Date:** 2026-03-14
|
||||
**Commit:** `b21b42d032919de2f2e6920a76fa9a37c3920c0a`
|
||||
**Date:** 2026-03-22
|
||||
|
||||
Update this section after each sync; do not reuse the previous range.
|
||||
|
||||
When starting a new sync, generate patches from this commit forward:
|
||||
|
||||
```bash
|
||||
git format-patch 15e0957b045d9e0d49253b2285cb585cf3a75c55..HEAD --stdout > changes.patch
|
||||
git format-patch b21b42d032919de2f2e6920a76fa9a37c3920c0a..HEAD --stdout > changes.patch
|
||||
```
|
||||
|
||||
## 0) Define the scope
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "13.14.2",
|
||||
"version": "13.15.0",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"homepage": "https://github.com/can1357/oh-my-pi",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -196,7 +196,6 @@ async function runLoop(
|
||||
// Outer loop: continues when queued follow-up messages arrive after agent would stop
|
||||
while (true) {
|
||||
let hasMoreToolCalls = true;
|
||||
let steeringAfterTools: AgentMessage[] | null = null;
|
||||
|
||||
// Inner loop: process tool calls and steering messages
|
||||
while (hasMoreToolCalls || pendingMessages.length > 0) {
|
||||
@@ -225,6 +224,7 @@ async function runLoop(
|
||||
// Stream assistant response
|
||||
const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn);
|
||||
newMessages.push(message);
|
||||
let steeringMessagesFromExecution: AgentMessage[] | undefined;
|
||||
|
||||
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
||||
// Create placeholder tool results for any tool calls in the aborted message
|
||||
@@ -250,19 +250,20 @@ async function runLoop(
|
||||
|
||||
const toolResults: ToolResultMessage[] = [];
|
||||
if (hasMoreToolCalls) {
|
||||
const toolExecution = await executeToolCalls(
|
||||
const executionResult = await executeToolCalls(
|
||||
currentContext.tools,
|
||||
message,
|
||||
signal,
|
||||
stream,
|
||||
config.getSteeringMessages,
|
||||
config.getToolContext,
|
||||
config.interruptMode,
|
||||
config.getToolContext,
|
||||
config.transformToolCallArguments,
|
||||
config.intentTracing,
|
||||
);
|
||||
toolResults.push(...toolExecution.toolResults);
|
||||
steeringAfterTools = toolExecution.steeringMessages ?? null;
|
||||
|
||||
toolResults.push(...executionResult.toolResults);
|
||||
steeringMessagesFromExecution = executionResult.steeringMessages;
|
||||
|
||||
for (const result of toolResults) {
|
||||
currentContext.messages.push(result);
|
||||
@@ -272,13 +273,7 @@ async function runLoop(
|
||||
|
||||
stream.push({ type: "turn_end", message, toolResults });
|
||||
|
||||
// Get steering messages after turn completes
|
||||
if (steeringAfterTools && steeringAfterTools.length > 0) {
|
||||
pendingMessages = steeringAfterTools;
|
||||
steeringAfterTools = null;
|
||||
} else {
|
||||
pendingMessages = (await config.getSteeringMessages?.()) || [];
|
||||
}
|
||||
pendingMessages = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []);
|
||||
}
|
||||
|
||||
// Agent would stop here. Check for follow-up messages.
|
||||
@@ -433,25 +428,37 @@ async function executeToolCalls(
|
||||
signal: AbortSignal | undefined,
|
||||
stream: EventStream<AgentEvent, AgentMessage[]>,
|
||||
getSteeringMessages?: AgentLoopConfig["getSteeringMessages"],
|
||||
getToolContext?: AgentLoopConfig["getToolContext"],
|
||||
interruptMode: AgentLoopConfig["interruptMode"] = "immediate",
|
||||
getToolContext?: AgentLoopConfig["getToolContext"],
|
||||
transformToolCallArguments?: AgentLoopConfig["transformToolCallArguments"],
|
||||
intentTracing?: AgentLoopConfig["intentTracing"],
|
||||
): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> {
|
||||
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
|
||||
const toolCalls = assistantMessage.content.filter((c): c is ToolCallContent => c.type === "toolCall");
|
||||
const emittedToolResults: ToolResultMessage[] = [];
|
||||
let steeringMessages: AgentMessage[] | undefined;
|
||||
const shouldInterruptImmediately = interruptMode !== "wait";
|
||||
const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name }));
|
||||
const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`;
|
||||
const shouldInterruptImmediately = interruptMode !== "wait";
|
||||
const steeringAbortController = new AbortController();
|
||||
const toolSignal = signal
|
||||
? AbortSignal.any([signal, steeringAbortController.signal])
|
||||
: steeringAbortController.signal;
|
||||
const interruptState = { triggered: false };
|
||||
let steeringMessages: AgentMessage[] | undefined;
|
||||
let steeringCheck: Promise<void> | null = null;
|
||||
|
||||
const records = toolCalls.map(toolCall => ({
|
||||
toolCall,
|
||||
tool: tools?.find(t => t.name === toolCall.name),
|
||||
args: toolCall.arguments as Record<string, unknown>,
|
||||
started: false,
|
||||
result: undefined as AgentToolResult<any> | undefined,
|
||||
isError: false,
|
||||
skipped: false,
|
||||
toolResultMessage: undefined as ToolResultMessage | undefined,
|
||||
resultEmitted: false,
|
||||
}));
|
||||
|
||||
const checkSteering = async (): Promise<void> => {
|
||||
if (!shouldInterruptImmediately || !getSteeringMessages || interruptState.triggered) {
|
||||
return;
|
||||
@@ -473,18 +480,6 @@ async function executeToolCalls(
|
||||
await steeringCheck;
|
||||
};
|
||||
|
||||
const records = toolCalls.map(toolCall => ({
|
||||
toolCall,
|
||||
tool: tools?.find(t => t.name === toolCall.name),
|
||||
args: toolCall.arguments as Record<string, unknown>,
|
||||
started: false,
|
||||
result: undefined as AgentToolResult<any> | undefined,
|
||||
isError: false,
|
||||
skipped: false,
|
||||
toolResultMessage: undefined as ToolResultMessage | undefined,
|
||||
resultEmitted: false,
|
||||
}));
|
||||
|
||||
const emitToolResult = (record: (typeof records)[number], result: AgentToolResult<any>, isError: boolean): void => {
|
||||
if (record.resultEmitted) return;
|
||||
const { toolCall } = record;
|
||||
@@ -578,7 +573,6 @@ async function executeToolCalls(
|
||||
transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs,
|
||||
tool.nonAbortable ? undefined : toolSignal,
|
||||
partialResult => {
|
||||
if (interruptState.triggered) return;
|
||||
stream.push({
|
||||
type: "tool_execution_update",
|
||||
toolCallId: toolCall.id,
|
||||
@@ -637,13 +631,6 @@ async function executeToolCalls(
|
||||
return { toolResults: emittedToolResults, steeringMessages };
|
||||
}
|
||||
|
||||
function createSkippedToolResult(): AgentToolResult<any> {
|
||||
return {
|
||||
content: [{ type: "text", text: "Skipped due to queued user message." }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tool result for a tool call that was aborted or errored before execution.
|
||||
* Maintains the tool_use/tool_result pairing required by the API.
|
||||
@@ -690,3 +677,10 @@ function createAbortedToolResult(
|
||||
|
||||
return toolResultMessage;
|
||||
}
|
||||
|
||||
function createSkippedToolResult(): AgentToolResult<any> {
|
||||
return {
|
||||
content: [{ type: "text", text: "Skipped due to queued user message." }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -603,33 +603,16 @@ describe("agentLoop with AgentMessage", () => {
|
||||
expect(text).not.toContain("Tool execution was aborted.:");
|
||||
}
|
||||
});
|
||||
it("should inject queued messages and skip remaining tool calls", async () => {
|
||||
it("should skip remaining tool calls when steering is queued", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const executed: string[] = [];
|
||||
const { promise: allowSecond, resolve: allowSecondResolve } = Promise.withResolvers<void>();
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo tool",
|
||||
parameters: toolSchema,
|
||||
async execute(_toolCallId, params, signal) {
|
||||
if (params.value === "second") {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Tool aborted"));
|
||||
return;
|
||||
}
|
||||
const onAbort = () => reject(new Error("Tool aborted"));
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
allowSecond.then(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Tool aborted");
|
||||
}
|
||||
}
|
||||
concurrency: "exclusive",
|
||||
async execute(_toolCallId, params) {
|
||||
executed.push(params.value);
|
||||
return {
|
||||
content: [{ type: "text", text: `ok:${params.value}` }],
|
||||
@@ -654,11 +637,11 @@ describe("agentLoop with AgentMessage", () => {
|
||||
const config: AgentLoopConfig = {
|
||||
model: createModel(),
|
||||
convertToLlm: identityConverter,
|
||||
interruptMode: "immediate",
|
||||
getSteeringMessages: async () => {
|
||||
// Return queued message after first tool executes
|
||||
if (executed.length === 1 && !queuedDelivered) {
|
||||
// Return steering message after tool execution has started
|
||||
if (executed.length >= 1 && !queuedDelivered) {
|
||||
queuedDelivered = true;
|
||||
allowSecondResolve();
|
||||
return [queuedUserMessage];
|
||||
}
|
||||
return [];
|
||||
@@ -700,29 +683,31 @@ describe("agentLoop with AgentMessage", () => {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
// Only first tool should have executed
|
||||
// Only the first tool should execute; the second is skipped after steering is queued.
|
||||
expect(executed).toEqual(["first"]);
|
||||
|
||||
// Second tool should be skipped
|
||||
const toolEnds = events.filter(
|
||||
(e): e is Extract<AgentEvent, { type: "tool_execution_end" }> => e.type === "tool_execution_end",
|
||||
);
|
||||
expect(toolEnds.length).toBe(2);
|
||||
expect(toolEnds[0].isError).toBeFalsy();
|
||||
expect(toolEnds[0].isError).toBe(false);
|
||||
expect(toolEnds[1].isError).toBe(true);
|
||||
if (toolEnds[1].result.content[0]?.type === "text") {
|
||||
expect(toolEnds[1].result.content[0].text).toContain("Skipped due to queued user message");
|
||||
}
|
||||
|
||||
// Queued message should appear in events
|
||||
const queuedMessageEvent = events.find(
|
||||
e =>
|
||||
e.type === "message_start" &&
|
||||
e.message.role === "user" &&
|
||||
typeof e.message.content === "string" &&
|
||||
e.message.content === "interrupt",
|
||||
);
|
||||
expect(queuedMessageEvent).toBeDefined();
|
||||
// Queued message should appear in events after the tool results and before the next model call.
|
||||
const eventSequence = events.flatMap(event => {
|
||||
if (event.type !== "message_start") return [];
|
||||
if (event.message.role === "toolResult") return [`tool:${event.message.toolCallId}`];
|
||||
if (event.message.role === "user" && typeof event.message.content === "string") {
|
||||
return [event.message.content];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
expect(eventSequence).toContain("interrupt");
|
||||
expect(eventSequence.indexOf("tool:tool-1")).toBeLessThan(eventSequence.indexOf("interrupt"));
|
||||
expect(eventSequence.indexOf("tool:tool-2")).toBeLessThan(eventSequence.indexOf("interrupt"));
|
||||
|
||||
// Interrupt message should be in context when second LLM call is made
|
||||
expect(sawInterruptInContext).toBe(true);
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [13.15.0] - 2026-03-23
|
||||
|
||||
### Added
|
||||
|
||||
- Added `isUsageLimitError()` to `rate-limit-utils` as a single source of truth for detecting usage/quota limit errors across all providers
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed lazy stream forwarding to properly handle final results from source streams with `result()` methods
|
||||
- Fixed lazy stream error handling to convert iterator failures into terminal error results instead of silently failing
|
||||
- Fixed `parseRateLimitReason` to recognize "usage limit" in error messages and correctly classify them as `QUOTA_EXHAUSTED`
|
||||
- Fixed Codex `fetchWithRetry` retrying 429 responses for `usage_limit_reached` errors for up to 5 minutes instead of returning immediately for credential switching
|
||||
- Removed `usage.?limit` from `TRANSIENT_MESSAGE_PATTERN` in retry utils since usage limits are not transient and require credential rotation
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "13.14.2",
|
||||
"version": "13.15.0",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"homepage": "https://github.com/can1357/oh-my-pi",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -16,6 +16,7 @@ export * from "./providers/google";
|
||||
export * from "./providers/google-gemini-cli";
|
||||
export * from "./providers/google-vertex";
|
||||
export * from "./providers/kimi";
|
||||
export type { OpenAICodexResponsesOptions } from "./providers/openai-codex-responses";
|
||||
export * from "./providers/openai-completions";
|
||||
export * from "./providers/openai-responses";
|
||||
export * from "./providers/synthetic";
|
||||
|
||||
@@ -360,6 +360,13 @@ function handleContentBlockStop(
|
||||
/**
|
||||
* Check if the model supports prompt caching.
|
||||
* Supported: Claude 3.5 Haiku, Claude 3.7 Sonnet, Claude 4.x+ models, Haiku 4.5+
|
||||
*
|
||||
* For base models and system-defined inference profiles the model ID / ARN
|
||||
* contains the model name, so we can decide locally.
|
||||
*
|
||||
* For application inference profiles (whose ARNs don't contain the model name),
|
||||
* set AWS_BEDROCK_FORCE_CACHE=1 to enable cache points. Amazon Nova models
|
||||
* have automatic caching and don't need explicit cache points.
|
||||
*/
|
||||
function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean {
|
||||
if (model.cost.cacheRead || model.cost.cacheWrite) return true;
|
||||
@@ -370,6 +377,9 @@ function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean
|
||||
if (id.includes("claude-3-7-sonnet") || id.includes("claude-3-5-haiku")) return true;
|
||||
// Claude Haiku 4.5+ (new naming)
|
||||
if (id.includes("claude-haiku")) return true;
|
||||
// Application inference profiles don't contain the model name in the ARN.
|
||||
// Allow users to force cache points via environment variable.
|
||||
if (typeof process !== "undefined" && process.env.AWS_BEDROCK_FORCE_CACHE === "1") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -376,6 +376,12 @@ export interface AnthropicOptions extends StreamOptions {
|
||||
betas?: string[] | string;
|
||||
/** Force OAuth bearer auth mode for proxy tokens that don't match Anthropic token prefixes. */
|
||||
isOAuth?: boolean;
|
||||
/**
|
||||
* Pre-built Anthropic client instance. When provided, skips internal client
|
||||
* construction entirely. Use this to inject alternative SDK clients such as
|
||||
* `AnthropicVertex` that shares the same messaging API.
|
||||
*/
|
||||
client?: Anthropic;
|
||||
}
|
||||
|
||||
export type AnthropicClientOptionsArgs = {
|
||||
@@ -611,19 +617,31 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
||||
let rawRequestDump: RawHttpRequestDump | undefined;
|
||||
|
||||
try {
|
||||
const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? "";
|
||||
const baseUrl = resolveAnthropicBaseUrl(model, apiKey) ?? "https://api.anthropic.com";
|
||||
let client: Anthropic;
|
||||
let isOAuthToken: boolean;
|
||||
|
||||
const { client, isOAuthToken } = createClient(model, {
|
||||
model,
|
||||
apiKey,
|
||||
extraBetas: normalizeExtraBetas(options?.betas),
|
||||
stream: true,
|
||||
interleavedThinking: options?.interleavedThinking ?? true,
|
||||
headers: options?.headers,
|
||||
dynamicHeaders: copilotDynamicHeaders?.headers,
|
||||
isOAuth: options?.isOAuth,
|
||||
});
|
||||
if (options?.client) {
|
||||
client = options.client;
|
||||
isOAuthToken = false;
|
||||
} else {
|
||||
const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? "";
|
||||
|
||||
const created = createClient(model, {
|
||||
model,
|
||||
apiKey,
|
||||
extraBetas: normalizeExtraBetas(options?.betas),
|
||||
stream: true,
|
||||
interleavedThinking: options?.interleavedThinking ?? true,
|
||||
headers: options?.headers,
|
||||
dynamicHeaders: copilotDynamicHeaders?.headers,
|
||||
isOAuth: options?.isOAuth,
|
||||
});
|
||||
client = created.client;
|
||||
isOAuthToken = created.isOAuthToken;
|
||||
}
|
||||
const baseUrl =
|
||||
resolveAnthropicBaseUrl(model, options?.apiKey ?? getEnvApiKey(model.provider) ?? "") ??
|
||||
"https://api.anthropic.com";
|
||||
let params = buildParams(model, baseUrl, context, isOAuthToken, options);
|
||||
const replacementPayload = await options?.onPayload?.(params, model);
|
||||
if (replacementPayload !== undefined) {
|
||||
@@ -661,6 +679,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
||||
for await (const event of anthropicStream) {
|
||||
started = true;
|
||||
if (event.type === "message_start") {
|
||||
output.responseId = event.message.id;
|
||||
// Capture initial token usage from message_start event
|
||||
// This ensures we have input token counts even if the stream is aborted early
|
||||
output.usage.input = event.message.usage.input_tokens || 0;
|
||||
|
||||
@@ -68,6 +68,20 @@ export function requiresToolCallId(modelId: string): boolean {
|
||||
return modelId.startsWith("claude-");
|
||||
}
|
||||
|
||||
function getGeminiMajorVersion(modelId: string): number | undefined {
|
||||
const match = modelId.toLowerCase().match(/^gemini(?:-live)?-(\d+)/);
|
||||
if (!match) return undefined;
|
||||
return Number.parseInt(match[1], 10);
|
||||
}
|
||||
|
||||
function supportsMultimodalFunctionResponse(modelId: string): boolean {
|
||||
const geminiMajorVersion = getGeminiMajorVersion(modelId);
|
||||
if (geminiMajorVersion !== undefined) {
|
||||
return geminiMajorVersion >= 3;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isGemini3Model(modelId: string): boolean {
|
||||
return modelId.includes("gemini-3");
|
||||
}
|
||||
@@ -189,10 +203,10 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
|
||||
const hasText = textResult.length > 0;
|
||||
const hasImages = imageContent.length > 0;
|
||||
|
||||
// Gemini 3 supports multimodal function responses with images nested inside functionResponse.parts
|
||||
// See: https://ai.google.dev/gemini-api/docs/function-calling#multimodal
|
||||
// Older models don't support this, so we put images in a separate user message.
|
||||
const supportsMultimodalFunctionResponse = model.id.includes("gemini-3");
|
||||
// Gemini 3+ models support multimodal function responses with images nested inside
|
||||
// functionResponse.parts. Claude and other non-Gemini models behind Cloud Code Assist /
|
||||
// Antigravity also accept this shape. Gemini < 3 still needs a separate user image turn.
|
||||
const modelSupportsMultimodalFunctionResponse = supportsMultimodalFunctionResponse(model.id);
|
||||
|
||||
// Use "output" key for success, "error" key for errors as per SDK documentation
|
||||
const responseValue = hasText ? textResult.toWellFormed() : hasImages ? "(see attached image)" : "";
|
||||
@@ -209,8 +223,7 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
|
||||
functionResponse: {
|
||||
name: msg.toolName,
|
||||
response: msg.isError ? { error: responseValue } : { output: responseValue },
|
||||
// Nest images inside functionResponse.parts for Gemini 3
|
||||
...(hasImages && supportsMultimodalFunctionResponse && { parts: imageParts }),
|
||||
...(hasImages && modelSupportsMultimodalFunctionResponse && { parts: imageParts }),
|
||||
...(includeId ? { id: msg.toolCallId } : {}),
|
||||
},
|
||||
};
|
||||
@@ -231,8 +244,8 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
|
||||
});
|
||||
}
|
||||
|
||||
// For older models, add images in a separate user message
|
||||
if (hasImages && !supportsMultimodalFunctionResponse) {
|
||||
// For Gemini < 3, add images in a separate user message
|
||||
if (hasImages && !modelSupportsMultimodalFunctionResponse) {
|
||||
contents.push({
|
||||
role: "user",
|
||||
parts: [{ text: "Tool result image:" }, ...imageParts],
|
||||
|
||||
@@ -811,7 +811,7 @@ function handleCodexStreamEvent(args: {
|
||||
return handleResponseCreated(runtime, rawEvent);
|
||||
}
|
||||
|
||||
if (eventType === "response.completed" || eventType === "response.done") {
|
||||
if (eventType === "response.completed" || eventType === "response.done" || eventType === "response.incomplete") {
|
||||
handleResponseCompleted(model, output, runtime, rawEvent);
|
||||
return firstTokenTime;
|
||||
}
|
||||
@@ -1046,6 +1046,9 @@ function handleResponseCompleted(
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
if (typeof response?.id === "string" && response.id.length > 0) {
|
||||
output.responseId = response.id;
|
||||
}
|
||||
|
||||
const state = runtime.websocketState;
|
||||
if (runtime.transport === "websocket" && state) {
|
||||
@@ -1764,6 +1767,7 @@ class CodexWebSocketConnection {
|
||||
if (
|
||||
eventType === "response.completed" ||
|
||||
eventType === "response.done" ||
|
||||
eventType === "response.incomplete" ||
|
||||
eventType === "response.failed" ||
|
||||
eventType === "error"
|
||||
) {
|
||||
|
||||
@@ -89,7 +89,13 @@ export function detectOpenAICompat(model: Model<"openai-completions">): Resolved
|
||||
requiresAssistantAfterToolResult: false,
|
||||
requiresThinkingAsText: isMistral,
|
||||
requiresMistralToolIds: isMistral,
|
||||
thinkingFormat: isZai ? "zai" : isAlibaba || isQwen ? "qwen" : "openai",
|
||||
thinkingFormat: isZai
|
||||
? "zai"
|
||||
: provider === "openrouter" || baseUrl.includes("openrouter.ai")
|
||||
? "openrouter"
|
||||
: isAlibaba || isQwen
|
||||
? "qwen"
|
||||
: "openai",
|
||||
reasoningContentField: "reasoning_content",
|
||||
requiresReasoningContentForToolCalls: isKimiModel,
|
||||
requiresAssistantContentForToolCalls: isKimiModel,
|
||||
|
||||
@@ -337,11 +337,17 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
||||
errorMessage: "OpenAI completions stream stalled while waiting for the next event",
|
||||
onIdle: () => requestAbortController.abort(),
|
||||
})) {
|
||||
if (!chunk || typeof chunk !== "object") continue;
|
||||
|
||||
// OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier,
|
||||
// and each chunk in a streamed completion carries the same id.
|
||||
output.responseId ||= chunk.id;
|
||||
|
||||
if (chunk.usage) {
|
||||
output.usage = parseChunkUsage(chunk.usage, model, copilotPremiumRequests);
|
||||
}
|
||||
|
||||
const choice = chunk.choices[0];
|
||||
const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
|
||||
if (!choice) continue;
|
||||
|
||||
if (!chunk.usage) {
|
||||
@@ -352,7 +358,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
||||
}
|
||||
|
||||
if (choice.finish_reason) {
|
||||
output.stopReason = mapStopReason(choice.finish_reason);
|
||||
const finishReasonResult = mapStopReason(choice.finish_reason);
|
||||
output.stopReason = finishReasonResult.stopReason;
|
||||
if (finishReasonResult.errorMessage) {
|
||||
output.errorMessage = finishReasonResult.errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
if (choice.delta) {
|
||||
@@ -463,8 +473,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
||||
throw new Error("Request was aborted");
|
||||
}
|
||||
|
||||
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
||||
throw new Error("An unknown error occurred");
|
||||
if (output.stopReason === "aborted") {
|
||||
throw new Error("Request was aborted");
|
||||
}
|
||||
if (output.stopReason === "error") {
|
||||
throw new Error(output.errorMessage || "Provider returned an error stop reason");
|
||||
}
|
||||
|
||||
output.duration = Date.now() - startTime;
|
||||
@@ -616,6 +629,12 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio
|
||||
Reflect.set(params, "enable_thinking", !!options?.reasoning);
|
||||
} else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) {
|
||||
Reflect.set(params, "chat_template_kwargs", { enable_thinking: !!options?.reasoning });
|
||||
} else if (compat.thinkingFormat === "openrouter" && options?.reasoning && model.reasoning) {
|
||||
// OpenRouter normalizes reasoning across providers via a nested reasoning object.
|
||||
const openRouterParams = params as typeof params & { reasoning?: { effort?: string } };
|
||||
openRouterParams.reasoning = {
|
||||
effort: mapReasoningEffort(options.reasoning, compat.reasoningEffortMap),
|
||||
};
|
||||
} else if (options?.reasoning && model.reasoning && compat.supportsReasoningEffort) {
|
||||
// OpenAI-style reasoning_effort
|
||||
Reflect.set(params, "reasoning_effort", mapReasoningEffort(options.reasoning, compat.reasoningEffortMap));
|
||||
@@ -1061,21 +1080,29 @@ function convertTools(tools: Tool[], compat: ResolvedOpenAICompat): OpenAI.Chat.
|
||||
});
|
||||
}
|
||||
|
||||
function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): StopReason {
|
||||
if (reason === null) return "stop";
|
||||
function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): {
|
||||
stopReason: StopReason;
|
||||
errorMessage?: string;
|
||||
} {
|
||||
if (reason === null) return { stopReason: "stop" };
|
||||
switch (reason) {
|
||||
case "stop":
|
||||
case "end":
|
||||
return "stop";
|
||||
return { stopReason: "stop" };
|
||||
case "length":
|
||||
return "length";
|
||||
return { stopReason: "length" };
|
||||
case "function_call":
|
||||
case "tool_calls":
|
||||
return "toolUse";
|
||||
return { stopReason: "toolUse" };
|
||||
case "content_filter":
|
||||
return "error";
|
||||
return { stopReason: "error", errorMessage: "Provider finish_reason: content_filter" };
|
||||
case "network_error":
|
||||
return { stopReason: "error", errorMessage: "Provider finish_reason: network_error" };
|
||||
default:
|
||||
throw new Error(`Unhandled stop reason: ${reason}`);
|
||||
return {
|
||||
stopReason: "error",
|
||||
errorMessage: `Provider finish_reason: ${reason}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,8 +52,26 @@ export function parseTextSignature(
|
||||
return { id: signature };
|
||||
}
|
||||
|
||||
export function normalizeResponsesToolCallIdForTransform(id: string): string {
|
||||
export function normalizeResponsesToolCallIdForTransform(
|
||||
id: string,
|
||||
model?: Model<Api>,
|
||||
source?: AssistantMessage,
|
||||
): string {
|
||||
if (!id.includes("|")) return id;
|
||||
const isForeignToolCall =
|
||||
source != null && model != null && (source.provider !== model.provider || source.api !== model.api);
|
||||
if (isForeignToolCall) {
|
||||
const [callId, itemId] = id.split("|");
|
||||
const normalizeIdPart = (part: string): string => {
|
||||
const sanitized = part.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
const truncated = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized;
|
||||
return truncated.replace(/_+$/, "");
|
||||
};
|
||||
const normalizedCallId = normalizeIdPart(callId);
|
||||
let normalizedItemId = `fc_${Bun.hash(itemId).toString(36)}`;
|
||||
if (normalizedItemId.length > 64) normalizedItemId = normalizedItemId.slice(0, 64);
|
||||
return `${normalizedCallId}|${normalizedItemId}`;
|
||||
}
|
||||
const normalized = normalizeResponsesToolCallId(id);
|
||||
return `${normalized.callId}|${normalized.itemId}`;
|
||||
}
|
||||
@@ -221,7 +239,9 @@ export async function processResponsesStream<TApi extends Api>(
|
||||
let sawFirstToken = false;
|
||||
|
||||
for await (const event of openaiStream) {
|
||||
if (event.type === "response.output_item.added") {
|
||||
if (event.type === "response.created") {
|
||||
output.responseId = event.response.id;
|
||||
} else if (event.type === "response.output_item.added") {
|
||||
if (!sawFirstToken) {
|
||||
sawFirstToken = true;
|
||||
options?.onFirstToken?.();
|
||||
@@ -376,6 +396,9 @@ export async function processResponsesStream<TApi extends Api>(
|
||||
}
|
||||
} else if (event.type === "response.completed") {
|
||||
const response = event.response;
|
||||
if (response?.id) {
|
||||
output.responseId = response.id;
|
||||
}
|
||||
if (response?.usage) {
|
||||
const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0;
|
||||
output.usage = {
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Lazy provider module loading.
|
||||
*
|
||||
* Each provider module is loaded only when its stream function is first called.
|
||||
* This avoids eagerly importing heavy SDK dependencies (e.g., @anthropic-ai/sdk,
|
||||
* openai) at startup. The loaded module promise is cached so subsequent calls
|
||||
* reuse the same import.
|
||||
*
|
||||
* NOTE: stream.ts currently imports providers directly, so this file is not yet
|
||||
* wired into the main streaming path. It provides the infrastructure for lazy
|
||||
* loading that can be integrated when stream.ts is refactored.
|
||||
*/
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
AssistantMessageEvent,
|
||||
AssistantMessageEventStream,
|
||||
Context,
|
||||
Model,
|
||||
OptionsForApi,
|
||||
} from "../types";
|
||||
import { AssistantMessageEventStream as EventStreamImpl } from "../utils/event-stream";
|
||||
import type { BedrockOptions } from "./amazon-bedrock";
|
||||
import type { AnthropicOptions } from "./anthropic";
|
||||
import type { AzureOpenAIResponsesOptions } from "./azure-openai-responses";
|
||||
import type { CursorOptions } from "./cursor";
|
||||
import type { GoogleOptions } from "./google";
|
||||
import type { GoogleGeminiCliOptions } from "./google-gemini-cli";
|
||||
import type { GoogleVertexOptions } from "./google-vertex";
|
||||
import type { OpenAICodexResponsesOptions } from "./openai-codex-responses";
|
||||
import type { OpenAICompletionsOptions } from "./openai-completions";
|
||||
import type { OpenAIResponsesOptions } from "./openai-responses";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy provider module shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LazyProviderModule<TApi extends Api> {
|
||||
stream: (model: Model<TApi>, context: Context, options: OptionsForApi<TApi>) => AsyncIterable<AssistantMessageEvent>;
|
||||
}
|
||||
|
||||
interface AnthropicProviderModule {
|
||||
streamAnthropic: (
|
||||
model: Model<"anthropic-messages">,
|
||||
context: Context,
|
||||
options: AnthropicOptions,
|
||||
) => AssistantMessageEventStream;
|
||||
}
|
||||
|
||||
interface AzureOpenAIResponsesProviderModule {
|
||||
streamAzureOpenAIResponses: (
|
||||
model: Model<"azure-openai-responses">,
|
||||
context: Context,
|
||||
options: AzureOpenAIResponsesOptions,
|
||||
) => AssistantMessageEventStream;
|
||||
}
|
||||
|
||||
interface GoogleProviderModule {
|
||||
streamGoogle: (
|
||||
model: Model<"google-generative-ai">,
|
||||
context: Context,
|
||||
options: GoogleOptions,
|
||||
) => AssistantMessageEventStream;
|
||||
}
|
||||
|
||||
interface GoogleGeminiCliProviderModule {
|
||||
streamGoogleGeminiCli: (
|
||||
model: Model<"google-gemini-cli">,
|
||||
context: Context,
|
||||
options: GoogleGeminiCliOptions,
|
||||
) => AssistantMessageEventStream;
|
||||
}
|
||||
|
||||
interface GoogleVertexProviderModule {
|
||||
streamGoogleVertex: (
|
||||
model: Model<"google-vertex">,
|
||||
context: Context,
|
||||
options: GoogleVertexOptions,
|
||||
) => AssistantMessageEventStream;
|
||||
}
|
||||
|
||||
interface OpenAICodexResponsesProviderModule {
|
||||
streamOpenAICodexResponses: (
|
||||
model: Model<"openai-codex-responses">,
|
||||
context: Context,
|
||||
options: OpenAICodexResponsesOptions,
|
||||
) => AssistantMessageEventStream;
|
||||
}
|
||||
|
||||
interface OpenAICompletionsProviderModule {
|
||||
streamOpenAICompletions: (
|
||||
model: Model<"openai-completions">,
|
||||
context: Context,
|
||||
options: OpenAICompletionsOptions,
|
||||
) => AssistantMessageEventStream;
|
||||
}
|
||||
|
||||
interface OpenAIResponsesProviderModule {
|
||||
streamOpenAIResponses: (
|
||||
model: Model<"openai-responses">,
|
||||
context: Context,
|
||||
options: OpenAIResponsesOptions,
|
||||
) => AssistantMessageEventStream;
|
||||
}
|
||||
|
||||
interface CursorProviderModule {
|
||||
streamCursor: (
|
||||
model: Model<"cursor-agent">,
|
||||
context: Context,
|
||||
options: CursorOptions,
|
||||
) => AssistantMessageEventStream;
|
||||
}
|
||||
|
||||
interface BedrockProviderModule {
|
||||
streamBedrock: (
|
||||
model: Model<"bedrock-converse-stream">,
|
||||
context: Context,
|
||||
options: BedrockOptions,
|
||||
) => AssistantMessageEventStream;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-level lazy promise caches
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const importNodeOnlyProvider = (specifier: string): Promise<unknown> => import(specifier);
|
||||
|
||||
let anthropicProviderModulePromise: Promise<LazyProviderModule<"anthropic-messages">> | undefined;
|
||||
let azureOpenAIResponsesProviderModulePromise: Promise<LazyProviderModule<"azure-openai-responses">> | undefined;
|
||||
let googleProviderModulePromise: Promise<LazyProviderModule<"google-generative-ai">> | undefined;
|
||||
let googleGeminiCliProviderModulePromise: Promise<LazyProviderModule<"google-gemini-cli">> | undefined;
|
||||
let googleVertexProviderModulePromise: Promise<LazyProviderModule<"google-vertex">> | undefined;
|
||||
let openAICodexResponsesProviderModulePromise: Promise<LazyProviderModule<"openai-codex-responses">> | undefined;
|
||||
let openAICompletionsProviderModulePromise: Promise<LazyProviderModule<"openai-completions">> | undefined;
|
||||
let openAIResponsesProviderModulePromise: Promise<LazyProviderModule<"openai-responses">> | undefined;
|
||||
let cursorProviderModulePromise: Promise<LazyProviderModule<"cursor-agent">> | undefined;
|
||||
let bedrockProviderModuleOverride: LazyProviderModule<"bedrock-converse-stream"> | undefined;
|
||||
let bedrockProviderModulePromise: Promise<LazyProviderModule<"bedrock-converse-stream">> | undefined;
|
||||
|
||||
export function setBedrockProviderModule(module: BedrockProviderModule): void {
|
||||
bedrockProviderModuleOverride = {
|
||||
stream: module.streamBedrock,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream forwarding / error helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function hasFinalResult(
|
||||
source: AsyncIterable<AssistantMessageEvent>,
|
||||
): source is AsyncIterable<AssistantMessageEvent> & { result(): Promise<AssistantMessage> } {
|
||||
return typeof (source as { result?: unknown }).result === "function";
|
||||
}
|
||||
|
||||
function forwardStream<TApi extends Api>(
|
||||
target: EventStreamImpl,
|
||||
source: AsyncIterable<AssistantMessageEvent>,
|
||||
model: Model<TApi>,
|
||||
): void {
|
||||
(async () => {
|
||||
try {
|
||||
for await (const event of source) {
|
||||
target.push(event);
|
||||
}
|
||||
if (hasFinalResult(source)) {
|
||||
target.end(await source.result());
|
||||
} else {
|
||||
target.end();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = createLazyLoadErrorMessage(model, error);
|
||||
target.push({ type: "error", reason: "error", error: message });
|
||||
target.end(message);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function createLazyLoadErrorMessage<TApi extends Api>(model: Model<TApi>, error: unknown): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "error",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic lazy stream factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createLazyStream<TApi extends Api>(
|
||||
loadModule: () => Promise<LazyProviderModule<TApi>>,
|
||||
): (model: Model<TApi>, context: Context, options: OptionsForApi<TApi>) => EventStreamImpl {
|
||||
return (model, context, options) => {
|
||||
const outer = new EventStreamImpl();
|
||||
|
||||
loadModule()
|
||||
.then(module => {
|
||||
const inner = module.stream(model, context, options);
|
||||
forwardStream(outer, inner, model);
|
||||
})
|
||||
.catch(error => {
|
||||
const message = createLazyLoadErrorMessage(model, error);
|
||||
outer.push({ type: "error", reason: "error", error: message });
|
||||
outer.end(message);
|
||||
});
|
||||
|
||||
return outer;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module loaders (one per provider, cached via ||=)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadAnthropicProviderModule(): Promise<LazyProviderModule<"anthropic-messages">> {
|
||||
anthropicProviderModulePromise ||= import("./anthropic").then(module => {
|
||||
const provider = module as AnthropicProviderModule;
|
||||
return { stream: provider.streamAnthropic };
|
||||
});
|
||||
return anthropicProviderModulePromise;
|
||||
}
|
||||
|
||||
function loadAzureOpenAIResponsesProviderModule(): Promise<LazyProviderModule<"azure-openai-responses">> {
|
||||
azureOpenAIResponsesProviderModulePromise ||= import("./azure-openai-responses").then(module => {
|
||||
const provider = module as AzureOpenAIResponsesProviderModule;
|
||||
return { stream: provider.streamAzureOpenAIResponses };
|
||||
});
|
||||
return azureOpenAIResponsesProviderModulePromise;
|
||||
}
|
||||
|
||||
function loadGoogleProviderModule(): Promise<LazyProviderModule<"google-generative-ai">> {
|
||||
googleProviderModulePromise ||= import("./google").then(module => {
|
||||
const provider = module as GoogleProviderModule;
|
||||
return { stream: provider.streamGoogle };
|
||||
});
|
||||
return googleProviderModulePromise;
|
||||
}
|
||||
|
||||
function loadGoogleGeminiCliProviderModule(): Promise<LazyProviderModule<"google-gemini-cli">> {
|
||||
googleGeminiCliProviderModulePromise ||= import("./google-gemini-cli").then(module => {
|
||||
const provider = module as GoogleGeminiCliProviderModule;
|
||||
return { stream: provider.streamGoogleGeminiCli };
|
||||
});
|
||||
return googleGeminiCliProviderModulePromise;
|
||||
}
|
||||
|
||||
function loadGoogleVertexProviderModule(): Promise<LazyProviderModule<"google-vertex">> {
|
||||
googleVertexProviderModulePromise ||= import("./google-vertex").then(module => {
|
||||
const provider = module as GoogleVertexProviderModule;
|
||||
return { stream: provider.streamGoogleVertex };
|
||||
});
|
||||
return googleVertexProviderModulePromise;
|
||||
}
|
||||
|
||||
function loadOpenAICodexResponsesProviderModule(): Promise<LazyProviderModule<"openai-codex-responses">> {
|
||||
openAICodexResponsesProviderModulePromise ||= import("./openai-codex-responses").then(module => {
|
||||
const provider = module as OpenAICodexResponsesProviderModule;
|
||||
return { stream: provider.streamOpenAICodexResponses };
|
||||
});
|
||||
return openAICodexResponsesProviderModulePromise;
|
||||
}
|
||||
|
||||
function loadOpenAICompletionsProviderModule(): Promise<LazyProviderModule<"openai-completions">> {
|
||||
openAICompletionsProviderModulePromise ||= import("./openai-completions").then(module => {
|
||||
const provider = module as OpenAICompletionsProviderModule;
|
||||
return { stream: provider.streamOpenAICompletions };
|
||||
});
|
||||
return openAICompletionsProviderModulePromise;
|
||||
}
|
||||
|
||||
function loadOpenAIResponsesProviderModule(): Promise<LazyProviderModule<"openai-responses">> {
|
||||
openAIResponsesProviderModulePromise ||= import("./openai-responses").then(module => {
|
||||
const provider = module as OpenAIResponsesProviderModule;
|
||||
return { stream: provider.streamOpenAIResponses };
|
||||
});
|
||||
return openAIResponsesProviderModulePromise;
|
||||
}
|
||||
|
||||
function loadCursorProviderModule(): Promise<LazyProviderModule<"cursor-agent">> {
|
||||
cursorProviderModulePromise ||= import("./cursor").then(module => {
|
||||
const provider = module as CursorProviderModule;
|
||||
return { stream: provider.streamCursor };
|
||||
});
|
||||
return cursorProviderModulePromise;
|
||||
}
|
||||
|
||||
function loadBedrockProviderModule(): Promise<LazyProviderModule<"bedrock-converse-stream">> {
|
||||
if (bedrockProviderModuleOverride) {
|
||||
return Promise.resolve(bedrockProviderModuleOverride);
|
||||
}
|
||||
bedrockProviderModulePromise ||= importNodeOnlyProvider("./amazon-bedrock").then(module => {
|
||||
const provider = module as BedrockProviderModule;
|
||||
return { stream: provider.streamBedrock };
|
||||
});
|
||||
return bedrockProviderModulePromise;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy stream function exports
|
||||
//
|
||||
// These use the same names as the direct provider stream functions. When
|
||||
// stream.ts is updated to import from this module instead of individual
|
||||
// providers, the lazy loading will take effect on the main code path.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const streamAnthropic = createLazyStream(loadAnthropicProviderModule);
|
||||
export const streamAzureOpenAIResponses = createLazyStream(loadAzureOpenAIResponsesProviderModule);
|
||||
export const streamGoogle = createLazyStream(loadGoogleProviderModule);
|
||||
export const streamGoogleGeminiCli = createLazyStream(loadGoogleGeminiCliProviderModule);
|
||||
export const streamGoogleVertex = createLazyStream(loadGoogleVertexProviderModule);
|
||||
export const streamOpenAICodexResponses = createLazyStream(loadOpenAICodexResponsesProviderModule);
|
||||
export const streamOpenAICompletions = createLazyStream(loadOpenAICompletionsProviderModule);
|
||||
export const streamOpenAIResponses = createLazyStream(loadOpenAIResponsesProviderModule);
|
||||
export const streamCursor = createLazyStream(loadCursorProviderModule);
|
||||
export const streamBedrock = createLazyStream(loadBedrockProviderModule);
|
||||
@@ -338,6 +338,7 @@ export interface AssistantMessage {
|
||||
api: Api;
|
||||
provider: Provider;
|
||||
model: string;
|
||||
responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one
|
||||
usage: Usage;
|
||||
stopReason: StopReason;
|
||||
errorMessage?: string;
|
||||
@@ -444,8 +445,8 @@ export interface OpenAICompat {
|
||||
requiresThinkingAsText?: boolean;
|
||||
/** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */
|
||||
requiresMistralToolIds?: boolean;
|
||||
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "zai" uses thinking: { type: "enabled" }, "qwen" uses top-level enable_thinking, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */
|
||||
thinkingFormat?: "openai" | "zai" | "qwen" | "qwen-chat-template";
|
||||
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "zai" uses thinking: { type: "enabled" }, "qwen" uses top-level enable_thinking, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */
|
||||
thinkingFormat?: "openai" | "openrouter" | "zai" | "qwen" | "qwen-chat-template";
|
||||
/** Which reasoning content field to emit on assistant messages. Default: auto-detected. */
|
||||
reasoningContentField?: "reasoning_content" | "reasoning" | "reasoning_text";
|
||||
/** Whether assistant tool-call messages must include reasoning content. Default: false. */
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { setBedrockProviderModule, streamBedrock } from "../src/providers/register-builtins";
|
||||
import type { AssistantMessage, Context, Model } from "../src/types";
|
||||
import type { AssistantMessageEventStream } from "../src/utils/event-stream";
|
||||
|
||||
function createModel(): Model<"bedrock-converse-stream"> {
|
||||
return {
|
||||
id: "mock-bedrock",
|
||||
name: "Mock Bedrock",
|
||||
api: "bedrock-converse-stream",
|
||||
provider: "amazon-bedrock",
|
||||
baseUrl: "https://example.invalid",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 2048,
|
||||
};
|
||||
}
|
||||
|
||||
function createAssistantMessage(
|
||||
stopReason: AssistantMessage["stopReason"] = "stop",
|
||||
errorMessage?: string,
|
||||
): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: errorMessage ? `error: ${errorMessage}` : "ok" }],
|
||||
api: "bedrock-converse-stream",
|
||||
provider: "amazon-bedrock",
|
||||
model: "mock-bedrock",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason,
|
||||
errorMessage,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
const baseContext: Context = { messages: [] };
|
||||
|
||||
describe("register-builtins lazy streams", () => {
|
||||
it("resolves the outer stream result from source.result() when no terminal event is iterated", async () => {
|
||||
const finalMessage = createAssistantMessage("stop");
|
||||
const partialMessage = createAssistantMessage("stop");
|
||||
const source = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield { type: "start", partial: partialMessage } as const;
|
||||
},
|
||||
result: async () => finalMessage,
|
||||
} as unknown as AssistantMessageEventStream;
|
||||
|
||||
setBedrockProviderModule({
|
||||
streamBedrock: () => source,
|
||||
});
|
||||
|
||||
const stream = streamBedrock(createModel(), baseContext, {});
|
||||
const result = await Promise.race([stream.result(), Bun.sleep(100).then(() => "timeout" as const)]);
|
||||
|
||||
expect(result).not.toBe("timeout");
|
||||
if (result === "timeout") {
|
||||
throw new Error("Timed out waiting for forwarded stream result");
|
||||
}
|
||||
expect(result).toEqual(finalMessage);
|
||||
});
|
||||
|
||||
it("turns iterator failures into terminal error results", async () => {
|
||||
const partialMessage = createAssistantMessage("stop");
|
||||
const source = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield { type: "start", partial: partialMessage } as const;
|
||||
throw new Error("bedrock exploded");
|
||||
},
|
||||
} as unknown as AssistantMessageEventStream;
|
||||
|
||||
setBedrockProviderModule({
|
||||
streamBedrock: () => source,
|
||||
});
|
||||
|
||||
const stream = streamBedrock(createModel(), baseContext, {});
|
||||
const result = await Promise.race([stream.result(), Bun.sleep(100).then(() => "timeout" as const)]);
|
||||
|
||||
expect(result).not.toBe("timeout");
|
||||
if (result === "timeout") {
|
||||
throw new Error("Timed out waiting for forwarded error result");
|
||||
}
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toContain("bedrock exploded");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,70 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [13.15.0] - 2026-03-23
|
||||
### Breaking Changes
|
||||
|
||||
- Changed hashline edit schema from flat `op`/`pos`/`end`/`lines` fields to structured `loc`/`content` format with location-specific objects
|
||||
- Renamed hashline edit operations: `replace_line` → `{ line: anchor }`, `replace_range` → `{ block: { pos, end } }`, `append_at` → `{ append: anchor }`, `prepend_at` → `{ prepend: anchor }`, `append_file` → `"append"`, `prepend_file` → `"prepend"`
|
||||
- Changed `lines` parameter to `content` in hashline edit entries
|
||||
- Renamed hashline edit operation types: `append` → `append_at`, `prepend` → `prepend_at`, `append_eof` → `append_file`, `prepend_bof` → `prepend_file`
|
||||
- Changed hashline edit operation types from `replace` (with optional `end`) to explicit `replace_line` and `replace_range` operations
|
||||
- Added required `append_eof` and `prepend_bof` operations for file-level edits; `append` and `prepend` now require an anchor position
|
||||
- Made `pos` parameter required for `replace_line`, `append`, and `prepend` operations; `append_eof` and `prepend_bof` no longer accept anchors
|
||||
|
||||
### Added
|
||||
|
||||
- Added prompt for tradeoff metrics during autoresearch setup to collect secondary metrics alongside primary metric
|
||||
- Added validation of contract path specifications to reject absolute paths and parent directory references
|
||||
- Added stricter benchmark command validation in `isAutoresearchShCommand()` to reject chained commands, pipes, and redirects
|
||||
- Added protection against prototype pollution in ASI data and metric cloning by filtering `__proto__`, `constructor`, and `prototype` keys
|
||||
- Added `autoResumeArmed` flag to track when autoresearch should automatically resume pending runs
|
||||
- Added `lastAutoResumePendingRunNumber` to prevent duplicate auto-resume prompts for the same pending run
|
||||
- Added `git clean -X` invocation during failed experiment rollback to remove ignored build artifacts
|
||||
- Added validation to reject `init_experiment` when a previous run is still pending and unlogged
|
||||
- Added autoresearch contract system for validating benchmark commands, metrics, scope paths, off-limits paths, and constraints with fingerprint tracking to detect configuration drift
|
||||
- Added `autoresearch.program.md` support for repo-local playbook overlays that guide session strategy while preserving `autoresearch.md` as source of truth
|
||||
- Added pending run artifact tracking and recovery to resume incomplete experiments from `.autoresearch/runs/` directory with run numbers and benchmark logs
|
||||
- Added run directory organization with numbered run artifacts, benchmark logs, and optional checks logs for experiment traceability
|
||||
- Added segment fingerprinting to detect when benchmark configuration changes between runs and warn about potential incomparability
|
||||
- Added support for secondary metrics tracking alongside primary metric with configurable direction (lower/higher is better)
|
||||
- Added `getCurrentAutoresearchBranch()` helper to detect and validate existing autoresearch branches for session resumption
|
||||
- Added `PendingRunSummary` type to track unlogged run state including parsed metrics, ASI data, and pass/fail status
|
||||
- Added hidden next-turn message delivery via `deliverAs: 'nextTurn'` with optional `triggerTurn` to queue context for next LLM call without exposing in editable queue
|
||||
- Added `#queueHiddenNextTurnMessage()` and `#promptQueuedHiddenNextTurnMessages()` to AgentSession for autonomous tool reactions
|
||||
- Added resume context support in `command-resume.md` template for user-provided guidance when resuming sessions
|
||||
- Added current segment snapshot display in autoresearch prompt showing recent runs, baseline metrics, and best results
|
||||
- Added pending run indicator in autoresearch prompt to guide users to complete unlogged experiments before starting new benchmarks
|
||||
- Added local playbook section in autoresearch prompt when `autoresearch.program.md` exists
|
||||
- Added tab replacement in dashboard and tool output rendering to prevent display corruption from shell commands with tabs
|
||||
- Added boundary duplication warning when replace_range or replace_line operations include a last inserted line that matches the next surviving line, helping detect off-by-one range errors
|
||||
- Added git branch isolation for autoresearch sessions via `ensureAutoresearchBranch()` to safely revert failed experiments
|
||||
- Added branch status line to autoresearch initialization and resume prompts showing created or reused branch name
|
||||
- Added `Files in Scope`, `Off Limits`, and `Constraints` sections to autoresearch.md template for explicit scope definition
|
||||
- Added validation of ASI metadata requirements in `log_experiment` tool, requiring hypothesis for all runs and rollback context for failed runs
|
||||
- Added keybinding matcher utilities `matchesAppInterrupt()` and `matchesSelectCancel()` for consistent escape key handling across components
|
||||
- Added support for customizable `app.interrupt` and `tui.select.cancel` keybindings in interactive components
|
||||
- Added `defaultInactive` property to `ToolDefinition` to allow tools to be registered but excluded from the initial active set, with extension responsibility for activation/deactivation
|
||||
- Added dynamic tool activation/deactivation in autoresearch mode via `setActiveTools()` API
|
||||
- Added separate initialization and resume workflows for autoresearch with `command-initialize.md` and `command-resume.md` prompts
|
||||
- Added intent dialog to prompt users for autoresearch optimization goals when starting fresh
|
||||
- Added automatic detection of existing `autoresearch.md` to resume from previous sessions without re-prompting for intent
|
||||
- Added autoresearch extension with autonomous experiment loop capabilities
|
||||
- Added `init_experiment` tool to initialize and reset autoresearch sessions with configurable metrics
|
||||
- Added `log_experiment` tool to record experiment results with metric parsing and confidence tracking
|
||||
- Added `run_experiment` tool to execute commands and capture metrics with timeout and crash detection
|
||||
- Added autoresearch dashboard controller for displaying experiment results and optimization progress
|
||||
- Added support for secondary metrics tracking alongside primary metric
|
||||
- Added `ExtensionWidgetContent` and `ExtensionUiComponentFactory` types for flexible widget configuration
|
||||
- Added `ExtensionWidgetOptions` interface with `placement` parameter to position widgets above or below editor
|
||||
- Added `WidgetPlacement` type supporting 'aboveEditor' and 'belowEditor' placement options
|
||||
- Added `hookWidgetContainerAbove` and `hookWidgetContainerBelow` containers to InteractiveMode for separate widget management
|
||||
- Added autoresearch mode for autonomous experiment loops with init_experiment, log_experiment, and run_experiment tools
|
||||
- Added autoresearch dashboard widget displaying experiment results, metrics, and optimization progress
|
||||
- Added support for metric tracking with configurable direction (lower/higher is better) and secondary metrics
|
||||
- Added widget placement options to position extensions above or below the editor via `placement` parameter
|
||||
- Added `ExtensionWidgetContent` and `ExtensionWidgetOptions` types for flexible widget configuration
|
||||
- Added ACP (Agent Client Protocol) mode for headless agent operation via `--mode acp`
|
||||
- Added support for Agent Client Protocol SDK integration with session management, MCP server configuration, and streaming communication
|
||||
- Added `ensureOnDisk()` method to SessionManager to persist sessions immediately for ACP discovery
|
||||
@@ -10,8 +72,76 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed `isAutoresearchShCommand()` to use proper command-line argument parsing instead of regex, improving accuracy for complex shell invocations
|
||||
- Changed autoresearch initialization prompt to display collected tradeoff metrics in the setup summary
|
||||
- Changed `command-initialize.md` template to include guidance on preflight requirements, comparability invariants, and marking measurement-critical files as off-limits
|
||||
- Changed `command-initialize.md` to instruct users to write or update `autoresearch.program.md` with durable heuristics and repo-specific strategy
|
||||
- Changed autoresearch resume guidance to emphasize continuing on the current protected branch rather than switching branches
|
||||
- Changed autoresearch prompt to clarify that `autoresearch.md` holds durable conclusions while `autoresearch.ideas.md` is the scratch backlog
|
||||
- Changed autoresearch prompt guidance to require stable measurement harness and fixed benchmark inputs unless intentionally starting a new segment
|
||||
- Changed autoresearch prompt to recommend keeping equal or near-equal results when they materially simplify implementation
|
||||
- Changed `init_experiment` to reset pending run state (checks, duration, ASI, artifact directory) when initializing a new segment
|
||||
- Changed `log_experiment` to set `autoResumeArmed` flag after successfully logging a run to enable auto-resume on next agent turn
|
||||
- Changed `run_experiment` to set `autoResumeArmed` flag and update dashboard after completing a run
|
||||
- Changed auto-resume logic to only prompt when a new pending run exists or when `autoResumeArmed` is explicitly set, preventing duplicate prompts
|
||||
- Changed path normalization in contract validation to use `path.posix.normalize()` for consistent path handling
|
||||
- Changed autoresearch initialization to collect and validate benchmark command, metric definition, scope paths, off-limits list, and constraints before `init_experiment`
|
||||
- Changed `init_experiment` to require exact benchmark command, metric definition, scope, off-limits, and constraints matching collected contract
|
||||
- Changed `log_experiment` to record run number, benchmark command, scope paths, off-limits list, constraints, and segment fingerprint with each result
|
||||
- Changed `run_experiment` to organize output in numbered run directories with separate benchmark and checks logs for artifact preservation
|
||||
- Changed autoresearch dashboard to show pending run indicator when unlogged experiment exists
|
||||
- Changed autoresearch resume workflow to detect and offer recovery of pending run artifacts before continuing experiment loop
|
||||
- Changed `ExperimentResult` to include `runNumber`, `benchmarkCommand`, `scopePaths`, `offLimits`, `constraints`, and `segmentFingerprint` fields
|
||||
- Changed `RunningExperiment` to track `runDirectory` and `runNumber` for artifact organization
|
||||
- Changed `AutoresearchRuntime` to include `lastRunArtifactDir`, `lastRunNumber`, `lastRunSummary`, `benchmarkCommand`, `secondaryMetrics`, `scopePaths`, `offLimits`, `constraints`, and `segmentFingerprint`
|
||||
- Changed autoresearch prompts to emphasize `autoresearch.md` as source of truth for benchmark, scope, and constraints
|
||||
- Changed `command-initialize.md` to display collected setup (benchmark command, metric, direction, scope, off-limits, constraints) before initialization
|
||||
- Changed `resume-message.md` to reference pending run artifacts and guide completion of unlogged experiments
|
||||
- Changed `sendMessage()` API documentation to clarify `deliverAs: 'nextTurn'` behavior for hidden context delivery
|
||||
- Changed `SendMessageHandler` type documentation to explain hidden next-turn message queuing during prompt teardown
|
||||
- Changed autoresearch startup to create or reuse a dedicated `autoresearch/...` git branch before enabling the experiment loop
|
||||
- Changed autoresearch to refuse startup when unrelated worktree changes would make auto-reverts unsafe
|
||||
- Changed autoresearch prompts to emphasize scope and constraints as source of truth for session direction
|
||||
- Changed component escape key handling to use keybinding manager for `app.interrupt` and `tui.select.cancel` with fallback to raw Escape matching
|
||||
- Updated autoresearch prompt guidance to require explicit files in scope, off-limits paths, and session constraints
|
||||
- Changed autoresearch command to use intent-based initialization instead of goal parameter, with user input dialog for new sessions
|
||||
- Changed autoresearch startup to create or reuse a dedicated `autoresearch/...` git branch before enabling the experiment loop, and to refuse startup when unrelated worktree changes would make auto-reverts unsafe
|
||||
- Changed autoresearch startup to activate experiment tools (`init_experiment`, `run_experiment`, `log_experiment`) only when autoresearch mode is enabled
|
||||
- Changed autoresearch shutdown to deactivate experiment tools when mode is disabled or cleared
|
||||
- Changed autoresearch session rehydration to dynamically manage experiment tool activation based on session state
|
||||
- Changed autoresearch prompts and notes guidance to require explicit files in scope, off-limits paths, and session constraints
|
||||
- Refactored hashline edit validation to enforce stricter anchor requirements per operation type
|
||||
- Updated edit application logic to handle explicit file-level operations (`append_eof`, `prepend_bof`) separately from anchor-based operations
|
||||
- Changed `setWidget` API to accept `ExtensionWidgetOptions` parameter for placement control
|
||||
- Changed widget placement logic to manage widgets above and below editor separately
|
||||
- Changed hashline edit application to preserve duplicated boundary lines exactly as provided instead of auto-correcting them
|
||||
- Updated RPC mode to support widget placement option in `setWidget` requests
|
||||
- Changed hashline edit application to preserve duplicated boundary lines exactly as provided instead of auto-correcting them
|
||||
- Changed widget API to support placement options and component factories in addition to string arrays
|
||||
- Updated extension UI controller to manage widgets above and below the editor separately
|
||||
- Updated ask tool rendering to support markdown formatting in questions and option labels
|
||||
- Refactored hook input and selector components to render titles as markdown for richer text formatting
|
||||
- Changed session collection to include sessions with zero messages, enabling ACP mode to create discoverable sessions immediately
|
||||
- Changed session persistence logic to use atomic file rewrite when flushing unflushed sessions to prevent duplication
|
||||
- Removed hashline edit autocorrection for duplicated boundary lines; escaped-tab autocorrection remains available for leading `\\t` sequences
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed `command-start.md` prompt template in favor of separate initialize and resume workflows
|
||||
- Removed auto-correction of off-by-one range edits that duplicated closing braces or boundary lines
|
||||
- Removed `shouldAutocorrect` function and related boundary line deduplication logic from hashline editor
|
||||
- Removed auto-correction of off-by-one range edits that duplicated closing braces or boundary lines
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed boundary duplication warnings to always display when replacement lines match the next surviving line, even when auto-correction is disabled
|
||||
- Fixed secondary metrics validation to properly reject missing configured metrics and new metrics without force flag
|
||||
- Fixed ASI data cloning to prevent prototype pollution attacks by filtering reserved property names
|
||||
- Fixed autoresearch resume to detect and recover pending run artifacts that were left unlogged from previous sessions
|
||||
- Fixed dashboard overlay to display when running experiment even with zero completed results
|
||||
- Fixed tab character rendering in dashboard command display and tool output summaries
|
||||
- Fixed autoresearch logging to require durable ASI metadata (hypothesis, rollback_reason, next_action_hint) for every run including rollback context for discarded, crashed, and checks-failed experiments
|
||||
- Fixed autoresearch logging to require durable ASI metadata for every run, including rollback context for discarded, crashed, and checks-failed experiments
|
||||
|
||||
## [13.14.0] - 2026-03-20
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-coding-agent",
|
||||
"version": "13.14.2",
|
||||
"version": "13.15.0",
|
||||
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||
"homepage": "https://github.com/can1357/oh-my-pi",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
Set up autoresearch for this intent:
|
||||
|
||||
{{intent}}
|
||||
|
||||
{{branch_status_line}}
|
||||
|
||||
Collected setup:
|
||||
|
||||
- benchmark command: `{{benchmark_command}}`
|
||||
- primary metric: `{{metric_name}}`
|
||||
- metric unit: `{{metric_unit}}`
|
||||
- direction: `{{direction}}`
|
||||
- tradeoff metrics:
|
||||
{{{secondary_metrics_block}}}
|
||||
- files in scope:
|
||||
{{{scope_paths_block}}}
|
||||
- off limits:
|
||||
{{{off_limits_block}}}
|
||||
- constraints:
|
||||
{{{constraints_block}}}
|
||||
|
||||
Explain briefly what autoresearch will do in this repository, then initialize the workspace.
|
||||
|
||||
Your first actions:
|
||||
- write `autoresearch.md`
|
||||
- record the collected benchmark command, primary metric, metric unit, direction, tradeoff metrics, scope, off-limits list, and constraints in `autoresearch.md`
|
||||
- add a short preflight section in `autoresearch.md` covering prerequisites, one-time setup, and the comparability invariant that must stay fixed across runs
|
||||
- explicitly mark the ground-truth evaluator, fixed datasets, and other measurement-critical files as off-limits or hard constraints when they define the benchmark contract
|
||||
- write or update `autoresearch.program.md` when you learn durable heuristics, failure patterns, or repo-specific strategy that future resume turns should inherit
|
||||
- define the benchmark entrypoint in `autoresearch.sh`
|
||||
- optionally add `autoresearch.checks.sh` if correctness or quality needs a hard gate
|
||||
- run `init_experiment` with the exact collected benchmark command, metric definition, scope paths, off-limits list, and constraints
|
||||
- run and log the baseline
|
||||
- keep iterating until interrupted or until the configured iteration cap is reached
|
||||
@@ -0,0 +1,17 @@
|
||||
Resume autoresearch from the attached notes.
|
||||
|
||||
@{{autoresearch_md_path}}
|
||||
|
||||
{{branch_status_line}}
|
||||
{{#if has_resume_context}}
|
||||
|
||||
Additional context from the user:
|
||||
|
||||
{{resume_context}}
|
||||
{{/if}}
|
||||
|
||||
Use the notes as the source of truth for the current direction, scope, and constraints.
|
||||
- inspect recent git history for context
|
||||
- inspect `autoresearch.jsonl` if it exists
|
||||
- continue the most promising unfinished direction on the current protected branch
|
||||
- keep iterating until interrupted or until the configured iteration cap is reached
|
||||
@@ -0,0 +1,332 @@
|
||||
import * as crypto from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { AutoresearchBenchmarkContract, AutoresearchContract, MetricDirection } from "./types";
|
||||
|
||||
export interface AutoresearchContractLoadResult {
|
||||
contract: AutoresearchContract;
|
||||
errors: string[];
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface AutoresearchScriptSnapshot {
|
||||
benchmarkScript: string;
|
||||
benchmarkScriptPath: string;
|
||||
checksScript: string | null;
|
||||
checksScriptPath: string;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const HEADING_REGEX = /^##\s+(.+?)\s*$/;
|
||||
const LIST_ITEM_REGEX = /^\s*[-*]\s+(.*)$/;
|
||||
const KEY_VALUE_REGEX = /^\s*[-*]\s+([^:]+):\s*(.*)$/;
|
||||
|
||||
export function readAutoresearchContract(workDir: string): AutoresearchContractLoadResult {
|
||||
const contractPath = path.join(workDir, "autoresearch.md");
|
||||
let content = "";
|
||||
try {
|
||||
content = fs.readFileSync(contractPath, "utf8");
|
||||
} catch {
|
||||
return {
|
||||
contract: createEmptyAutoresearchContract(),
|
||||
errors: [`${contractPath} does not exist. Create it before initializing autoresearch.`],
|
||||
path: contractPath,
|
||||
};
|
||||
}
|
||||
|
||||
const contract = parseAutoresearchContract(content);
|
||||
const errors = validateAutoresearchContract(contract);
|
||||
return { contract, errors, path: contractPath };
|
||||
}
|
||||
|
||||
export function parseAutoresearchContract(markdown: string): AutoresearchContract {
|
||||
const sections = extractSections(markdown);
|
||||
return {
|
||||
benchmark: parseBenchmarkSection(sections.get("benchmark") ?? ""),
|
||||
scopePaths: parseListSection(sections.get("files in scope") ?? "", normalizeContractPathSpec),
|
||||
offLimits: parseListSection(sections.get("off limits") ?? "", normalizeContractPathSpec),
|
||||
constraints: parseListSection(sections.get("constraints") ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateAutoresearchContract(contract: AutoresearchContract): string[] {
|
||||
const errors: string[] = [];
|
||||
if (!contract.benchmark.command) {
|
||||
errors.push("Benchmark.command is required in autoresearch.md.");
|
||||
}
|
||||
if (!contract.benchmark.primaryMetric) {
|
||||
errors.push("Benchmark.primary metric is required in autoresearch.md.");
|
||||
}
|
||||
if (!contract.benchmark.direction) {
|
||||
errors.push("Benchmark.direction must be `lower` or `higher` in autoresearch.md.");
|
||||
}
|
||||
if (contract.scopePaths.length === 0) {
|
||||
errors.push("Files in Scope must contain at least one path in autoresearch.md.");
|
||||
}
|
||||
for (const scopePath of contract.scopePaths) {
|
||||
if (isUnsafeContractPathSpec(scopePath)) {
|
||||
errors.push(`Files in Scope contains an invalid path: ${scopePath}`);
|
||||
}
|
||||
}
|
||||
for (const offLimitsPath of contract.offLimits) {
|
||||
if (isUnsafeContractPathSpec(offLimitsPath)) {
|
||||
errors.push(`Off Limits contains an invalid path: ${offLimitsPath}`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function buildAutoresearchSegmentFingerprint(
|
||||
contract: AutoresearchContract,
|
||||
scripts: {
|
||||
benchmarkScript: string;
|
||||
checksScript: string | null;
|
||||
},
|
||||
): string {
|
||||
const payload = {
|
||||
benchmark: contract.benchmark,
|
||||
scopePaths: contract.scopePaths,
|
||||
offLimits: contract.offLimits,
|
||||
constraints: contract.constraints,
|
||||
scripts,
|
||||
};
|
||||
return crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");
|
||||
}
|
||||
|
||||
export function getAutoresearchFingerprintMismatchError(
|
||||
stateFingerprint: string | null,
|
||||
workDir: string,
|
||||
): string | null {
|
||||
if (!stateFingerprint) {
|
||||
return "The current segment has no fingerprint metadata. Re-run init_experiment before continuing.";
|
||||
}
|
||||
|
||||
const contractResult = readAutoresearchContract(workDir);
|
||||
const scriptSnapshot = loadAutoresearchScriptSnapshot(workDir);
|
||||
const errors = [...contractResult.errors, ...scriptSnapshot.errors];
|
||||
if (errors.length > 0) {
|
||||
return `${errors.join(" ")} Re-run init_experiment after fixing the workspace contract.`;
|
||||
}
|
||||
|
||||
const currentFingerprint = buildAutoresearchSegmentFingerprint(contractResult.contract, {
|
||||
benchmarkScript: scriptSnapshot.benchmarkScript,
|
||||
checksScript: scriptSnapshot.checksScript,
|
||||
});
|
||||
if (currentFingerprint === stateFingerprint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "autoresearch.md, autoresearch.sh, or autoresearch.checks.sh changed since the current segment was initialized. Re-run init_experiment before continuing.";
|
||||
}
|
||||
|
||||
export function loadAutoresearchScriptSnapshot(workDir: string): AutoresearchScriptSnapshot {
|
||||
const benchmarkScriptPath = path.join(workDir, "autoresearch.sh");
|
||||
const checksScriptPath = path.join(workDir, "autoresearch.checks.sh");
|
||||
const errors: string[] = [];
|
||||
|
||||
let benchmarkScript = "";
|
||||
try {
|
||||
benchmarkScript = fs.readFileSync(benchmarkScriptPath, "utf8");
|
||||
} catch {
|
||||
errors.push(`${benchmarkScriptPath} does not exist. Create it before initializing autoresearch.`);
|
||||
}
|
||||
|
||||
let checksScript: string | null = null;
|
||||
try {
|
||||
checksScript = fs.readFileSync(checksScriptPath, "utf8");
|
||||
} catch {
|
||||
checksScript = null;
|
||||
}
|
||||
|
||||
return {
|
||||
benchmarkScript,
|
||||
benchmarkScriptPath,
|
||||
checksScript,
|
||||
checksScriptPath,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAutoresearchList(values: readonly string[]): string[] {
|
||||
const normalized: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const value of values) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) continue;
|
||||
if (seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
normalized.push(trimmed);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeContractPathSpec(value: string): string {
|
||||
const normalized = path.posix.normalize(value.trim().replaceAll("\\", "/"));
|
||||
if (normalized === "." || normalized === "./") return ".";
|
||||
return normalized.replace(/^\.\/+/, "").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function pathMatchesContractPath(pathValue: string, specValue: string): boolean {
|
||||
const normalizedPath = normalizeContractPathSpec(pathValue);
|
||||
const normalizedSpec = normalizeContractPathSpec(specValue);
|
||||
if (normalizedSpec === ".") return true;
|
||||
return normalizedPath === normalizedSpec || normalizedPath.startsWith(`${normalizedSpec}/`);
|
||||
}
|
||||
|
||||
export function contractListsEqual(left: readonly string[], right: readonly string[]): boolean {
|
||||
const normalizedLeft = normalizeAutoresearchList(left);
|
||||
const normalizedRight = normalizeAutoresearchList(right);
|
||||
if (normalizedLeft.length !== normalizedRight.length) return false;
|
||||
return normalizedLeft.every((value, index) => value === normalizedRight[index]);
|
||||
}
|
||||
|
||||
export function contractPathListsEqual(left: readonly string[], right: readonly string[]): boolean {
|
||||
const normalizedLeft = normalizeContractPathList(left);
|
||||
const normalizedRight = normalizeContractPathList(right);
|
||||
if (normalizedLeft.length !== normalizedRight.length) return false;
|
||||
return normalizedLeft.every((value, index) => value === normalizedRight[index]);
|
||||
}
|
||||
|
||||
function createEmptyAutoresearchContract(): AutoresearchContract {
|
||||
return {
|
||||
benchmark: {
|
||||
command: null,
|
||||
primaryMetric: null,
|
||||
metricUnit: "",
|
||||
direction: null,
|
||||
secondaryMetrics: [],
|
||||
},
|
||||
scopePaths: [],
|
||||
offLimits: [],
|
||||
constraints: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeContractPathList(values: readonly string[]): string[] {
|
||||
return normalizeAutoresearchList(values.map(normalizeContractPathSpec)).sort((left, right) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
}
|
||||
|
||||
function extractSections(markdown: string): Map<string, string> {
|
||||
const sections = new Map<string, string>();
|
||||
const lines = markdown.split("\n");
|
||||
let currentHeading: string | null = null;
|
||||
let currentLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const headingMatch = line.match(HEADING_REGEX);
|
||||
if (headingMatch) {
|
||||
if (currentHeading) {
|
||||
sections.set(currentHeading, currentLines.join("\n").trim());
|
||||
}
|
||||
currentHeading = headingMatch[1]?.trim().toLowerCase() ?? null;
|
||||
currentLines = [];
|
||||
continue;
|
||||
}
|
||||
if (currentHeading) {
|
||||
currentLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentHeading) {
|
||||
sections.set(currentHeading, currentLines.join("\n").trim());
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
function parseBenchmarkSection(section: string): AutoresearchBenchmarkContract {
|
||||
const entries = new Map<string, string>();
|
||||
const lines = section.split("\n");
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const rawLine = lines[index] ?? "";
|
||||
const match = rawLine.match(KEY_VALUE_REGEX);
|
||||
if (!match) continue;
|
||||
const key = normalizeKey(match[1] ?? "");
|
||||
let value = (match[2] ?? "").trim();
|
||||
if (key === "secondarymetrics") {
|
||||
const nestedItems: string[] = [];
|
||||
for (let nestedIndex = index + 1; nestedIndex < lines.length; nestedIndex += 1) {
|
||||
const nestedLine = lines[nestedIndex] ?? "";
|
||||
if (nestedLine.match(KEY_VALUE_REGEX)) break;
|
||||
const nestedMatch = nestedLine.match(/^\s{2,}[-*]\s+(.*)$/);
|
||||
if (!nestedMatch) {
|
||||
if (nestedLine.trim().length > 0) break;
|
||||
continue;
|
||||
}
|
||||
nestedItems.push((nestedMatch[1] ?? "").trim());
|
||||
index = nestedIndex;
|
||||
}
|
||||
if (nestedItems.length > 0) {
|
||||
value = [value, ...nestedItems].filter(Boolean).join(", ");
|
||||
}
|
||||
}
|
||||
entries.set(key, value);
|
||||
}
|
||||
|
||||
const direction = parseDirection(entries.get("direction"));
|
||||
return {
|
||||
command: readNullableEntry(entries.get("command")),
|
||||
primaryMetric: readNullableEntry(entries.get("primarymetric")),
|
||||
metricUnit: entries.get("metricunit")?.trim() ?? "",
|
||||
direction,
|
||||
secondaryMetrics: parseSecondaryMetrics(entries.get("secondarymetrics")),
|
||||
};
|
||||
}
|
||||
|
||||
function parseListSection(section: string, normalizeItem?: (value: string) => string): string[] {
|
||||
const items: string[] = [];
|
||||
let activeItem: string | null = null;
|
||||
for (const rawLine of section.split("\n")) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (line.trim().length === 0) continue;
|
||||
const match = rawLine.match(LIST_ITEM_REGEX);
|
||||
if (match) {
|
||||
if (activeItem) items.push(activeItem);
|
||||
activeItem = (match[1] ?? "").trim();
|
||||
continue;
|
||||
}
|
||||
if (activeItem && /^\s{2,}\S/.test(rawLine)) {
|
||||
activeItem = `${activeItem} ${line.trim()}`;
|
||||
continue;
|
||||
}
|
||||
if (activeItem) {
|
||||
items.push(activeItem);
|
||||
activeItem = null;
|
||||
}
|
||||
items.push(line.trim());
|
||||
}
|
||||
if (activeItem) {
|
||||
items.push(activeItem);
|
||||
}
|
||||
const normalizedItems = normalizeAutoresearchList(items);
|
||||
return normalizeItem ? normalizedItems.map(normalizeItem) : normalizedItems;
|
||||
}
|
||||
|
||||
function normalizeKey(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function parseDirection(value: string | undefined): MetricDirection | null {
|
||||
if (value === "lower" || value === "higher") return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function readNullableEntry(value: string | undefined): string | null {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function parseSecondaryMetrics(value: string | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return normalizeAutoresearchList(
|
||||
value
|
||||
.split(",")
|
||||
.map(entry => entry.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function isUnsafeContractPathSpec(value: string): boolean {
|
||||
return path.posix.isAbsolute(value) || value === ".." || value.startsWith("../");
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import { matchesKey, Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
||||
import type { Theme } from "../modes/theme/theme";
|
||||
import { replaceTabs } from "../tools/render-utils";
|
||||
import { formatElapsed, formatNum, isBetter } from "./helpers";
|
||||
import { currentResults, findBaselineMetric, findBaselineRunNumber, findBaselineSecondary } from "./state";
|
||||
import type { AutoresearchRuntime, DashboardController, ExperimentResult, ExperimentState } from "./types";
|
||||
|
||||
export function createDashboardController(): DashboardController {
|
||||
let overlayTui: { requestRender(): void } | null = null;
|
||||
let spinnerTimer: NodeJS.Timeout | undefined;
|
||||
let spinnerFrame = 0;
|
||||
|
||||
const requestRender = (): void => {
|
||||
overlayTui?.requestRender();
|
||||
};
|
||||
|
||||
const clear = (): void => {
|
||||
overlayTui = null;
|
||||
if (spinnerTimer) {
|
||||
clearInterval(spinnerTimer);
|
||||
spinnerTimer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
clear(ctx): void {
|
||||
clear();
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.setWidget("autoresearch", undefined);
|
||||
}
|
||||
},
|
||||
requestRender,
|
||||
updateWidget(ctx, runtime): void {
|
||||
if (!ctx.hasUI) return;
|
||||
const state = runtime.state;
|
||||
if (!shouldShowDashboard(runtime, state)) {
|
||||
ctx.ui.setWidget("autoresearch", undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.setWidget("autoresearch", (_tui, theme) => {
|
||||
if (state.results.length === 0 && runtime.runningExperiment) {
|
||||
return new Text(renderRunningOnly(runtime, state, theme), 0, 0);
|
||||
}
|
||||
if (runtime.dashboardExpanded) {
|
||||
const width = process.stdout.columns ?? 120;
|
||||
const lines = [
|
||||
renderExpandedHeader(runtime, width, theme),
|
||||
...renderDashboardLines(runtime, width, theme, 8),
|
||||
];
|
||||
return new Text(lines.join("\n"), 0, 0);
|
||||
}
|
||||
return new Text(renderCollapsedLine(runtime, state, theme), 0, 0);
|
||||
});
|
||||
},
|
||||
async showOverlay(ctx, runtime): Promise<void> {
|
||||
if (!ctx.hasUI || !shouldShowDashboard(runtime, runtime.state)) return;
|
||||
await ctx.ui.custom<void>(
|
||||
(tui, theme, _keybindings, done) => {
|
||||
overlayTui = tui;
|
||||
if (!spinnerTimer) {
|
||||
spinnerTimer = setInterval(() => {
|
||||
spinnerFrame += 1;
|
||||
requestRender();
|
||||
}, 80);
|
||||
}
|
||||
|
||||
let scrollOffset = 0;
|
||||
return {
|
||||
render(width: number): string[] {
|
||||
const terminalRows = process.stdout.rows ?? 40;
|
||||
const header = renderExpandedHeader(runtime, width, theme);
|
||||
const body = renderDashboardLines(runtime, width, theme, 0);
|
||||
if (runtime.runningExperiment) {
|
||||
body.push(renderOverlayRunningLine(runtime, theme, width, spinnerFrame));
|
||||
}
|
||||
const viewportRows = Math.max(4, terminalRows - 4);
|
||||
const maxScroll = Math.max(0, body.length - viewportRows);
|
||||
if (scrollOffset > maxScroll) scrollOffset = maxScroll;
|
||||
const visible = body.slice(scrollOffset, scrollOffset + viewportRows);
|
||||
const footer = renderOverlayFooter(width, scrollOffset, viewportRows, body.length, theme);
|
||||
return [
|
||||
header,
|
||||
...visible,
|
||||
...Array.from({ length: Math.max(0, viewportRows - visible.length) }, () => ""),
|
||||
footer,
|
||||
];
|
||||
},
|
||||
handleInput(data: string): void {
|
||||
const totalRows =
|
||||
renderDashboardLines(runtime, process.stdout.columns ?? 120, theme, 0).length +
|
||||
(runtime.runningExperiment ? 1 : 0);
|
||||
const viewportRows = Math.max(4, (process.stdout.rows ?? 40) - 4);
|
||||
const maxScroll = Math.max(0, totalRows - viewportRows);
|
||||
if (matchesKey(data, "escape") || matchesKey(data, "esc") || data === "q") {
|
||||
done(undefined);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, "up") || data === "k") {
|
||||
scrollOffset = Math.max(0, scrollOffset - 1);
|
||||
} else if (matchesKey(data, "down") || data === "j") {
|
||||
scrollOffset = Math.min(maxScroll, scrollOffset + 1);
|
||||
} else if (matchesKey(data, "pageUp")) {
|
||||
scrollOffset = Math.max(0, scrollOffset - viewportRows);
|
||||
} else if (matchesKey(data, "pageDown")) {
|
||||
scrollOffset = Math.min(maxScroll, scrollOffset + viewportRows);
|
||||
} else if (data === "g") {
|
||||
scrollOffset = 0;
|
||||
} else if (data === "G") {
|
||||
scrollOffset = maxScroll;
|
||||
}
|
||||
tui.requestRender();
|
||||
},
|
||||
invalidate(): void {},
|
||||
dispose(): void {
|
||||
clear();
|
||||
},
|
||||
};
|
||||
},
|
||||
{ overlay: true },
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderRunningOnly(runtime: AutoresearchRuntime, state: ExperimentState, theme: Theme): string {
|
||||
const parts = [theme.fg("accent", "autoresearch"), theme.fg("warning", " running...")];
|
||||
if (state.name) {
|
||||
parts.push(theme.fg("dim", ` | ${replaceTabs(state.name)}`));
|
||||
}
|
||||
if (runtime.runningExperiment) {
|
||||
parts.push(theme.fg("dim", ` | ${replaceTabs(runtime.runningExperiment.command)}`));
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function shouldShowDashboard(runtime: AutoresearchRuntime, state: ExperimentState): boolean {
|
||||
return (
|
||||
runtime.autoresearchMode ||
|
||||
state.results.length > 0 ||
|
||||
runtime.runningExperiment !== null ||
|
||||
runtime.lastRunSummary !== null
|
||||
);
|
||||
}
|
||||
|
||||
function renderExpandedHeader(runtime: AutoresearchRuntime, width: number, theme: Theme): string {
|
||||
const state = runtime.state;
|
||||
const status = renderModeStatus(runtime, state);
|
||||
const label = state.name ? ` autoresearch: ${replaceTabs(state.name)} ` : " autoresearch ";
|
||||
const hint = theme.fg("dim", ` ctrl+x collapse ctrl+shift+x overlay${status ? ` ${status}` : ""} `);
|
||||
const fillWidth = Math.max(0, width - visibleWidth(label) - visibleWidth(hint));
|
||||
return truncateToWidth(theme.fg("accent", label) + theme.fg("borderMuted", "-".repeat(fillWidth)) + hint, width);
|
||||
}
|
||||
|
||||
function renderCollapsedLine(runtime: AutoresearchRuntime, state: ExperimentState, theme: Theme): string {
|
||||
if (runtime.lastRunSummary) {
|
||||
const parts = [
|
||||
theme.fg("accent", "autoresearch"),
|
||||
theme.fg("warning", ` pending run #${runtime.lastRunSummary.runNumber}`),
|
||||
theme.fg("dim", runtime.lastRunSummary.passed ? " pass" : " fail"),
|
||||
];
|
||||
if (runtime.lastRunSummary.parsedPrimary !== null) {
|
||||
parts.push(
|
||||
theme.fg(
|
||||
"muted",
|
||||
` | ${state.metricName}=${formatNum(runtime.lastRunSummary.parsedPrimary, state.metricUnit)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
parts.push(theme.fg("warning", " | log_experiment required"));
|
||||
if (!runtime.autoresearchMode) {
|
||||
parts.push(theme.fg("dim", " | mode off"));
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
if (state.results.length === 0) {
|
||||
const modeStatus = runtime.autoresearchMode ? "baseline pending" : "mode off";
|
||||
const parts = [theme.fg("accent", "autoresearch"), theme.fg("warning", ` ${modeStatus}`)];
|
||||
if (state.name) {
|
||||
parts.push(theme.fg("dim", ` | ${replaceTabs(state.name)}`));
|
||||
}
|
||||
if (runtime.autoresearchMode) {
|
||||
parts.push(theme.fg("dim", " | run the baseline"));
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
const current = currentResults(state.results, state.currentSegment);
|
||||
const kept = current.filter(result => result.status === "keep").length;
|
||||
const crashed = current.filter(result => result.status === "crash").length;
|
||||
const checksFailed = current.filter(result => result.status === "checks_failed").length;
|
||||
const best = findBestResult(state);
|
||||
const archivedRuns = Math.max(0, state.results.length - current.length);
|
||||
const parts = [
|
||||
theme.fg("accent", "autoresearch"),
|
||||
theme.fg("muted", ` ${current.length} runs`),
|
||||
theme.fg("success", ` ${kept} kept`),
|
||||
];
|
||||
if (archivedRuns > 0) parts.push(theme.fg("dim", ` +${archivedRuns} archived`));
|
||||
if (crashed > 0) parts.push(theme.fg("error", ` ${crashed} crash`));
|
||||
if (checksFailed > 0) parts.push(theme.fg("error", ` ${checksFailed} checks_failed`));
|
||||
parts.push(theme.fg("dim", " | "));
|
||||
if (best && state.bestMetric !== null && best.result.metric !== state.bestMetric) {
|
||||
parts.push(theme.fg("warning", `best ${formatNum(best.result.metric, state.metricUnit)}`));
|
||||
parts.push(theme.fg("dim", ` baseline ${formatNum(state.bestMetric, state.metricUnit)}`));
|
||||
} else if (state.bestMetric !== null) {
|
||||
parts.push(theme.fg("warning", `baseline ${formatNum(state.bestMetric, state.metricUnit)}`));
|
||||
} else {
|
||||
parts.push(theme.fg("warning", `no kept runs yet`));
|
||||
}
|
||||
if (state.confidence !== null) {
|
||||
const confidenceColor = state.confidence >= 2 ? "success" : state.confidence >= 1 ? "warning" : "error";
|
||||
parts.push(theme.fg("dim", " | "));
|
||||
parts.push(theme.fg(confidenceColor, `conf ${state.confidence.toFixed(1)}x`));
|
||||
}
|
||||
if (runtime.runningExperiment) {
|
||||
parts.push(theme.fg("dim", ` | running ${formatElapsed(Date.now() - runtime.runningExperiment.startedAt)}`));
|
||||
} else if (!runtime.autoresearchMode) {
|
||||
parts.push(theme.fg("dim", ` | ${renderModeStatus(runtime, state)}`));
|
||||
}
|
||||
parts.push(theme.fg("dim", " | ctrl+x expand"));
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
export function renderDashboardLines(
|
||||
runtime: AutoresearchRuntime,
|
||||
width: number,
|
||||
theme: Theme,
|
||||
maxRows: number,
|
||||
): string[] {
|
||||
const state = runtime.state;
|
||||
if (state.results.length === 0) {
|
||||
if (runtime.lastRunSummary) {
|
||||
const lines = [
|
||||
truncateToWidth(`Pending run: #${runtime.lastRunSummary.runNumber}`, width),
|
||||
truncateToWidth(
|
||||
`Result: ${runtime.lastRunSummary.passed ? "passed" : "failed"}${runtime.lastRunSummary.parsedPrimary !== null ? ` ${state.metricName} ${formatNum(runtime.lastRunSummary.parsedPrimary, state.metricUnit)}` : ""}`,
|
||||
width,
|
||||
),
|
||||
truncateToWidth("Next action: finish log_experiment before starting another run.", width),
|
||||
];
|
||||
if (!runtime.autoresearchMode) {
|
||||
lines.push(truncateToWidth("Mode: off", width));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
if (runtime.autoresearchMode) {
|
||||
return [
|
||||
truncateToWidth("Current segment: 0 runs", width),
|
||||
truncateToWidth("Baseline: pending", width),
|
||||
truncateToWidth("Next action: run and log the baseline experiment.", width),
|
||||
];
|
||||
}
|
||||
return [theme.fg("dim", "No experiments logged yet.")];
|
||||
}
|
||||
|
||||
const current = currentResults(state.results, state.currentSegment);
|
||||
const kept = current.filter(result => result.status === "keep").length;
|
||||
const discarded = current.filter(result => result.status === "discard").length;
|
||||
const crashed = current.filter(result => result.status === "crash").length;
|
||||
const checksFailed = current.filter(result => result.status === "checks_failed").length;
|
||||
const baseline = findBaselineMetric(state.results, state.currentSegment);
|
||||
const baselineRunNumber = findBaselineRunNumber(state.results, state.currentSegment);
|
||||
const baselineSecondary = findBaselineSecondary(state.results, state.currentSegment, state.secondaryMetrics);
|
||||
const best = findBestResult(state);
|
||||
const lines = [
|
||||
truncateToWidth(
|
||||
`Current segment: ${current.length} runs ${kept} kept ${discarded} discarded ${crashed} crashed ${checksFailed} checks_failed`,
|
||||
width,
|
||||
),
|
||||
truncateToWidth(
|
||||
`Baseline: ${formatNum(baseline, state.metricUnit)}${baselineRunNumber ? ` (#${baselineRunNumber})` : ""}`,
|
||||
width,
|
||||
),
|
||||
];
|
||||
if (state.results.length > current.length) {
|
||||
lines.push(
|
||||
truncateToWidth(`Archived from earlier segments: ${state.results.length - current.length} runs`, width),
|
||||
);
|
||||
}
|
||||
if (runtime.lastRunSummary) {
|
||||
lines.push(
|
||||
truncateToWidth(
|
||||
`Pending run: #${runtime.lastRunSummary.runNumber} (${runtime.lastRunSummary.passed ? "passed" : "failed"}) — log_experiment required`,
|
||||
width,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!runtime.autoresearchMode) {
|
||||
lines.push(truncateToWidth(`Mode: ${renderModeStatus(runtime, state)}`, width));
|
||||
}
|
||||
if (best) {
|
||||
const bestRunNumber = best.result.runNumber ?? best.index + 1;
|
||||
let progress = `Best: ${formatNum(best.result.metric, state.metricUnit)} (#${bestRunNumber})`;
|
||||
if (baseline !== null && baseline !== 0 && best.result.metric !== baseline) {
|
||||
const delta = ((best.result.metric - baseline) / baseline) * 100;
|
||||
const sign = delta > 0 ? "+" : "";
|
||||
progress += ` ${sign}${delta.toFixed(1)}%`;
|
||||
}
|
||||
if (state.confidence !== null) {
|
||||
progress += ` conf ${state.confidence.toFixed(1)}x`;
|
||||
}
|
||||
lines.push(truncateToWidth(progress, width));
|
||||
if (state.secondaryMetrics.length > 0) {
|
||||
const details = state.secondaryMetrics
|
||||
.map(metric =>
|
||||
renderSecondarySummary(
|
||||
metric.name,
|
||||
best.result.metrics[metric.name],
|
||||
baselineSecondary[metric.name],
|
||||
metric.unit,
|
||||
),
|
||||
)
|
||||
.filter((value): value is string => Boolean(value));
|
||||
if (details.length > 0) {
|
||||
lines.push(truncateToWidth(`Secondary: ${details.join(" ")}`, width));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(renderTableHeader(state, width, theme));
|
||||
lines.push(theme.fg("borderMuted", "-".repeat(Math.max(0, width - 1))));
|
||||
|
||||
const visible = maxRows > 0 ? current.slice(-maxRows) : current;
|
||||
if (visible.length < current.length) {
|
||||
lines.push(theme.fg("dim", `... ${current.length - visible.length} earlier runs hidden ...`));
|
||||
}
|
||||
for (const result of visible) {
|
||||
lines.push(renderResultRow(result, state, baselineSecondary, width, theme));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function renderTableHeader(state: ExperimentState, width: number, theme: Theme): string {
|
||||
const secondaryHeader = state.secondaryMetrics.map(metric => truncateToWidth(metric.name, 10)).join(" ");
|
||||
return truncateToWidth(
|
||||
`${theme.fg("muted", "#".padEnd(4))}${theme.fg("muted", "commit".padEnd(10))}${theme.fg("warning", state.metricName.padEnd(12))}${secondaryHeader ? `${theme.fg("muted", secondaryHeader)} ` : ""}${theme.fg("muted", "status".padEnd(14))}${theme.fg("muted", "description")}`,
|
||||
width,
|
||||
);
|
||||
}
|
||||
|
||||
function renderResultRow(
|
||||
result: ExperimentResult,
|
||||
state: ExperimentState,
|
||||
baselineSecondary: { [key: string]: number },
|
||||
width: number,
|
||||
theme: Theme,
|
||||
): string {
|
||||
const runNumber = result.runNumber ?? state.results.indexOf(result) + 1;
|
||||
const secondary = state.secondaryMetrics
|
||||
.map(metric =>
|
||||
truncateToWidth(
|
||||
renderSecondaryCell(result.metrics[metric.name], metric.unit, baselineSecondary[metric.name]),
|
||||
10,
|
||||
).padEnd(11),
|
||||
)
|
||||
.join("");
|
||||
const statusColor = result.status === "keep" ? "success" : result.status === "discard" ? "warning" : "error";
|
||||
const line =
|
||||
`${theme.fg("dim", String(runNumber).padEnd(4))}` +
|
||||
`${theme.fg("accent", (result.commit || "-").padEnd(10))}` +
|
||||
`${theme.fg(statusColor, formatNum(result.metric, state.metricUnit).padEnd(12))}` +
|
||||
`${secondary}` +
|
||||
`${theme.fg(statusColor, result.status.padEnd(14))}` +
|
||||
`${theme.fg("muted", replaceTabs(result.description))}`;
|
||||
return truncateToWidth(line, width);
|
||||
}
|
||||
|
||||
function renderSecondaryCell(value: number | undefined, unit: string, baseline: number | undefined): string {
|
||||
if (value === undefined) return "-";
|
||||
const formatted = formatNum(value, unit);
|
||||
if (baseline === undefined || baseline === 0 || baseline === value) return formatted;
|
||||
const delta = ((value - baseline) / baseline) * 100;
|
||||
const sign = delta > 0 ? "+" : "";
|
||||
return `${formatted} ${sign}${delta.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function renderSecondarySummary(
|
||||
name: string,
|
||||
value: number | undefined,
|
||||
baseline: number | undefined,
|
||||
unit: string,
|
||||
): string | null {
|
||||
if (value === undefined) return null;
|
||||
if (baseline === undefined || baseline === 0 || baseline === value) {
|
||||
return `${name} ${formatNum(value, unit)}`;
|
||||
}
|
||||
const delta = ((value - baseline) / baseline) * 100;
|
||||
const sign = delta > 0 ? "+" : "";
|
||||
return `${name} ${formatNum(value, unit)} ${sign}${delta.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function renderOverlayRunningLine(
|
||||
runtime: AutoresearchRuntime,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
spinnerFrame: number,
|
||||
): string {
|
||||
const spinner = theme.spinnerFrames[spinnerFrame % theme.spinnerFrames.length] ?? "*";
|
||||
return truncateToWidth(
|
||||
theme.fg(
|
||||
"warning",
|
||||
`${spinner} running ${formatElapsed(Date.now() - (runtime.runningExperiment?.startedAt ?? Date.now()))} ${replaceTabs(
|
||||
runtime.runningExperiment?.command ?? "",
|
||||
)}`,
|
||||
),
|
||||
width,
|
||||
);
|
||||
}
|
||||
|
||||
function renderOverlayFooter(
|
||||
width: number,
|
||||
scrollOffset: number,
|
||||
viewportRows: number,
|
||||
totalRows: number,
|
||||
theme: Theme,
|
||||
): string {
|
||||
const position =
|
||||
totalRows > viewportRows
|
||||
? ` ${scrollOffset + 1}-${Math.min(totalRows, scrollOffset + viewportRows)}/${totalRows}`
|
||||
: "";
|
||||
const hint = theme.fg("dim", ` up/down j/k pageup pagedown g G esc${position} `);
|
||||
const fill = Math.max(0, width - visibleWidth(hint));
|
||||
return theme.fg("borderMuted", "-".repeat(fill)) + hint;
|
||||
}
|
||||
|
||||
function renderModeStatus(runtime: AutoresearchRuntime, state: ExperimentState): string {
|
||||
if (runtime.autoresearchMode) {
|
||||
return state.results.length === 0 ? "baseline pending" : "mode on";
|
||||
}
|
||||
const current = currentResults(state.results, state.currentSegment);
|
||||
if (state.maxExperiments !== null && current.length >= state.maxExperiments) {
|
||||
return "segment complete";
|
||||
}
|
||||
return "mode off";
|
||||
}
|
||||
|
||||
function findBestResult(state: ExperimentState): { index: number; result: ExperimentResult } | null {
|
||||
let best: { index: number; result: ExperimentResult } | null = null;
|
||||
for (let index = 0; index < state.results.length; index += 1) {
|
||||
const result = state.results[index];
|
||||
if (result.segment !== state.currentSegment || result.status !== "keep" || result.metric <= 0) continue;
|
||||
if (!best || isBetter(result.metric, best.result.metric, state.bestDirection)) {
|
||||
best = { index, result };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { ExtensionAPI } from "../extensibility/extensions";
|
||||
import { isAutoresearchLocalStatePath, normalizeAutoresearchPath } from "./helpers";
|
||||
|
||||
const AUTORESEARCH_BRANCH_PREFIX = "autoresearch/";
|
||||
const BRANCH_NAME_MAX_LENGTH = 48;
|
||||
|
||||
export interface EnsureAutoresearchBranchFailure {
|
||||
error: string;
|
||||
ok: false;
|
||||
}
|
||||
|
||||
export interface EnsureAutoresearchBranchSuccess {
|
||||
branchName: string;
|
||||
created: boolean;
|
||||
ok: true;
|
||||
}
|
||||
|
||||
export type EnsureAutoresearchBranchResult = EnsureAutoresearchBranchFailure | EnsureAutoresearchBranchSuccess;
|
||||
|
||||
export async function getCurrentAutoresearchBranch(api: ExtensionAPI, workDir: string): Promise<string | null> {
|
||||
const currentBranchResult = await api.exec("git", ["branch", "--show-current"], { cwd: workDir, timeout: 5_000 });
|
||||
const currentBranch = currentBranchResult.stdout.trim();
|
||||
return currentBranch.startsWith(AUTORESEARCH_BRANCH_PREFIX) ? currentBranch : null;
|
||||
}
|
||||
|
||||
export async function ensureAutoresearchBranch(
|
||||
api: ExtensionAPI,
|
||||
workDir: string,
|
||||
goal: string | null,
|
||||
): Promise<EnsureAutoresearchBranchResult> {
|
||||
const repoRootResult = await api.exec("git", ["rev-parse", "--show-toplevel"], { cwd: workDir, timeout: 5_000 });
|
||||
if (repoRootResult.code !== 0) {
|
||||
return {
|
||||
error: "Autoresearch requires a git repository so it can isolate experiments and revert failed runs safely.",
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
const repoRoot = repoRootResult.stdout.trim() || workDir;
|
||||
|
||||
const dirtyPathsResult = await api.exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
|
||||
cwd: repoRoot,
|
||||
timeout: 5_000,
|
||||
});
|
||||
if (dirtyPathsResult.code !== 0) {
|
||||
return {
|
||||
error: `Unable to inspect git status before starting autoresearch: ${mergeStdoutStderr(dirtyPathsResult).trim() || `exit ${dirtyPathsResult.code}`}`,
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
|
||||
const workDirPrefix = await readGitWorkDirPrefix(api, workDir);
|
||||
const unsafeDirtyPaths = collectUnsafeDirtyPaths(dirtyPathsResult.stdout, workDirPrefix);
|
||||
const currentBranch = await getCurrentAutoresearchBranch(api, workDir);
|
||||
if (currentBranch) {
|
||||
if (unsafeDirtyPaths.length > 0) {
|
||||
return buildUnsafeDirtyPathsFailure(unsafeDirtyPaths);
|
||||
}
|
||||
return {
|
||||
branchName: currentBranch,
|
||||
created: false,
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
if (unsafeDirtyPaths.length > 0) {
|
||||
return buildUnsafeDirtyPathsFailure(unsafeDirtyPaths);
|
||||
}
|
||||
|
||||
const branchName = await allocateBranchName(api, workDir, goal);
|
||||
const checkoutResult = await api.exec("git", ["checkout", "-b", branchName], { cwd: workDir, timeout: 10_000 });
|
||||
if (checkoutResult.code !== 0) {
|
||||
return {
|
||||
error:
|
||||
`Failed to create autoresearch branch ${branchName}: ` +
|
||||
`${mergeStdoutStderr(checkoutResult).trim() || `exit ${checkoutResult.code}`}`,
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
branchName,
|
||||
created: true,
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseWorkDirDirtyPaths(statusOutput: string, workDirPrefix: string): string[] {
|
||||
const relativePaths: string[] = [];
|
||||
for (const dirtyPath of parseDirtyPaths(statusOutput)) {
|
||||
const relativePath = relativizeGitPathToWorkDir(dirtyPath, workDirPrefix);
|
||||
if (relativePath === null) continue;
|
||||
relativePaths.push(relativePath);
|
||||
}
|
||||
return relativePaths;
|
||||
}
|
||||
|
||||
export function relativizeGitPathToWorkDir(repoRelativePath: string, workDirPrefix: string): string | null {
|
||||
const normalizedPath = normalizeStatusPath(repoRelativePath);
|
||||
const normalizedPrefix = normalizeAutoresearchPath(workDirPrefix);
|
||||
if (normalizedPrefix === "" || normalizedPrefix === ".") {
|
||||
return normalizedPath;
|
||||
}
|
||||
if (normalizedPath === normalizedPrefix) {
|
||||
return ".";
|
||||
}
|
||||
if (!normalizedPath.startsWith(`${normalizedPrefix}/`)) {
|
||||
return null;
|
||||
}
|
||||
return normalizeAutoresearchPath(normalizedPath.slice(normalizedPrefix.length + 1));
|
||||
}
|
||||
|
||||
async function readGitWorkDirPrefix(api: ExtensionAPI, workDir: string): Promise<string> {
|
||||
const prefixResult = await api.exec("git", ["rev-parse", "--show-prefix"], { cwd: workDir, timeout: 5_000 });
|
||||
if (prefixResult.code !== 0) {
|
||||
return "";
|
||||
}
|
||||
return prefixResult.stdout.trim();
|
||||
}
|
||||
|
||||
export function parseDirtyPaths(statusOutput: string): string[] {
|
||||
if (statusOutput.includes("\0")) {
|
||||
return parseDirtyPathsNul(statusOutput);
|
||||
}
|
||||
return parseDirtyPathsLines(statusOutput);
|
||||
}
|
||||
|
||||
function parseDirtyPathsNul(statusOutput: string): string[] {
|
||||
const unsafePaths = new Set<string>();
|
||||
let index = 0;
|
||||
while (index + 3 <= statusOutput.length) {
|
||||
const statusToken = statusOutput.slice(index, index + 3);
|
||||
index += 3;
|
||||
const pathEnd = statusOutput.indexOf("\0", index);
|
||||
if (pathEnd < 0) break;
|
||||
const firstPath = statusOutput.slice(index, pathEnd);
|
||||
index = pathEnd + 1;
|
||||
addDirtyPath(unsafePaths, firstPath);
|
||||
if (isRenameOrCopy(statusToken)) {
|
||||
const secondPathEnd = statusOutput.indexOf("\0", index);
|
||||
if (secondPathEnd < 0) break;
|
||||
const secondPath = statusOutput.slice(index, secondPathEnd);
|
||||
index = secondPathEnd + 1;
|
||||
addDirtyPath(unsafePaths, secondPath);
|
||||
}
|
||||
}
|
||||
return [...unsafePaths];
|
||||
}
|
||||
|
||||
function parseDirtyPathsLines(statusOutput: string): string[] {
|
||||
const unsafePaths = new Set<string>();
|
||||
for (const line of statusOutput.split("\n")) {
|
||||
const trimmedLine = line.trimEnd();
|
||||
if (trimmedLine.length < 4) continue;
|
||||
const rawPath = trimmedLine.slice(3).trim();
|
||||
if (rawPath.length === 0) continue;
|
||||
const renameParts = rawPath.split(" -> ");
|
||||
for (const renamePart of renameParts) {
|
||||
addDirtyPath(unsafePaths, renamePart);
|
||||
}
|
||||
}
|
||||
return [...unsafePaths];
|
||||
}
|
||||
|
||||
export function normalizeStatusPath(path: string): string {
|
||||
let normalized = path.trim();
|
||||
if (normalized.startsWith('"') && normalized.endsWith('"')) {
|
||||
normalized = normalized.slice(1, -1);
|
||||
}
|
||||
return normalizeAutoresearchPath(normalized);
|
||||
}
|
||||
|
||||
async function allocateBranchName(api: ExtensionAPI, workDir: string, goal: string | null): Promise<string> {
|
||||
const baseName = `${AUTORESEARCH_BRANCH_PREFIX}${slugifyGoal(goal)}-${currentDateStamp()}`;
|
||||
let candidate = baseName;
|
||||
let suffix = 2;
|
||||
while (await branchExists(api, workDir, candidate)) {
|
||||
candidate = `${baseName}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function branchExists(api: ExtensionAPI, workDir: string, branchName: string): Promise<boolean> {
|
||||
const result = await api.exec("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
|
||||
cwd: workDir,
|
||||
timeout: 5_000,
|
||||
});
|
||||
return result.code === 0;
|
||||
}
|
||||
|
||||
function slugifyGoal(goal: string | null): string {
|
||||
const normalized = (goal ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
const trimmed = normalized.slice(0, BRANCH_NAME_MAX_LENGTH).replace(/-+$/g, "");
|
||||
return trimmed || "session";
|
||||
}
|
||||
|
||||
function currentDateStamp(): string {
|
||||
const now = new Date();
|
||||
const year = String(now.getFullYear());
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
function mergeStdoutStderr(result: { stderr: string; stdout: string }): string {
|
||||
return `${result.stdout}${result.stderr}`;
|
||||
}
|
||||
|
||||
function addDirtyPath(paths: Set<string>, rawPath: string): void {
|
||||
const normalizedPath = normalizeStatusPath(rawPath);
|
||||
if (normalizedPath.length === 0) return;
|
||||
paths.add(normalizedPath);
|
||||
}
|
||||
|
||||
function buildUnsafeDirtyPathsFailure(unsafeDirtyPaths: string[]): EnsureAutoresearchBranchFailure {
|
||||
const preview = unsafeDirtyPaths.slice(0, 5).join(", ");
|
||||
const suffix = unsafeDirtyPaths.length > 5 ? ` (+${unsafeDirtyPaths.length - 5} more)` : "";
|
||||
return {
|
||||
error:
|
||||
"Autoresearch needs a clean git worktree before it can create or reuse an isolated branch. " +
|
||||
`Commit or stash these paths first: ${preview}${suffix}`,
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
|
||||
function isRenameOrCopy(statusToken: string): boolean {
|
||||
const trimmed = statusToken.trim();
|
||||
return trimmed.startsWith("R") || trimmed.startsWith("C");
|
||||
}
|
||||
|
||||
function collectUnsafeDirtyPaths(statusOutput: string, workDirPrefix: string): string[] {
|
||||
const unsafeDirtyPaths: string[] = [];
|
||||
for (const dirtyPath of parseDirtyPaths(statusOutput)) {
|
||||
const relativePath = relativizeGitPathToWorkDir(dirtyPath, workDirPrefix);
|
||||
if (relativePath && isAutoresearchLocalStatePath(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
unsafeDirtyPaths.push(relativePath ?? normalizeStatusPath(dirtyPath));
|
||||
}
|
||||
return unsafeDirtyPaths;
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { isEnoent } from "@oh-my-pi/pi-utils";
|
||||
import { parseCommandArgs } from "../utils/command-args";
|
||||
import type {
|
||||
ASIData,
|
||||
ASIValue,
|
||||
AutoresearchConfig,
|
||||
MetricDirection,
|
||||
NumericMetricMap,
|
||||
PendingRunSummary,
|
||||
} from "./types";
|
||||
|
||||
export const METRIC_LINE_PREFIX = "METRIC";
|
||||
export const ASI_LINE_PREFIX = "ASI";
|
||||
export const EXPERIMENT_MAX_LINES = 10;
|
||||
export const EXPERIMENT_MAX_BYTES = 4 * 1024;
|
||||
export const AUTORESEARCH_COMMITTABLE_FILES = [
|
||||
"autoresearch.md",
|
||||
"autoresearch.program.md",
|
||||
"autoresearch.sh",
|
||||
"autoresearch.checks.sh",
|
||||
"autoresearch.ideas.md",
|
||||
] as const;
|
||||
export const AUTORESEARCH_LOCAL_STATE_FILES = ["autoresearch.jsonl"] as const;
|
||||
export const AUTORESEARCH_LOCAL_STATE_DIRECTORIES = [".autoresearch"] as const;
|
||||
|
||||
const DENIED_KEY_NAMES = new Set(["__proto__", "constructor", "prototype"]);
|
||||
|
||||
export function parseMetricLines(output: string): Map<string, number> {
|
||||
const metrics = new Map<string, number>();
|
||||
const regex = new RegExp(`^${METRIC_LINE_PREFIX}\\s+([\\w.µ-]+)=(\\S+)\\s*$`, "gm");
|
||||
let match = regex.exec(output);
|
||||
while (match !== null) {
|
||||
const name = match[1];
|
||||
if (!DENIED_KEY_NAMES.has(name)) {
|
||||
const value = Number(match[2]);
|
||||
if (Number.isFinite(value)) {
|
||||
metrics.set(name, value);
|
||||
}
|
||||
}
|
||||
match = regex.exec(output);
|
||||
}
|
||||
return metrics;
|
||||
}
|
||||
|
||||
export function parseAsiLines(output: string): ASIData | null {
|
||||
const asi: ASIData = {};
|
||||
const regex = new RegExp(`^${ASI_LINE_PREFIX}\\s+([\\w.-]+)=(.+)\\s*$`, "gm");
|
||||
let match = regex.exec(output);
|
||||
while (match !== null) {
|
||||
const key = match[1];
|
||||
if (!DENIED_KEY_NAMES.has(key)) {
|
||||
asi[key] = parseAsiValue(match[2]);
|
||||
}
|
||||
match = regex.exec(output);
|
||||
}
|
||||
return Object.keys(asi).length > 0 ? asi : null;
|
||||
}
|
||||
|
||||
function parseAsiValue(raw: string): ASIValue {
|
||||
const value = raw.trim();
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
if (value === "null") return null;
|
||||
if (/^-?\d+(?:\.\d+)?$/.test(value)) {
|
||||
const numberValue = Number(value);
|
||||
if (Number.isFinite(numberValue)) return numberValue;
|
||||
}
|
||||
if (value.startsWith("{") || value.startsWith("[") || value.startsWith('"')) {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as ASIValue;
|
||||
return parsed;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function mergeAsi(base: ASIData | null, override: ASIData | undefined): ASIData | undefined {
|
||||
if (!base && !override) return undefined;
|
||||
return {
|
||||
...(base ?? {}),
|
||||
...(override ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function commas(value: number): string {
|
||||
const sign = value < 0 ? "-" : "";
|
||||
const digits = String(Math.trunc(Math.abs(value)));
|
||||
const groups: string[] = [];
|
||||
for (let index = digits.length; index > 0; index -= 3) {
|
||||
groups.unshift(digits.slice(Math.max(0, index - 3), index));
|
||||
}
|
||||
return sign + groups.join(",");
|
||||
}
|
||||
|
||||
export function fmtNum(value: number, decimals: number = 0): string {
|
||||
if (decimals <= 0) return commas(Math.round(value));
|
||||
const absolute = Math.abs(value);
|
||||
const whole = Math.floor(absolute);
|
||||
const fraction = (absolute - whole).toFixed(decimals).slice(1);
|
||||
return `${value < 0 ? "-" : ""}${commas(whole)}${fraction}`;
|
||||
}
|
||||
|
||||
export function formatNum(value: number | null, unit: string): string {
|
||||
if (value === null) return "-";
|
||||
if (Number.isInteger(value)) return `${fmtNum(value)}${unit}`;
|
||||
return `${fmtNum(value, 2)}${unit}`;
|
||||
}
|
||||
|
||||
export function formatElapsed(milliseconds: number): string {
|
||||
const totalSeconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
export function getAutoresearchRunDirectory(workDir: string, runNumber: number): string {
|
||||
return path.join(workDir, ".autoresearch", "runs", String(runNumber).padStart(4, "0"));
|
||||
}
|
||||
|
||||
export function getNextAutoresearchRunNumber(workDir: string, lastRunNumber: number | null): number {
|
||||
const runsDirectory = path.join(workDir, ".autoresearch", "runs");
|
||||
let maxRunNumber = lastRunNumber ?? 0;
|
||||
try {
|
||||
for (const entry of fs.readdirSync(runsDirectory, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const runNumber = Number.parseInt(entry.name, 10);
|
||||
if (Number.isFinite(runNumber)) {
|
||||
maxRunNumber = Math.max(maxRunNumber, runNumber);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isEnoent(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return maxRunNumber + 1;
|
||||
}
|
||||
|
||||
export function normalizeAutoresearchPath(relativePath: string): string {
|
||||
const normalized = relativePath.replaceAll("\\", "/").trim();
|
||||
if (normalized === "." || normalized === "./") return ".";
|
||||
return normalized.replace(/^\.\/+/, "").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function isAutoresearchCommittableFile(relativePath: string): boolean {
|
||||
const normalized = normalizeAutoresearchPath(relativePath);
|
||||
return AUTORESEARCH_COMMITTABLE_FILES.some(candidate => candidate === normalized);
|
||||
}
|
||||
|
||||
export function isAutoresearchLocalStatePath(relativePath: string): boolean {
|
||||
const normalized = normalizeAutoresearchPath(relativePath);
|
||||
if (AUTORESEARCH_LOCAL_STATE_FILES.some(candidate => candidate === normalized)) {
|
||||
return true;
|
||||
}
|
||||
return AUTORESEARCH_LOCAL_STATE_DIRECTORIES.some(candidate => {
|
||||
const normalizedCandidate = normalizeAutoresearchPath(candidate);
|
||||
return normalized === normalizedCandidate || normalized.startsWith(`${normalizedCandidate}/`);
|
||||
});
|
||||
}
|
||||
|
||||
export function killTree(pid: number, signal: NodeJS.Signals | number = "SIGTERM"): void {
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
} catch {
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch {
|
||||
// Process already exited.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isAutoresearchShCommand(command: string): boolean {
|
||||
let normalized = command.trim();
|
||||
normalized = normalized.replace(/^(?:\w+=\S*\s+)+/, "");
|
||||
|
||||
let previous = "";
|
||||
while (previous !== normalized) {
|
||||
previous = normalized;
|
||||
normalized = normalized.replace(/^(?:env|time|nice|nohup)(?:\s+-\S+(?:\s+\d+)?)?\s+/, "");
|
||||
}
|
||||
if (/[;&|<>]/.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tokens = parseCommandArgs(normalized);
|
||||
if (tokens.length === 0) return false;
|
||||
|
||||
let index = 0;
|
||||
if (tokens[index] === "bash" || tokens[index] === "sh") {
|
||||
index += 1;
|
||||
while (index < tokens.length && tokens[index]?.startsWith("-")) {
|
||||
if (tokens[index]?.includes("c")) {
|
||||
return false;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const scriptToken = tokens[index];
|
||||
if (!scriptToken || !/^(?:\.\/|\/[\w/.-]*\/)?autoresearch\.sh$/.test(scriptToken)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const token of tokens.slice(index + 1)) {
|
||||
if (token === "&&" || token === "||" || token === ";" || token === "|" || token === ">" || token === "<") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isBetter(current: number, best: number, direction: MetricDirection): boolean {
|
||||
return direction === "lower" ? current < best : current > best;
|
||||
}
|
||||
|
||||
export function inferMetricUnitFromName(name: string): string {
|
||||
if (name.endsWith("µs") || name.endsWith("_µs")) return "µs";
|
||||
if (name.endsWith("ms") || name.endsWith("_ms")) return "ms";
|
||||
if (name.endsWith("_s") || name.endsWith("_sec") || name.endsWith("_secs")) return "s";
|
||||
if (name.endsWith("_kb") || name.endsWith("kb")) return "kb";
|
||||
if (name.endsWith("_mb") || name.endsWith("mb")) return "mb";
|
||||
return "";
|
||||
}
|
||||
|
||||
export async function readPendingRunSummary(
|
||||
workDir: string,
|
||||
loggedRunNumbers: ReadonlySet<number> = new Set<number>(),
|
||||
): Promise<PendingRunSummary | null> {
|
||||
const runsDir = path.join(workDir, ".autoresearch", "runs");
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = await fs.promises.readdir(runsDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const runDirectories = entries
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => entry.name)
|
||||
.sort((left, right) => right.localeCompare(left));
|
||||
|
||||
for (const directoryName of runDirectories) {
|
||||
const runDirectory = path.join(runsDir, directoryName);
|
||||
const runJsonPath = path.join(runDirectory, "run.json");
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await Bun.file(runJsonPath).json();
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) continue;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const pendingRun = parsePendingRunSummary(parsed, runDirectory, directoryName, loggedRunNumbers);
|
||||
if (pendingRun) {
|
||||
return pendingRun;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readConfig(cwd: string): AutoresearchConfig {
|
||||
const configPath = path.join(cwd, "autoresearch.config.json");
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null) return {};
|
||||
const candidate = parsed as { maxIterations?: unknown; workingDir?: unknown };
|
||||
const config: AutoresearchConfig = {};
|
||||
if (typeof candidate.maxIterations === "number" && Number.isFinite(candidate.maxIterations)) {
|
||||
config.maxIterations = candidate.maxIterations;
|
||||
}
|
||||
if (typeof candidate.workingDir === "string" && candidate.workingDir.trim().length > 0) {
|
||||
config.workingDir = candidate.workingDir;
|
||||
}
|
||||
return config;
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) return {};
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function readMaxExperiments(cwd: string): number | null {
|
||||
const value = readConfig(cwd).maxIterations;
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
export function resolveWorkDir(cwd: string): string {
|
||||
const configured = readConfig(cwd).workingDir;
|
||||
if (!configured) return cwd;
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
|
||||
export function validateWorkDir(cwd: string): string | null {
|
||||
const workDir = resolveWorkDir(cwd);
|
||||
try {
|
||||
const stat = fs.statSync(workDir);
|
||||
if (!stat.isDirectory()) {
|
||||
return `workingDir ${workDir} is not a directory.`;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) {
|
||||
return `workingDir ${workDir} does not exist.`;
|
||||
}
|
||||
return `workingDir ${workDir} is unavailable.`;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePendingRunSummary(
|
||||
value: unknown,
|
||||
runDirectory: string,
|
||||
directoryName: string,
|
||||
loggedRunNumbers: ReadonlySet<number>,
|
||||
): PendingRunSummary | null {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
const candidate = value as {
|
||||
checks?: { durationSeconds?: unknown; passed?: unknown; timedOut?: unknown };
|
||||
completedAt?: unknown;
|
||||
command?: unknown;
|
||||
durationSeconds?: unknown;
|
||||
exitCode?: unknown;
|
||||
loggedAt?: unknown;
|
||||
parsedAsi?: unknown;
|
||||
parsedMetrics?: unknown;
|
||||
parsedPrimary?: unknown;
|
||||
runNumber?: unknown;
|
||||
status?: unknown;
|
||||
timedOut?: unknown;
|
||||
};
|
||||
if (candidate.loggedAt !== undefined || candidate.status !== undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const command = typeof candidate.command === "string" ? candidate.command : "";
|
||||
const runNumber =
|
||||
typeof candidate.runNumber === "number" && Number.isFinite(candidate.runNumber)
|
||||
? candidate.runNumber
|
||||
: parseInt(directoryName, 10);
|
||||
if (!Number.isFinite(runNumber)) return null;
|
||||
if (loggedRunNumbers.has(runNumber)) return null;
|
||||
|
||||
const hasCompletedMetadata =
|
||||
typeof candidate.completedAt === "string" ||
|
||||
candidate.exitCode !== undefined ||
|
||||
candidate.timedOut !== undefined ||
|
||||
candidate.durationSeconds !== undefined ||
|
||||
candidate.checks !== undefined ||
|
||||
candidate.parsedPrimary !== undefined ||
|
||||
candidate.parsedMetrics !== undefined ||
|
||||
candidate.parsedAsi !== undefined;
|
||||
if (!hasCompletedMetadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const checksPass =
|
||||
typeof candidate.checks?.passed === "boolean"
|
||||
? candidate.checks.passed
|
||||
: typeof candidate.checks?.timedOut === "boolean" && candidate.checks.timedOut
|
||||
? false
|
||||
: null;
|
||||
const exitCode =
|
||||
typeof candidate.exitCode === "number" && Number.isFinite(candidate.exitCode) ? candidate.exitCode : null;
|
||||
const timedOut = candidate.timedOut === true;
|
||||
const durationSeconds =
|
||||
typeof candidate.durationSeconds === "number" && Number.isFinite(candidate.durationSeconds)
|
||||
? candidate.durationSeconds
|
||||
: null;
|
||||
const parsedPrimary =
|
||||
typeof candidate.parsedPrimary === "number" && Number.isFinite(candidate.parsedPrimary)
|
||||
? candidate.parsedPrimary
|
||||
: null;
|
||||
const parsedAsi = cloneAsiData(candidate.parsedAsi);
|
||||
const parsedMetrics = cloneNumericMetricMap(candidate.parsedMetrics);
|
||||
const checksDurationSeconds =
|
||||
typeof candidate.checks?.durationSeconds === "number" && Number.isFinite(candidate.checks.durationSeconds)
|
||||
? candidate.checks.durationSeconds
|
||||
: null;
|
||||
const checksTimedOut = candidate.checks?.timedOut === true;
|
||||
|
||||
return {
|
||||
checksDurationSeconds,
|
||||
checksPass,
|
||||
checksTimedOut,
|
||||
command,
|
||||
durationSeconds,
|
||||
parsedAsi,
|
||||
parsedMetrics,
|
||||
parsedPrimary,
|
||||
passed: exitCode === 0 && !timedOut && checksPass !== false,
|
||||
runDirectory,
|
||||
runNumber,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneNumericMetricMap(value: unknown): NumericMetricMap | null {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
const metrics = value as { [key: string]: unknown };
|
||||
const clone: NumericMetricMap = {};
|
||||
for (const [key, entryValue] of Object.entries(metrics)) {
|
||||
if (DENIED_KEY_NAMES.has(key)) continue;
|
||||
if (typeof entryValue === "number" && Number.isFinite(entryValue)) {
|
||||
clone[key] = entryValue;
|
||||
}
|
||||
}
|
||||
return Object.keys(clone).length > 0 ? clone : null;
|
||||
}
|
||||
|
||||
function cloneAsiData(value: unknown): ASIData | null {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
const candidate = value as { [key: string]: unknown };
|
||||
const clone: ASIData = {};
|
||||
for (const [key, entryValue] of Object.entries(candidate)) {
|
||||
if (DENIED_KEY_NAMES.has(key)) continue;
|
||||
const sanitized = clonePendingAsiValue(entryValue);
|
||||
if (sanitized !== undefined) {
|
||||
clone[key] = sanitized;
|
||||
}
|
||||
}
|
||||
return Object.keys(clone).length > 0 ? clone : null;
|
||||
}
|
||||
|
||||
function clonePendingAsiValue(value: unknown): ASIValue | undefined {
|
||||
if (value === null) return null;
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const items = value
|
||||
.map(entry => clonePendingAsiValue(entry))
|
||||
.filter((entry): entry is NonNullable<typeof entry> => entry !== undefined);
|
||||
return items;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const candidate = value as { [key: string]: unknown };
|
||||
const clone: { [key: string]: ASIValue } = {};
|
||||
for (const [key, entryValue] of Object.entries(candidate)) {
|
||||
if (DENIED_KEY_NAMES.has(key)) continue;
|
||||
const sanitized = clonePendingAsiValue(entryValue);
|
||||
if (sanitized !== undefined) {
|
||||
clone[key] = sanitized;
|
||||
}
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { AutocompleteItem } from "@oh-my-pi/pi-tui";
|
||||
import { renderPromptTemplate } from "../config/prompt-templates";
|
||||
import type { ExtensionContext, ExtensionFactory } from "../extensibility/extensions";
|
||||
import commandInitializeTemplate from "./command-initialize.md" with { type: "text" };
|
||||
import commandResumeTemplate from "./command-resume.md" with { type: "text" };
|
||||
import { pathMatchesContractPath } from "./contract";
|
||||
import { createDashboardController } from "./dashboard";
|
||||
import { ensureAutoresearchBranch } from "./git";
|
||||
import {
|
||||
formatNum,
|
||||
isAutoresearchCommittableFile,
|
||||
isAutoresearchLocalStatePath,
|
||||
isAutoresearchShCommand,
|
||||
normalizeAutoresearchPath,
|
||||
readMaxExperiments,
|
||||
readPendingRunSummary,
|
||||
resolveWorkDir,
|
||||
validateWorkDir,
|
||||
} from "./helpers";
|
||||
import promptTemplate from "./prompt.md" with { type: "text" };
|
||||
import resumeMessageTemplate from "./resume-message.md" with { type: "text" };
|
||||
import {
|
||||
cloneExperimentState,
|
||||
createExperimentState,
|
||||
createRuntimeStore,
|
||||
currentResults,
|
||||
findBaselineMetric,
|
||||
reconstructControlState,
|
||||
reconstructStateFromJsonl,
|
||||
} from "./state";
|
||||
import { createInitExperimentTool } from "./tools/init-experiment";
|
||||
import { createLogExperimentTool } from "./tools/log-experiment";
|
||||
import { createRunExperimentTool } from "./tools/run-experiment";
|
||||
import type { AutoresearchRuntime, ChecksResult, ExperimentResult, PendingRunSummary } from "./types";
|
||||
|
||||
const EXPERIMENT_TOOL_NAMES = ["init_experiment", "run_experiment", "log_experiment"];
|
||||
|
||||
interface AutoresearchSetupInput {
|
||||
intent: string;
|
||||
benchmarkCommand: string;
|
||||
metricName: string;
|
||||
metricUnit: string;
|
||||
direction: "lower" | "higher";
|
||||
secondaryMetrics: string[];
|
||||
scopePaths: string[];
|
||||
offLimits: string[];
|
||||
constraints: string[];
|
||||
}
|
||||
|
||||
export const createAutoresearchExtension: ExtensionFactory = api => {
|
||||
const runtimeStore = createRuntimeStore();
|
||||
const dashboard = createDashboardController();
|
||||
|
||||
const getSessionKey = (ctx: ExtensionContext): string => ctx.sessionManager.getSessionId();
|
||||
const getRuntime = (ctx: ExtensionContext): AutoresearchRuntime => runtimeStore.ensure(getSessionKey(ctx));
|
||||
|
||||
const rehydrate = async (ctx: ExtensionContext): Promise<void> => {
|
||||
const runtime = getRuntime(ctx);
|
||||
const workDir = resolveWorkDir(ctx.cwd);
|
||||
const reconstructed = reconstructStateFromJsonl(workDir);
|
||||
const control = reconstructControlState(ctx.sessionManager.getBranch());
|
||||
const loggedRunNumbers = collectLoggedRunNumbers(reconstructed.state.results);
|
||||
runtime.state = cloneExperimentState(reconstructed.state);
|
||||
runtime.state.maxExperiments = readMaxExperiments(ctx.cwd);
|
||||
runtime.goal = control.goal;
|
||||
runtime.autoresearchMode = control.autoresearchMode;
|
||||
runtime.autoResumeArmed = false;
|
||||
runtime.lastAutoResumePendingRunNumber = null;
|
||||
runtime.lastRunSummary = await readPendingRunSummary(workDir, loggedRunNumbers);
|
||||
runtime.lastRunChecks = summaryToChecks(runtime.lastRunSummary);
|
||||
runtime.lastRunDuration = runtime.lastRunSummary?.durationSeconds ?? null;
|
||||
runtime.lastRunAsi = runtime.lastRunSummary?.parsedAsi ?? null;
|
||||
runtime.lastRunArtifactDir = runtime.lastRunSummary?.runDirectory ?? null;
|
||||
runtime.lastRunNumber = runtime.lastRunSummary?.runNumber ?? null;
|
||||
runtime.runningExperiment = null;
|
||||
dashboard.updateWidget(ctx, runtime);
|
||||
const activeTools = api.getActiveTools();
|
||||
const experimentTools = new Set(EXPERIMENT_TOOL_NAMES);
|
||||
const nextActiveTools = runtime.autoresearchMode
|
||||
? [...new Set([...activeTools, ...EXPERIMENT_TOOL_NAMES])]
|
||||
: activeTools.filter(name => !experimentTools.has(name));
|
||||
const toolsChanged =
|
||||
nextActiveTools.length !== activeTools.length ||
|
||||
nextActiveTools.some((name, index) => name !== activeTools[index]);
|
||||
if (toolsChanged) {
|
||||
await api.setActiveTools(nextActiveTools);
|
||||
}
|
||||
};
|
||||
|
||||
const setMode = (
|
||||
ctx: ExtensionContext,
|
||||
enabled: boolean,
|
||||
goal: string | null,
|
||||
mode: "on" | "off" | "clear",
|
||||
): void => {
|
||||
const runtime = getRuntime(ctx);
|
||||
runtime.autoresearchMode = enabled;
|
||||
runtime.autoResumeArmed = false;
|
||||
runtime.goal = goal;
|
||||
runtime.lastAutoResumePendingRunNumber = null;
|
||||
api.appendEntry("autoresearch-control", goal ? { mode, goal } : { mode });
|
||||
};
|
||||
|
||||
api.registerTool(createInitExperimentTool({ dashboard, getRuntime, pi: api }));
|
||||
api.registerTool(createRunExperimentTool({ dashboard, getRuntime, pi: api }));
|
||||
api.registerTool(createLogExperimentTool({ dashboard, getRuntime, pi: api }));
|
||||
api.on("tool_call", (event, ctx) => {
|
||||
const runtime = getRuntime(ctx);
|
||||
if (!runtime.autoresearchMode) return;
|
||||
if (event.toolName === "bash") {
|
||||
const command = typeof event.input.command === "string" ? event.input.command : "";
|
||||
const validationError = validateAutoresearchBashCommand(command);
|
||||
if (validationError) {
|
||||
return {
|
||||
block: true,
|
||||
reason: validationError,
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.toolName !== "write" && event.toolName !== "edit" && event.toolName !== "ast_edit") return;
|
||||
|
||||
const rawPaths = getGuardedToolPaths(event.toolName, event.input);
|
||||
if (rawPaths === null) {
|
||||
return {
|
||||
block: true,
|
||||
reason:
|
||||
"Autoresearch requires an explicit target path for this editing tool so it can enforce Files in Scope and Off Limits before changes are made.",
|
||||
};
|
||||
}
|
||||
|
||||
const workDir = resolveWorkDir(ctx.cwd);
|
||||
for (const rawPath of rawPaths) {
|
||||
const relativePath = resolveAutoresearchRelativePath(workDir, rawPath);
|
||||
if (!relativePath.ok) {
|
||||
return {
|
||||
block: true,
|
||||
reason: relativePath.reason,
|
||||
};
|
||||
}
|
||||
const validationError = validateEditableAutoresearchPath(relativePath.relativePath, runtime);
|
||||
if (validationError) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Autoresearch blocked edits to ${relativePath.relativePath}: ${validationError}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
api.registerCommand("autoresearch", {
|
||||
description: "Start, stop, or clear builtin autoresearch mode.",
|
||||
getArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
|
||||
if (argumentPrefix.includes(" ")) return null;
|
||||
const completions: AutocompleteItem[] = [
|
||||
{ label: "off", value: "off", description: "Leave autoresearch mode" },
|
||||
{ label: "clear", value: "clear", description: "Delete autoresearch.jsonl and leave autoresearch mode" },
|
||||
];
|
||||
const normalized = argumentPrefix.trim().toLowerCase();
|
||||
const filtered = completions.filter(item => item.label.startsWith(normalized));
|
||||
return filtered.length > 0 ? filtered : null;
|
||||
},
|
||||
async handler(args, ctx): Promise<void> {
|
||||
const trimmed = args.trim();
|
||||
const runtime = getRuntime(ctx);
|
||||
const workDirError = validateWorkDir(ctx.cwd);
|
||||
if (workDirError) {
|
||||
ctx.ui.notify(workDirError, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed === "off") {
|
||||
setMode(ctx, false, runtime.goal, "off");
|
||||
dashboard.updateWidget(ctx, runtime);
|
||||
const experimentTools = new Set(EXPERIMENT_TOOL_NAMES);
|
||||
await api.setActiveTools(api.getActiveTools().filter(name => !experimentTools.has(name)));
|
||||
ctx.ui.notify("Autoresearch mode disabled", "info");
|
||||
return;
|
||||
}
|
||||
if (trimmed === "clear") {
|
||||
const workDir = resolveWorkDir(ctx.cwd);
|
||||
const jsonlPath = path.join(workDir, "autoresearch.jsonl");
|
||||
const localStatePath = path.join(workDir, ".autoresearch");
|
||||
if (fs.existsSync(jsonlPath)) {
|
||||
fs.rmSync(jsonlPath);
|
||||
}
|
||||
if (fs.existsSync(localStatePath)) {
|
||||
fs.rmSync(localStatePath, { force: true, recursive: true });
|
||||
}
|
||||
runtime.state = createExperimentState();
|
||||
runtime.state.maxExperiments = readMaxExperiments(ctx.cwd);
|
||||
runtime.goal = null;
|
||||
runtime.lastRunChecks = null;
|
||||
runtime.lastRunDuration = null;
|
||||
runtime.lastRunAsi = null;
|
||||
runtime.lastRunArtifactDir = null;
|
||||
runtime.lastRunNumber = null;
|
||||
runtime.lastRunSummary = null;
|
||||
setMode(ctx, false, null, "clear");
|
||||
dashboard.updateWidget(ctx, runtime);
|
||||
const experimentTools = new Set(EXPERIMENT_TOOL_NAMES);
|
||||
await api.setActiveTools(api.getActiveTools().filter(name => !experimentTools.has(name)));
|
||||
ctx.ui.notify("Autoresearch local state cleared", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const workDir = resolveWorkDir(ctx.cwd);
|
||||
const autoresearchMdPath = path.join(workDir, "autoresearch.md");
|
||||
const hasAutoresearchMd = fs.existsSync(autoresearchMdPath);
|
||||
const controlState = reconstructControlState(ctx.sessionManager.getBranch());
|
||||
const shouldResumeExistingNotes =
|
||||
hasAutoresearchMd &&
|
||||
(hasLocalAutoresearchState(workDir) || (controlState.lastMode !== "clear" && trimmed.length === 0));
|
||||
|
||||
if (shouldResumeExistingNotes) {
|
||||
const resumeContext = trimmed;
|
||||
const resumeGoal = runtime.goal ?? runtime.state.name ?? null;
|
||||
const branchResult = await ensureAutoresearchBranch(api, workDir, resumeGoal);
|
||||
if (!branchResult.ok) {
|
||||
ctx.ui.notify(branchResult.error, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setMode(ctx, true, resumeGoal, "on");
|
||||
dashboard.updateWidget(ctx, runtime);
|
||||
await api.setActiveTools([...new Set([...api.getActiveTools(), ...EXPERIMENT_TOOL_NAMES])]);
|
||||
api.sendUserMessage(
|
||||
renderPromptTemplate(commandResumeTemplate, {
|
||||
autoresearch_md_path: autoresearchMdPath,
|
||||
branch_status_line: branchResult.created
|
||||
? `Created and checked out dedicated git branch \`${branchResult.branchName}\` before resuming.`
|
||||
: `Using dedicated git branch \`${branchResult.branchName}\`.`,
|
||||
has_resume_context: resumeContext.length > 0,
|
||||
resume_context: resumeContext,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const setup = await promptForAutoresearchSetup(
|
||||
ctx,
|
||||
trimmed || runtime.goal || "what should autoresearch improve?",
|
||||
);
|
||||
if (!setup) return;
|
||||
|
||||
const branchResult = await ensureAutoresearchBranch(api, workDir, setup.intent);
|
||||
if (!branchResult.ok) {
|
||||
ctx.ui.notify(branchResult.error, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setMode(ctx, true, setup.intent, "on");
|
||||
runtime.state.name = setup.intent;
|
||||
runtime.state.metricName = setup.metricName;
|
||||
runtime.state.metricUnit = setup.metricUnit;
|
||||
runtime.state.bestDirection = setup.direction;
|
||||
runtime.state.secondaryMetrics = setup.secondaryMetrics.map(name => ({ name, unit: "" }));
|
||||
runtime.state.benchmarkCommand = setup.benchmarkCommand;
|
||||
runtime.state.scopePaths = [...setup.scopePaths];
|
||||
runtime.state.offLimits = [...setup.offLimits];
|
||||
runtime.state.constraints = [...setup.constraints];
|
||||
dashboard.updateWidget(ctx, runtime);
|
||||
await api.setActiveTools([...new Set([...api.getActiveTools(), ...EXPERIMENT_TOOL_NAMES])]);
|
||||
api.sendUserMessage(
|
||||
renderPromptTemplate(commandInitializeTemplate, {
|
||||
branch_status_line: branchResult.created
|
||||
? `Created and checked out dedicated git branch \`${branchResult.branchName}\`.`
|
||||
: `Using dedicated git branch \`${branchResult.branchName}\`.`,
|
||||
intent: setup.intent,
|
||||
benchmark_command: setup.benchmarkCommand,
|
||||
metric_name: setup.metricName,
|
||||
metric_unit: setup.metricUnit,
|
||||
direction: setup.direction,
|
||||
has_secondary_metrics: setup.secondaryMetrics.length > 0,
|
||||
secondary_metrics: setup.secondaryMetrics,
|
||||
secondary_metrics_block: formatBulletBlock(
|
||||
setup.secondaryMetrics,
|
||||
value => ` - \`${value}\``,
|
||||
" - `(none)`",
|
||||
),
|
||||
scope_paths: setup.scopePaths,
|
||||
scope_paths_block: formatBulletBlock(setup.scopePaths, value => ` - \`${value}\``),
|
||||
has_off_limits: setup.offLimits.length > 0,
|
||||
off_limits: setup.offLimits,
|
||||
off_limits_block: formatBulletBlock(setup.offLimits, value => ` - \`${value}\``, " - `(none)`"),
|
||||
has_constraints: setup.constraints.length > 0,
|
||||
constraints: setup.constraints,
|
||||
constraints_block: formatBulletBlock(setup.constraints, value => ` - ${value}`, " - `(none)`"),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
api.registerShortcut("ctrl+x", {
|
||||
description: "Toggle autoresearch dashboard",
|
||||
handler(ctx): void {
|
||||
const runtime = getRuntime(ctx);
|
||||
if (runtime.state.results.length === 0 && !runtime.runningExperiment) {
|
||||
ctx.ui.notify("No autoresearch results yet", "info");
|
||||
return;
|
||||
}
|
||||
runtime.dashboardExpanded = !runtime.dashboardExpanded;
|
||||
dashboard.updateWidget(ctx, runtime);
|
||||
},
|
||||
});
|
||||
|
||||
api.registerShortcut("ctrl+shift+x", {
|
||||
description: "Show autoresearch dashboard overlay",
|
||||
handler(ctx): Promise<void> {
|
||||
return dashboard.showOverlay(ctx, getRuntime(ctx));
|
||||
},
|
||||
});
|
||||
|
||||
api.on("session_start", (_event, ctx) => rehydrate(ctx));
|
||||
api.on("session_switch", (_event, ctx) => rehydrate(ctx));
|
||||
api.on("session_branch", (_event, ctx) => rehydrate(ctx));
|
||||
api.on("session_tree", (_event, ctx) => rehydrate(ctx));
|
||||
api.on("session_shutdown", (_event, ctx) => {
|
||||
dashboard.clear(ctx);
|
||||
runtimeStore.clear(getSessionKey(ctx));
|
||||
});
|
||||
|
||||
api.on("agent_end", async (_event, ctx) => {
|
||||
const runtime = getRuntime(ctx);
|
||||
runtime.runningExperiment = null;
|
||||
dashboard.updateWidget(ctx, runtime);
|
||||
dashboard.requestRender();
|
||||
if (!runtime.autoresearchMode) return;
|
||||
if (ctx.hasPendingMessages()) {
|
||||
runtime.autoResumeArmed = false;
|
||||
return;
|
||||
}
|
||||
const workDir = resolveWorkDir(ctx.cwd);
|
||||
const pendingRun =
|
||||
runtime.lastRunSummary ??
|
||||
(await readPendingRunSummary(workDir, collectLoggedRunNumbers(runtime.state.results)));
|
||||
runtime.lastRunSummary = pendingRun;
|
||||
runtime.lastRunChecks = summaryToChecks(pendingRun);
|
||||
runtime.lastRunDuration = pendingRun?.durationSeconds ?? runtime.lastRunDuration;
|
||||
runtime.lastRunAsi = pendingRun?.parsedAsi ?? runtime.lastRunAsi;
|
||||
const shouldResumePendingRun =
|
||||
pendingRun !== null && runtime.lastAutoResumePendingRunNumber !== pendingRun.runNumber;
|
||||
if (!shouldResumePendingRun && !runtime.autoResumeArmed) {
|
||||
return;
|
||||
}
|
||||
runtime.autoResumeArmed = false;
|
||||
runtime.lastAutoResumePendingRunNumber = pendingRun?.runNumber ?? null;
|
||||
const autoresearchMdPath = path.join(workDir, "autoresearch.md");
|
||||
const ideasPath = path.join(workDir, "autoresearch.ideas.md");
|
||||
api.sendMessage(
|
||||
{
|
||||
customType: "autoresearch-resume",
|
||||
content: renderPromptTemplate(resumeMessageTemplate, {
|
||||
autoresearch_md_path: autoresearchMdPath,
|
||||
has_ideas: fs.existsSync(ideasPath),
|
||||
has_pending_run: Boolean(pendingRun),
|
||||
}),
|
||||
display: false,
|
||||
attribution: "agent",
|
||||
},
|
||||
{ deliverAs: "nextTurn", triggerTurn: true },
|
||||
);
|
||||
});
|
||||
|
||||
api.on("before_agent_start", async (event, ctx) => {
|
||||
const runtime = getRuntime(ctx);
|
||||
if (!runtime.autoresearchMode) return;
|
||||
const workDir = resolveWorkDir(ctx.cwd);
|
||||
const autoresearchMdPath = path.join(workDir, "autoresearch.md");
|
||||
const checksPath = path.join(workDir, "autoresearch.checks.sh");
|
||||
const ideasPath = path.join(workDir, "autoresearch.ideas.md");
|
||||
const programPath = path.join(workDir, "autoresearch.program.md");
|
||||
const pendingRun =
|
||||
runtime.lastRunSummary ??
|
||||
(await readPendingRunSummary(workDir, collectLoggedRunNumbers(runtime.state.results)));
|
||||
runtime.lastRunSummary = pendingRun;
|
||||
runtime.lastRunChecks = summaryToChecks(pendingRun);
|
||||
runtime.lastRunDuration = pendingRun?.durationSeconds ?? runtime.lastRunDuration;
|
||||
runtime.lastRunAsi = pendingRun?.parsedAsi ?? runtime.lastRunAsi;
|
||||
const currentSegmentResults = currentResults(runtime.state.results, runtime.state.currentSegment);
|
||||
const baselineMetric = findBaselineMetric(runtime.state.results, runtime.state.currentSegment);
|
||||
const bestResult = findBestResult(runtime);
|
||||
const goal = runtime.goal ?? runtime.state.name ?? "";
|
||||
const recentResults = currentSegmentResults.slice(-3).map(result => {
|
||||
const asiSummary = summarizeExperimentAsi(result);
|
||||
return {
|
||||
asi_summary: asiSummary,
|
||||
description: result.description,
|
||||
has_asi_summary: Boolean(asiSummary),
|
||||
metric_display: formatNum(result.metric, runtime.state.metricUnit),
|
||||
run_number: result.runNumber ?? runtime.state.results.indexOf(result) + 1,
|
||||
status: result.status,
|
||||
};
|
||||
});
|
||||
return {
|
||||
systemPrompt: renderPromptTemplate(promptTemplate, {
|
||||
base_system_prompt: event.systemPrompt,
|
||||
has_goal: goal.trim().length > 0,
|
||||
goal,
|
||||
working_dir: workDir,
|
||||
default_metric_name: runtime.state.metricName,
|
||||
metric_name: runtime.state.metricName,
|
||||
has_autoresearch_md: fs.existsSync(autoresearchMdPath),
|
||||
autoresearch_md_path: autoresearchMdPath,
|
||||
has_checks: fs.existsSync(checksPath),
|
||||
checks_path: checksPath,
|
||||
has_ideas: fs.existsSync(ideasPath),
|
||||
ideas_path: ideasPath,
|
||||
has_program: fs.existsSync(programPath),
|
||||
program_path: programPath,
|
||||
current_segment: runtime.state.currentSegment + 1,
|
||||
current_segment_run_count: currentSegmentResults.length,
|
||||
has_baseline_metric: baselineMetric !== null,
|
||||
baseline_metric_display: formatNum(baselineMetric, runtime.state.metricUnit),
|
||||
has_best_result: Boolean(bestResult),
|
||||
best_metric_display: bestResult
|
||||
? formatNum(bestResult.metric, runtime.state.metricUnit)
|
||||
: formatNum(baselineMetric, runtime.state.metricUnit),
|
||||
best_run_number: bestResult
|
||||
? (bestResult.runNumber ?? runtime.state.results.indexOf(bestResult) + 1)
|
||||
: null,
|
||||
has_recent_results: recentResults.length > 0,
|
||||
recent_results: recentResults,
|
||||
has_pending_run: Boolean(pendingRun),
|
||||
pending_run_number: pendingRun?.runNumber,
|
||||
pending_run_command: pendingRun?.command,
|
||||
pending_run_directory: pendingRun?.runDirectory,
|
||||
pending_run_passed: pendingRun?.passed ?? false,
|
||||
has_pending_run_metric: pendingRun?.parsedPrimary !== null && pendingRun?.parsedPrimary !== undefined,
|
||||
pending_run_metric_display:
|
||||
pendingRun?.parsedPrimary !== null && pendingRun?.parsedPrimary !== undefined
|
||||
? formatNum(pendingRun.parsedPrimary, runtime.state.metricUnit)
|
||||
: null,
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
async function promptForAutoresearchSetup(
|
||||
ctx: ExtensionContext,
|
||||
defaultIntent: string,
|
||||
): Promise<AutoresearchSetupInput | undefined> {
|
||||
const intentInput = await ctx.ui.input("Autoresearch Intent", defaultIntent);
|
||||
if (intentInput === undefined) return undefined;
|
||||
const intent = intentInput.trim();
|
||||
if (intent.length === 0) {
|
||||
ctx.ui.notify("Autoresearch intent is required", "info");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const benchmarkCommandInput = await ctx.ui.input("Benchmark Command", "bash autoresearch.sh");
|
||||
if (benchmarkCommandInput === undefined) return undefined;
|
||||
const benchmarkCommand = benchmarkCommandInput.trim();
|
||||
if (benchmarkCommand.length === 0) {
|
||||
ctx.ui.notify("Benchmark command is required", "info");
|
||||
return undefined;
|
||||
}
|
||||
if (!isAutoresearchShCommand(benchmarkCommand)) {
|
||||
ctx.ui.notify("Benchmark command must invoke `autoresearch.sh` directly", "info");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const metricNameInput = await ctx.ui.input("Primary Metric Name", "runtime_ms");
|
||||
if (metricNameInput === undefined) return undefined;
|
||||
const metricName = metricNameInput.trim();
|
||||
if (metricName.length === 0) {
|
||||
ctx.ui.notify("Primary metric name is required", "info");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const metricUnitInput = await ctx.ui.input("Metric Unit", "ms");
|
||||
if (metricUnitInput === undefined) return undefined;
|
||||
const metricUnit = metricUnitInput.trim();
|
||||
|
||||
const directionInput = await ctx.ui.input("Metric Direction", "lower");
|
||||
if (directionInput === undefined) return undefined;
|
||||
const normalizedDirection = directionInput.trim().toLowerCase();
|
||||
if (normalizedDirection !== "lower" && normalizedDirection !== "higher") {
|
||||
ctx.ui.notify("Metric direction must be `lower` or `higher`", "info");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const secondaryMetricsInput = await ctx.ui.input("Tradeoff Metrics", "");
|
||||
if (secondaryMetricsInput === undefined) return undefined;
|
||||
|
||||
const scopePathsInput = await ctx.ui.input("Files in Scope", "packages/coding-agent/src/autoresearch");
|
||||
if (scopePathsInput === undefined) return undefined;
|
||||
const scopePaths = splitSetupList(scopePathsInput);
|
||||
if (scopePaths.length === 0) {
|
||||
ctx.ui.notify("Files in Scope must include at least one path", "info");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const offLimitsInput = await ctx.ui.input("Off Limits", "");
|
||||
if (offLimitsInput === undefined) return undefined;
|
||||
const constraintsInput = await ctx.ui.input("Constraints", "");
|
||||
if (constraintsInput === undefined) return undefined;
|
||||
|
||||
return {
|
||||
intent,
|
||||
benchmarkCommand,
|
||||
metricName,
|
||||
metricUnit,
|
||||
direction: normalizedDirection,
|
||||
secondaryMetrics: splitSetupList(secondaryMetricsInput),
|
||||
scopePaths,
|
||||
offLimits: splitSetupList(offLimitsInput),
|
||||
constraints: splitSetupList(constraintsInput),
|
||||
};
|
||||
}
|
||||
|
||||
function splitSetupList(value: string): string[] {
|
||||
return value
|
||||
.split(/\r?\n|,/)
|
||||
.map(entry => entry.trim())
|
||||
.filter((entry, index, values) => entry.length > 0 && values.indexOf(entry) === index);
|
||||
}
|
||||
|
||||
function formatBulletBlock(values: string[], renderValue: (value: string) => string, emptyValue = ""): string {
|
||||
if (values.length === 0) {
|
||||
return emptyValue;
|
||||
}
|
||||
return values.map(renderValue).join("\n");
|
||||
}
|
||||
|
||||
function hasLocalAutoresearchState(workDir: string): boolean {
|
||||
return fs.existsSync(path.join(workDir, "autoresearch.jsonl")) || fs.existsSync(path.join(workDir, ".autoresearch"));
|
||||
}
|
||||
|
||||
function summarizeExperimentAsi(result: ExperimentResult): string | null {
|
||||
const hypothesis = typeof result.asi?.hypothesis === "string" ? result.asi.hypothesis.trim() : "";
|
||||
const rollbackReason = typeof result.asi?.rollback_reason === "string" ? result.asi.rollback_reason.trim() : "";
|
||||
const nextActionHint = typeof result.asi?.next_action_hint === "string" ? result.asi.next_action_hint.trim() : "";
|
||||
const summary = [hypothesis, rollbackReason, nextActionHint].filter(part => part.length > 0).join(" | ");
|
||||
return summary.length > 0 ? summary.slice(0, 220) : null;
|
||||
}
|
||||
|
||||
function getGuardedToolPaths(toolName: string, input: Record<string, unknown>): string[] | null {
|
||||
if (toolName === "write") {
|
||||
return typeof input.path === "string" ? [input.path] : null;
|
||||
}
|
||||
if (toolName === "ast_edit") {
|
||||
return typeof input.path === "string" ? [input.path] : null;
|
||||
}
|
||||
if (toolName !== "edit") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const paths: string[] = [];
|
||||
if (typeof input.path === "string") {
|
||||
paths.push(input.path);
|
||||
}
|
||||
if (typeof input.rename === "string") {
|
||||
paths.push(input.rename);
|
||||
}
|
||||
if (typeof input.move === "string") {
|
||||
paths.push(input.move);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function resolveAutoresearchRelativePath(
|
||||
workDir: string,
|
||||
rawPath: string,
|
||||
): { ok: false; reason: string } | { ok: true; relativePath: string } {
|
||||
if (looksLikeInternalUrl(rawPath)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Autoresearch cannot validate internal URL paths during scoped editing: ${rawPath}`,
|
||||
};
|
||||
}
|
||||
const resolvedPath = path.isAbsolute(rawPath) ? path.resolve(rawPath) : path.resolve(workDir, rawPath);
|
||||
const canonicalWorkDir = canonicalizeExistingPath(workDir);
|
||||
const canonicalTargetPath = canonicalizeTargetPath(resolvedPath);
|
||||
const relativePath = path.relative(canonicalWorkDir, canonicalTargetPath);
|
||||
if (relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Autoresearch blocked edits outside the working tree: ${rawPath}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
relativePath: relativePath.length === 0 ? "." : normalizeAutoresearchPath(relativePath),
|
||||
};
|
||||
}
|
||||
|
||||
function validateEditableAutoresearchPath(relativePath: string, runtime: AutoresearchRuntime): string | null {
|
||||
if (isAutoresearchLocalStatePath(relativePath)) {
|
||||
return "autoresearch local state files are managed by the experiment tools and cannot be edited directly";
|
||||
}
|
||||
if (runtime.state.offLimits.some(spec => pathMatchesContractPath(relativePath, spec))) {
|
||||
return "this path is listed under Off Limits in autoresearch.md";
|
||||
}
|
||||
if (isAutoresearchCommittableFile(relativePath)) {
|
||||
return null;
|
||||
}
|
||||
if (runtime.state.scopePaths.length === 0) {
|
||||
return "Files in Scope is not initialized yet; only autoresearch control files may be edited before init_experiment runs";
|
||||
}
|
||||
if (!runtime.state.scopePaths.some(spec => pathMatchesContractPath(relativePath, spec))) {
|
||||
return "this path is outside Files in Scope in autoresearch.md";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findBestResult(runtime: AutoresearchRuntime): ExperimentResult | null {
|
||||
let best: ExperimentResult | null = null;
|
||||
for (const result of runtime.state.results) {
|
||||
if (result.segment !== runtime.state.currentSegment || result.status !== "keep") continue;
|
||||
if (!best) {
|
||||
best = result;
|
||||
continue;
|
||||
}
|
||||
if (runtime.state.bestDirection === "lower" ? result.metric < best.metric : result.metric > best.metric) {
|
||||
best = result;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function collectLoggedRunNumbers(results: ExperimentResult[]): Set<number> {
|
||||
const runNumbers = new Set<number>();
|
||||
for (const result of results) {
|
||||
if (result.runNumber !== null) {
|
||||
runNumbers.add(result.runNumber);
|
||||
}
|
||||
}
|
||||
return runNumbers;
|
||||
}
|
||||
|
||||
function summaryToChecks(summary: PendingRunSummary | null): ChecksResult | null {
|
||||
if (!summary || summary.checksPass === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
pass: summary.checksPass,
|
||||
output: "",
|
||||
duration: summary.checksDurationSeconds ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function looksLikeInternalUrl(value: string): boolean {
|
||||
return /^[a-z][a-z0-9+.-]*:\/\//i.test(value);
|
||||
}
|
||||
|
||||
function canonicalizeExistingPath(targetPath: string): string {
|
||||
try {
|
||||
return fs.realpathSync.native(targetPath);
|
||||
} catch {
|
||||
return path.resolve(targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalizeTargetPath(targetPath: string): string {
|
||||
const pendingSegments: string[] = [];
|
||||
let currentPath = path.resolve(targetPath);
|
||||
while (!fs.existsSync(currentPath)) {
|
||||
const parentPath = path.dirname(currentPath);
|
||||
if (parentPath === currentPath) {
|
||||
return currentPath;
|
||||
}
|
||||
pendingSegments.unshift(path.basename(currentPath));
|
||||
currentPath = parentPath;
|
||||
}
|
||||
return path.resolve(canonicalizeExistingPath(currentPath), ...pendingSegments);
|
||||
}
|
||||
|
||||
function validateAutoresearchBashCommand(command: string): string | null {
|
||||
const trimmed = command.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const mutationPatterns = [
|
||||
/(^|[;&|()]\s*)(?:bash|sh)\b/,
|
||||
/(^|[;&|()]\s*)(?:python|python3|node|perl|ruby|php)\b/,
|
||||
/(^|[;&|()]\s*)(?:mv|cp|rm|mkdir|touch|chmod|chown|ln|install|patch)\b/,
|
||||
/(^|[;&|()]\s*)sed\s+-i\b/,
|
||||
/(^|[;&|()]\s*)git\s+(?:add|apply|checkout|clean|commit|merge|rebase|reset|restore|revert|stash|switch|worktree)\b/,
|
||||
/(^|[^<])>>?/,
|
||||
/\|\s*tee\b/,
|
||||
/<<<?/,
|
||||
];
|
||||
if (mutationPatterns.some(pattern => pattern.test(trimmed))) {
|
||||
return (
|
||||
"Autoresearch only allows read-only shell inspection. " +
|
||||
"Use write/edit/ast_edit for file changes and run_experiment for benchmark execution."
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
{{{base_system_prompt}}}
|
||||
|
||||
## Autoresearch Mode
|
||||
|
||||
Autoresearch mode is active.
|
||||
|
||||
{{#if has_goal}}
|
||||
Primary goal:
|
||||
{{goal}}
|
||||
{{else}}
|
||||
Primary goal is documented in `autoresearch.md` for this session.
|
||||
{{/if}}
|
||||
|
||||
Working directory:
|
||||
`{{working_dir}}`
|
||||
|
||||
You are running an autonomous experiment loop. Keep iterating until the user interrupts you or the configured maximum iteration count is reached.
|
||||
{{#if has_program}}
|
||||
|
||||
### Local Playbook
|
||||
|
||||
`autoresearch.program.md` exists at `{{program_path}}`.
|
||||
|
||||
Use it as a repo-local strategy overlay for this session. `autoresearch.md` remains the source of truth for benchmark, scope, and constraints.
|
||||
{{/if}}
|
||||
{{#if has_recent_results}}
|
||||
|
||||
### Current Segment Snapshot
|
||||
|
||||
- segment: `{{current_segment}}`
|
||||
- runs in current segment: `{{current_segment_run_count}}`
|
||||
{{#if has_baseline_metric}}
|
||||
- baseline `{{metric_name}}`: `{{baseline_metric_display}}`
|
||||
{{/if}}
|
||||
{{#if has_best_result}}
|
||||
- best kept `{{metric_name}}`: `{{best_metric_display}}`{{#if best_run_number}} from run `#{{best_run_number}}`{{/if}}
|
||||
{{/if}}
|
||||
|
||||
Recent runs:
|
||||
{{#each recent_results}}
|
||||
- run `#{{run_number}}`: `{{status}}` `{{metric_display}}` — {{description}}
|
||||
{{#if has_asi_summary}}
|
||||
ASI: {{asi_summary}}
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
{{#if has_pending_run}}
|
||||
|
||||
### Pending Run
|
||||
|
||||
An unlogged run artifact exists at `{{pending_run_directory}}`.
|
||||
|
||||
- run: `#{{pending_run_number}}`
|
||||
- command: `{{pending_run_command}}`
|
||||
{{#if has_pending_run_metric}}
|
||||
- parsed `{{metric_name}}`: `{{pending_run_metric_display}}`
|
||||
{{/if}}
|
||||
- result status: {{#if pending_run_passed}}passed{{else}}failed{{/if}}
|
||||
- finish the `log_experiment` step before starting another benchmark
|
||||
{{/if}}
|
||||
|
||||
### Available tools
|
||||
|
||||
- `init_experiment` — initialize or reset the experiment session for the current optimization target.
|
||||
- `run_experiment` — run a benchmark or experiment command with timing, output capture, structured metric parsing, and optional backpressure checks.
|
||||
- `log_experiment` — record the result, update the dashboard, persist JSONL history, auto-commit kept experiments, and auto-revert discarded or failed experiments.
|
||||
|
||||
### Operating protocol
|
||||
|
||||
1. Understand the target before touching code.
|
||||
- Read the relevant source files.
|
||||
- Identify the true bottleneck or quality constraint.
|
||||
- Check existing scripts, benchmark harnesses, and config files.
|
||||
- Verify prerequisites, one-time setup, and benchmark inputs before the first run of a segment.
|
||||
2. Keep your notes in `autoresearch.md`.
|
||||
- Record the goal, the benchmark command, the primary metric, important secondary metrics, the files in scope, hard constraints, preflight requirements, and the benchmark comparability invariant.
|
||||
- Update the notes whenever the strategy changes.
|
||||
- Keep durable conclusions in `autoresearch.md`.
|
||||
- Use `autoresearch.ideas.md` for deferred experiment ideas that are promising but not active yet.
|
||||
3. Use `autoresearch.sh` as the canonical benchmark entrypoint.
|
||||
- If it does not exist yet, create it.
|
||||
- Make it print structured metric lines in the form `METRIC name=value`.
|
||||
- Use the same workload every run unless you intentionally re-initialize with a new segment.
|
||||
- Keep the measurement harness, evaluator, and fixed benchmark inputs stable unless you intentionally start a new segment and document the change.
|
||||
4. Initialize the loop with `init_experiment` before the first logged run of a segment.
|
||||
5. Run a baseline first.
|
||||
- Establish the baseline metric before attempting optimizations.
|
||||
- Track secondary metrics only when they matter to correctness, quality, or obvious regressions.
|
||||
6. Iterate.
|
||||
- Make one coherent experiment at a time.
|
||||
- Run `run_experiment`.
|
||||
- Interpret the result honestly.
|
||||
- Call `log_experiment` after every run.
|
||||
7. Keep the primary metric as the decision maker.
|
||||
- `keep` when the primary metric improves.
|
||||
- `discard` when it regresses or stays flat.
|
||||
- `crash` when the run fails.
|
||||
- `checks_failed` when the benchmark passes but backpressure checks fail.
|
||||
8. Record ASI on every `log_experiment` call.
|
||||
- At minimum include `hypothesis`.
|
||||
- On `discard`, `crash`, or `checks_failed`, also include `rollback_reason` and `next_action_hint`.
|
||||
- Use ASI to capture what you learned, not just what you changed.
|
||||
9. Prefer simpler wins.
|
||||
- Remove dead ends.
|
||||
- Keep equal or near-equal results when they materially simplify the implementation.
|
||||
- Do not keep ugly complexity for tiny gains unless the payoff is clearly worth it.
|
||||
- Do not thrash between unrelated ideas without writing down the conclusion.
|
||||
10. When confidence is low, confirm.
|
||||
- The dashboard confidence score compares the best observed improvement against the observed noise floor.
|
||||
- Below `1.0x` usually means the improvement is within noise.
|
||||
- Re-run promising changes when needed before keeping them.
|
||||
|
||||
### Benchmark harness guidance
|
||||
|
||||
Your benchmark script SHOULD:
|
||||
|
||||
- live at `autoresearch.sh`
|
||||
- run from `{{working_dir}}`
|
||||
- fail with a non-zero exit status on invalid runs
|
||||
- print the primary metric as `METRIC {{default_metric_name}}=<number>` or another explicit metric name chosen during initialization
|
||||
- print secondary metrics as additional `METRIC name=value` lines
|
||||
- avoid extra randomness when possible
|
||||
- use repeated samples and median-style summaries for fast benchmarks
|
||||
- preserve the comparability invariant for the current segment
|
||||
- keep the ground-truth evaluator and fixed benchmark inputs unchanged unless the segment is explicitly re-initialized
|
||||
|
||||
### Notes file template
|
||||
|
||||
Keep `autoresearch.md` concise and current.
|
||||
|
||||
Suggested structure:
|
||||
|
||||
```md
|
||||
# Autoresearch
|
||||
|
||||
## Goal
|
||||
{{#if has_goal}}
|
||||
- {{goal}}
|
||||
{{else}}
|
||||
- document the active target here before the first benchmark
|
||||
{{/if}}
|
||||
|
||||
## Benchmark
|
||||
- command:
|
||||
- primary metric:
|
||||
- metric unit:
|
||||
- direction:
|
||||
- secondary metrics: memory_mb, rss_mb
|
||||
|
||||
## Files in Scope
|
||||
- path:
|
||||
|
||||
## Off Limits
|
||||
- path:
|
||||
|
||||
## Constraints
|
||||
- rule:
|
||||
|
||||
## Baseline
|
||||
- metric:
|
||||
- notes:
|
||||
|
||||
## Current best
|
||||
- metric:
|
||||
- why it won:
|
||||
|
||||
## What's Been Tried
|
||||
- experiment:
|
||||
- lesson:
|
||||
```
|
||||
|
||||
### Guardrails
|
||||
|
||||
- Do not game the benchmark.
|
||||
- Do not overfit to synthetic inputs if the real workload is broader.
|
||||
- Preserve correctness.
|
||||
- Only modify files that are explicitly in scope for the current session.
|
||||
- Do not use the general shell tool for file mutations during autoresearch. Use `write`, `edit`, or `ast_edit` for scoped code changes and `run_experiment` for benchmark execution.
|
||||
- If you create `autoresearch.checks.sh`, treat it as a hard gate for `keep`.
|
||||
- If the user sends another message while a run is in progress, finish the current run and logging cycle first, then address the new input in the next iteration.
|
||||
|
||||
{{#if has_autoresearch_md}}
|
||||
### Resume mode
|
||||
|
||||
`autoresearch.md` already exists at `{{autoresearch_md_path}}`.
|
||||
|
||||
Resume from the existing notes:
|
||||
|
||||
- read `autoresearch.md`
|
||||
- inspect recent git history
|
||||
- inspect `autoresearch.jsonl`
|
||||
- continue from the most promising unfinished direction on the current protected branch
|
||||
|
||||
{{else}}
|
||||
### Initial setup
|
||||
|
||||
`autoresearch.md` does not exist yet.
|
||||
|
||||
Create the experiment workspace before the first benchmark:
|
||||
|
||||
- write `autoresearch.md`
|
||||
- write `autoresearch.sh`
|
||||
- optionally write `autoresearch.checks.sh`
|
||||
- run `init_experiment`
|
||||
- run and log the baseline
|
||||
|
||||
{{/if}}
|
||||
{{#if has_checks}}
|
||||
### Backpressure checks
|
||||
|
||||
`autoresearch.checks.sh` exists at `{{checks_path}}` and runs automatically after passing benchmark runs.
|
||||
|
||||
Treat failing checks as a failed experiment:
|
||||
|
||||
- do not `keep` a run when checks fail
|
||||
- log it as `checks_failed`
|
||||
- diagnose the regression before continuing
|
||||
|
||||
{{/if}}
|
||||
{{#if has_ideas}}
|
||||
### Ideas backlog
|
||||
|
||||
`autoresearch.ideas.md` exists at `{{ideas_path}}`.
|
||||
|
||||
Use it to keep promising but deferred experiments. `autoresearch.md` should hold durable conclusions; `autoresearch.ideas.md` is the scratch backlog. Prune stale ideas when they are disproven or superseded.
|
||||
|
||||
{{/if}}
|
||||
@@ -0,0 +1,16 @@
|
||||
Continue the autoresearch loop now.
|
||||
|
||||
@{{autoresearch_md_path}}
|
||||
|
||||
- Read `autoresearch.md` and `autoresearch.jsonl`.
|
||||
- Treat `autoresearch.md` as the source of truth for the current direction, scope, and constraints.
|
||||
- Inspect recent git history for context.
|
||||
{{#if has_pending_run}}
|
||||
- Inspect the latest unlogged `run.json` under `.autoresearch/runs/` and finish the pending `log_experiment` step before starting a new benchmark.
|
||||
{{/if}}
|
||||
- Continue from the most promising unfinished direction.
|
||||
{{#if has_ideas}}
|
||||
- Review `autoresearch.ideas.md` for deferred next steps and prune stale items.
|
||||
{{/if}}
|
||||
- Keep iterating until interrupted or until the configured iteration cap is reached.
|
||||
- Preserve correctness and do not game the benchmark.
|
||||
@@ -0,0 +1,386 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { SessionEntry } from "../session/session-manager";
|
||||
import { normalizeAutoresearchList, normalizeContractPathSpec } from "./contract";
|
||||
import { inferMetricUnitFromName, isBetter } from "./helpers";
|
||||
import type {
|
||||
AutoresearchControlEntryData,
|
||||
AutoresearchJsonConfigEntry,
|
||||
AutoresearchJsonRunEntry,
|
||||
AutoresearchRuntime,
|
||||
ExperimentResult,
|
||||
ExperimentState,
|
||||
MetricDef,
|
||||
MetricDirection,
|
||||
NumericMetricMap,
|
||||
ReconstructedControlState,
|
||||
ReconstructedExperimentData,
|
||||
RuntimeStore,
|
||||
} from "./types";
|
||||
|
||||
export function createExperimentState(): ExperimentState {
|
||||
return {
|
||||
results: [],
|
||||
bestMetric: null,
|
||||
bestDirection: "lower",
|
||||
metricName: "metric",
|
||||
metricUnit: "",
|
||||
secondaryMetrics: [],
|
||||
name: null,
|
||||
currentSegment: 0,
|
||||
maxExperiments: null,
|
||||
confidence: null,
|
||||
benchmarkCommand: null,
|
||||
scopePaths: [],
|
||||
offLimits: [],
|
||||
constraints: [],
|
||||
segmentFingerprint: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSessionRuntime(): AutoresearchRuntime {
|
||||
return {
|
||||
autoresearchMode: false,
|
||||
autoResumeArmed: false,
|
||||
dashboardExpanded: false,
|
||||
lastAutoResumePendingRunNumber: null,
|
||||
lastRunChecks: null,
|
||||
lastRunDuration: null,
|
||||
lastRunAsi: null,
|
||||
lastRunArtifactDir: null,
|
||||
lastRunNumber: null,
|
||||
lastRunSummary: null,
|
||||
runningExperiment: null,
|
||||
state: createExperimentState(),
|
||||
goal: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneExperimentState(state: ExperimentState): ExperimentState {
|
||||
return {
|
||||
...state,
|
||||
results: state.results.map(result => ({
|
||||
...result,
|
||||
metrics: { ...result.metrics },
|
||||
asi: result.asi ? structuredClone(result.asi) : undefined,
|
||||
})),
|
||||
secondaryMetrics: state.secondaryMetrics.map(metric => ({ ...metric })),
|
||||
scopePaths: [...state.scopePaths],
|
||||
offLimits: [...state.offLimits],
|
||||
constraints: [...state.constraints],
|
||||
};
|
||||
}
|
||||
|
||||
export function currentResults(results: ExperimentResult[], segment: number): ExperimentResult[] {
|
||||
return results.filter(result => result.segment === segment);
|
||||
}
|
||||
|
||||
export function findBaselineResult(results: ExperimentResult[], segment: number): ExperimentResult | null {
|
||||
return currentResults(results, segment).find(result => result.status === "keep") ?? null;
|
||||
}
|
||||
|
||||
export function findBaselineMetric(results: ExperimentResult[], segment: number): number | null {
|
||||
const baseline = findBaselineResult(results, segment);
|
||||
return baseline ? baseline.metric : null;
|
||||
}
|
||||
|
||||
export function findBestKeptMetric(
|
||||
results: ExperimentResult[],
|
||||
segment: number,
|
||||
direction: MetricDirection,
|
||||
): number | null {
|
||||
let best: number | null = null;
|
||||
for (const result of currentResults(results, segment)) {
|
||||
if (result.status !== "keep") continue;
|
||||
if (best === null || isBetter(result.metric, best, direction)) {
|
||||
best = result.metric;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function findBaselineRunNumber(results: ExperimentResult[], segment: number): number | null {
|
||||
const baseline = findBaselineResult(results, segment);
|
||||
if (!baseline) return null;
|
||||
if (baseline.runNumber !== null) return baseline.runNumber;
|
||||
const index = results.indexOf(baseline);
|
||||
return index >= 0 ? index + 1 : null;
|
||||
}
|
||||
|
||||
export function findBaselineSecondary(
|
||||
results: ExperimentResult[],
|
||||
segment: number,
|
||||
knownMetrics: MetricDef[],
|
||||
): NumericMetricMap {
|
||||
const baseline = findBaselineResult(results, segment);
|
||||
const values: NumericMetricMap = baseline ? { ...baseline.metrics } : {};
|
||||
for (const metric of knownMetrics) {
|
||||
if (values[metric.name] !== undefined) continue;
|
||||
for (const result of currentResults(results, segment)) {
|
||||
const value = result.metrics[metric.name];
|
||||
if (value !== undefined) {
|
||||
values[metric.name] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function sortedMedian(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
const midpoint = Math.floor(sorted.length / 2);
|
||||
if (sorted.length % 2 === 0) {
|
||||
return (sorted[midpoint - 1] + sorted[midpoint]) / 2;
|
||||
}
|
||||
return sorted[midpoint];
|
||||
}
|
||||
|
||||
export function computeConfidence(
|
||||
results: ExperimentResult[],
|
||||
segment: number,
|
||||
direction: MetricDirection,
|
||||
): number | null {
|
||||
const current = currentResults(results, segment).filter(result => result.metric > 0);
|
||||
if (current.length < 3) return null;
|
||||
|
||||
const values = current.map(result => result.metric);
|
||||
const median = sortedMedian(values);
|
||||
const mad = sortedMedian(values.map(value => Math.abs(value - median)));
|
||||
if (mad === 0) return null;
|
||||
|
||||
const baseline = findBaselineMetric(results, segment);
|
||||
if (baseline === null) return null;
|
||||
|
||||
let bestKept: number | null = null;
|
||||
for (const result of current) {
|
||||
if (result.status !== "keep" || result.metric <= 0) continue;
|
||||
if (bestKept === null || isBetter(result.metric, bestKept, direction)) {
|
||||
bestKept = result.metric;
|
||||
}
|
||||
}
|
||||
if (bestKept === null || bestKept === baseline) return null;
|
||||
|
||||
return Math.abs(bestKept - baseline) / mad;
|
||||
}
|
||||
|
||||
export function reconstructStateFromJsonl(workDir: string): ReconstructedExperimentData {
|
||||
const state = createExperimentState();
|
||||
const jsonlPath = path.join(workDir, "autoresearch.jsonl");
|
||||
if (!fs.existsSync(jsonlPath)) {
|
||||
return { hasLog: false, state };
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(jsonlPath, "utf8");
|
||||
const lines = content
|
||||
.split("\n")
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
|
||||
let segment = 0;
|
||||
let sawConfig = false;
|
||||
for (const line of lines) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line) as unknown;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const configEntry = parseConfigEntry(parsed);
|
||||
if (configEntry) {
|
||||
if (sawConfig || state.results.length > 0) {
|
||||
segment += 1;
|
||||
}
|
||||
sawConfig = true;
|
||||
state.currentSegment = segment;
|
||||
if (configEntry.name) state.name = configEntry.name;
|
||||
if (configEntry.metricName) state.metricName = configEntry.metricName;
|
||||
if (configEntry.metricUnit !== undefined) state.metricUnit = configEntry.metricUnit;
|
||||
if (configEntry.bestDirection) state.bestDirection = configEntry.bestDirection;
|
||||
if (configEntry.benchmarkCommand !== undefined) state.benchmarkCommand = configEntry.benchmarkCommand;
|
||||
state.scopePaths = cloneStringArray(configEntry.scopePaths);
|
||||
state.offLimits = cloneStringArray(configEntry.offLimits);
|
||||
state.constraints = cloneStringArray(configEntry.constraints);
|
||||
state.segmentFingerprint =
|
||||
typeof configEntry.segmentFingerprint === "string" ? configEntry.segmentFingerprint : null;
|
||||
state.secondaryMetrics = hydrateMetricDefs(configEntry.secondaryMetrics);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isRunEntry(parsed)) continue;
|
||||
const result: ExperimentResult = {
|
||||
runNumber: typeof parsed.run === "number" && Number.isFinite(parsed.run) ? parsed.run : null,
|
||||
commit: typeof parsed.commit === "string" ? parsed.commit : "",
|
||||
metric: typeof parsed.metric === "number" && Number.isFinite(parsed.metric) ? parsed.metric : 0,
|
||||
metrics: cloneNumericMetrics(parsed.metrics),
|
||||
status: isExperimentStatus(parsed.status) ? parsed.status : "keep",
|
||||
description: typeof parsed.description === "string" ? parsed.description : "",
|
||||
timestamp: typeof parsed.timestamp === "number" && Number.isFinite(parsed.timestamp) ? parsed.timestamp : 0,
|
||||
segment,
|
||||
confidence:
|
||||
typeof parsed.confidence === "number" && Number.isFinite(parsed.confidence) ? parsed.confidence : null,
|
||||
asi: cloneAsi(parsed.asi),
|
||||
};
|
||||
state.results.push(result);
|
||||
if (segment !== state.currentSegment) continue;
|
||||
registerSecondaryMetrics(state.secondaryMetrics, result.metrics);
|
||||
}
|
||||
|
||||
state.bestMetric = findBaselineMetric(state.results, state.currentSegment);
|
||||
state.confidence = computeConfidence(state.results, state.currentSegment, state.bestDirection);
|
||||
return { hasLog: true, state };
|
||||
}
|
||||
|
||||
export function reconstructControlState(entries: SessionEntry[]): ReconstructedControlState {
|
||||
let autoresearchMode = false;
|
||||
let goal: string | null = null;
|
||||
let lastMode: ReconstructedControlState["lastMode"] = null;
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== "custom" || entry.customType !== "autoresearch-control") continue;
|
||||
const data = parseControlEntry(entry.data);
|
||||
if (!data) continue;
|
||||
lastMode = data.mode;
|
||||
autoresearchMode = data.mode === "on";
|
||||
goal = data.goal ?? goal;
|
||||
if (data.mode === "clear") {
|
||||
goal = null;
|
||||
}
|
||||
}
|
||||
return { autoresearchMode, goal, lastMode };
|
||||
}
|
||||
|
||||
export function createRuntimeStore(): RuntimeStore {
|
||||
const runtimes = new Map<string, AutoresearchRuntime>();
|
||||
return {
|
||||
clear(sessionKey: string): void {
|
||||
runtimes.delete(sessionKey);
|
||||
},
|
||||
ensure(sessionKey: string): AutoresearchRuntime {
|
||||
const existing = runtimes.get(sessionKey);
|
||||
if (existing) return existing;
|
||||
const runtime = createSessionRuntime();
|
||||
runtimes.set(sessionKey, runtime);
|
||||
return runtime;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function registerSecondaryMetrics(metrics: MetricDef[], values: NumericMetricMap): void {
|
||||
for (const name of Object.keys(values)) {
|
||||
if (metrics.some(metric => metric.name === name)) continue;
|
||||
metrics.push({
|
||||
name,
|
||||
unit: inferMetricUnitFromName(name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isConfigEntry(value: unknown): value is AutoresearchJsonConfigEntry {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const candidate = value as { type?: unknown };
|
||||
return candidate.type === "config";
|
||||
}
|
||||
|
||||
function parseConfigEntry(value: unknown): AutoresearchJsonConfigEntry | null {
|
||||
if (!isConfigEntry(value)) return null;
|
||||
const candidate = value as AutoresearchJsonConfigEntry;
|
||||
const config: AutoresearchJsonConfigEntry = { type: "config" };
|
||||
if (typeof candidate.name === "string" && candidate.name.trim().length > 0) {
|
||||
config.name = candidate.name;
|
||||
}
|
||||
if (typeof candidate.metricName === "string" && candidate.metricName.trim().length > 0) {
|
||||
config.metricName = candidate.metricName;
|
||||
}
|
||||
if (typeof candidate.metricUnit === "string") {
|
||||
config.metricUnit = candidate.metricUnit;
|
||||
}
|
||||
if (candidate.bestDirection === "lower" || candidate.bestDirection === "higher") {
|
||||
config.bestDirection = candidate.bestDirection;
|
||||
}
|
||||
if (typeof candidate.benchmarkCommand === "string" && candidate.benchmarkCommand.trim().length > 0) {
|
||||
config.benchmarkCommand = candidate.benchmarkCommand;
|
||||
}
|
||||
if (Array.isArray(candidate.secondaryMetrics)) {
|
||||
config.secondaryMetrics = normalizeAutoresearchList(
|
||||
candidate.secondaryMetrics.filter((item): item is string => typeof item === "string"),
|
||||
);
|
||||
}
|
||||
if (Array.isArray(candidate.scopePaths)) {
|
||||
config.scopePaths = normalizeAutoresearchList(
|
||||
candidate.scopePaths.filter((item): item is string => typeof item === "string").map(normalizeContractPathSpec),
|
||||
);
|
||||
}
|
||||
if (Array.isArray(candidate.offLimits)) {
|
||||
config.offLimits = normalizeAutoresearchList(
|
||||
candidate.offLimits.filter((item): item is string => typeof item === "string").map(normalizeContractPathSpec),
|
||||
);
|
||||
}
|
||||
if (Array.isArray(candidate.constraints)) {
|
||||
config.constraints = normalizeAutoresearchList(
|
||||
candidate.constraints.filter((item): item is string => typeof item === "string"),
|
||||
);
|
||||
}
|
||||
if (typeof candidate.segmentFingerprint === "string" && candidate.segmentFingerprint.trim().length > 0) {
|
||||
config.segmentFingerprint = candidate.segmentFingerprint;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function isRunEntry(value: unknown): value is AutoresearchJsonRunEntry {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const candidate = value as { type?: unknown };
|
||||
return candidate.type === undefined || candidate.type === "run";
|
||||
}
|
||||
|
||||
function isExperimentStatus(value: unknown): value is ExperimentResult["status"] {
|
||||
return value === "keep" || value === "discard" || value === "crash" || value === "checks_failed";
|
||||
}
|
||||
|
||||
function cloneNumericMetrics(value: unknown): NumericMetricMap {
|
||||
if (typeof value !== "object" || value === null) return {};
|
||||
const metrics = value as { [key: string]: unknown };
|
||||
const clone: NumericMetricMap = {};
|
||||
for (const [key, entryValue] of Object.entries(metrics)) {
|
||||
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
|
||||
if (typeof entryValue === "number" && Number.isFinite(entryValue)) {
|
||||
clone[key] = entryValue;
|
||||
}
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
function cloneStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((item): item is string => typeof item === "string");
|
||||
}
|
||||
|
||||
function hydrateMetricDefs(metricNames: string[] | undefined): MetricDef[] {
|
||||
if (!metricNames) return [];
|
||||
return metricNames.map(name => ({
|
||||
name,
|
||||
unit: inferMetricUnitFromName(name),
|
||||
}));
|
||||
}
|
||||
|
||||
function cloneAsi(value: unknown): ExperimentResult["asi"] {
|
||||
if (typeof value !== "object" || value === null) return undefined;
|
||||
const clone: { [key: string]: unknown } = {};
|
||||
for (const [key, entryValue] of Object.entries(value)) {
|
||||
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
|
||||
clone[key] = structuredClone(entryValue);
|
||||
}
|
||||
return clone as ExperimentResult["asi"];
|
||||
}
|
||||
|
||||
function parseControlEntry(value: unknown): AutoresearchControlEntryData | null {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
const candidate = value as { goal?: unknown; mode?: unknown };
|
||||
if (candidate.mode !== "on" && candidate.mode !== "off" && candidate.mode !== "clear") return null;
|
||||
const data: AutoresearchControlEntryData = { mode: candidate.mode };
|
||||
if (typeof candidate.goal === "string" && candidate.goal.trim().length > 0) {
|
||||
data.goal = candidate.goal;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { StringEnum } from "@oh-my-pi/pi-ai";
|
||||
import { Text } from "@oh-my-pi/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { ToolDefinition } from "../../extensibility/extensions";
|
||||
import type { Theme } from "../../modes/theme/theme";
|
||||
import { replaceTabs, truncateToWidth } from "../../tools/render-utils";
|
||||
import {
|
||||
buildAutoresearchSegmentFingerprint,
|
||||
contractListsEqual,
|
||||
contractPathListsEqual,
|
||||
loadAutoresearchScriptSnapshot,
|
||||
readAutoresearchContract,
|
||||
} from "../contract";
|
||||
import {
|
||||
inferMetricUnitFromName,
|
||||
isAutoresearchShCommand,
|
||||
readMaxExperiments,
|
||||
readPendingRunSummary,
|
||||
resolveWorkDir,
|
||||
validateWorkDir,
|
||||
} from "../helpers";
|
||||
import { cloneExperimentState } from "../state";
|
||||
import type { AutoresearchToolFactoryOptions, ExperimentState } from "../types";
|
||||
|
||||
const initExperimentSchema = Type.Object({
|
||||
name: Type.String({
|
||||
description: "Human-readable experiment name.",
|
||||
}),
|
||||
metric_name: Type.String({
|
||||
description: "Primary metric name shown in the dashboard.",
|
||||
}),
|
||||
metric_unit: Type.Optional(
|
||||
Type.String({
|
||||
description: "Unit for the primary metric, for example µs, ms, s, kb, or empty.",
|
||||
}),
|
||||
),
|
||||
direction: Type.Optional(
|
||||
StringEnum(["lower", "higher"], {
|
||||
description: "Whether lower or higher values are better. Defaults to lower.",
|
||||
}),
|
||||
),
|
||||
benchmark_command: Type.String({
|
||||
description: "Benchmark command recorded in autoresearch.md.",
|
||||
}),
|
||||
scope_paths: Type.Array(Type.String(), {
|
||||
description: "Files in Scope from autoresearch.md. Must be non-empty.",
|
||||
minItems: 1,
|
||||
}),
|
||||
off_limits: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description: "Off Limits paths from autoresearch.md.",
|
||||
}),
|
||||
),
|
||||
constraints: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description: "Constraints from autoresearch.md.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
interface InitExperimentDetails {
|
||||
state: ExperimentState;
|
||||
}
|
||||
|
||||
export function createInitExperimentTool(
|
||||
options: AutoresearchToolFactoryOptions,
|
||||
): ToolDefinition<typeof initExperimentSchema, InitExperimentDetails> {
|
||||
return {
|
||||
name: "init_experiment",
|
||||
label: "Init Experiment",
|
||||
description:
|
||||
"Initialize or reset the autoresearch session for the current optimization target before the first logged run of a segment.",
|
||||
parameters: initExperimentSchema,
|
||||
defaultInactive: true,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const workDirError = validateWorkDir(ctx.cwd);
|
||||
if (workDirError) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${workDirError}` }],
|
||||
};
|
||||
}
|
||||
|
||||
const runtime = options.getRuntime(ctx);
|
||||
const state = runtime.state;
|
||||
const isReinitializing = state.results.length > 0;
|
||||
const workDir = resolveWorkDir(ctx.cwd);
|
||||
const pendingRun = await readPendingRunSummary(workDir, collectLoggedRunNumbers(state.results));
|
||||
if (pendingRun) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`Error: run #${pendingRun.runNumber} has not been logged yet. ` +
|
||||
"Call log_experiment before re-initializing the current segment.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const contractResult = readAutoresearchContract(workDir);
|
||||
const scriptSnapshot = loadAutoresearchScriptSnapshot(workDir);
|
||||
const errors = [...contractResult.errors, ...scriptSnapshot.errors];
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${errors.join(" ")}` }],
|
||||
};
|
||||
}
|
||||
|
||||
const benchmarkContract = contractResult.contract.benchmark;
|
||||
const expectedDirection = benchmarkContract.direction ?? "lower";
|
||||
const expectedMetricUnit = benchmarkContract.metricUnit;
|
||||
if (benchmarkContract.command && !isAutoresearchShCommand(benchmarkContract.command)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Error: Benchmark.command in autoresearch.md must invoke `autoresearch.sh` directly. " +
|
||||
"Move the real workload into `autoresearch.sh` and re-run init_experiment.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (benchmarkContract.command !== params.benchmark_command.trim()) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Error: benchmark_command does not match autoresearch.md. " +
|
||||
`Expected: ${benchmarkContract.command ?? "(missing)"}\nReceived: ${params.benchmark_command}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (benchmarkContract.primaryMetric !== params.metric_name.trim()) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Error: metric_name does not match autoresearch.md. " +
|
||||
`Expected: ${benchmarkContract.primaryMetric ?? "(missing)"}\nReceived: ${params.metric_name}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if ((params.metric_unit ?? "") !== expectedMetricUnit) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Error: metric_unit does not match autoresearch.md. " +
|
||||
`Expected: ${expectedMetricUnit || "(empty)"}\nReceived: ${params.metric_unit ?? "(empty)"}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if ((params.direction ?? "lower") !== expectedDirection) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Error: direction does not match autoresearch.md. " +
|
||||
`Expected: ${expectedDirection}\nReceived: ${params.direction ?? "lower"}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (!contractPathListsEqual(params.scope_paths, contractResult.contract.scopePaths)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Error: scope_paths do not match autoresearch.md. " +
|
||||
`Expected: ${contractResult.contract.scopePaths.join(", ")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (!contractPathListsEqual(params.off_limits ?? [], contractResult.contract.offLimits)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Error: off_limits do not match autoresearch.md. " +
|
||||
`Expected: ${contractResult.contract.offLimits.join(", ") || "(empty)"}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (!contractListsEqual(params.constraints ?? [], contractResult.contract.constraints)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Error: constraints do not match autoresearch.md. " +
|
||||
`Expected: ${contractResult.contract.constraints.join(", ") || "(empty)"}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const segmentFingerprint = buildAutoresearchSegmentFingerprint(contractResult.contract, {
|
||||
benchmarkScript: scriptSnapshot.benchmarkScript,
|
||||
checksScript: scriptSnapshot.checksScript,
|
||||
});
|
||||
|
||||
state.name = params.name;
|
||||
state.metricName = params.metric_name;
|
||||
state.metricUnit = params.metric_unit ?? "";
|
||||
state.bestDirection = params.direction ?? "lower";
|
||||
state.maxExperiments = readMaxExperiments(ctx.cwd);
|
||||
state.bestMetric = null;
|
||||
state.confidence = null;
|
||||
state.secondaryMetrics = benchmarkContract.secondaryMetrics.map(name => ({
|
||||
name,
|
||||
unit: inferMetricUnitFromName(name),
|
||||
}));
|
||||
state.benchmarkCommand = params.benchmark_command.trim();
|
||||
state.scopePaths = [...contractResult.contract.scopePaths];
|
||||
state.offLimits = [...contractResult.contract.offLimits];
|
||||
state.constraints = [...contractResult.contract.constraints];
|
||||
state.segmentFingerprint = segmentFingerprint;
|
||||
if (isReinitializing) {
|
||||
state.currentSegment += 1;
|
||||
}
|
||||
|
||||
const jsonlPath = path.join(workDir, "autoresearch.jsonl");
|
||||
const configLine = JSON.stringify({
|
||||
type: "config",
|
||||
name: state.name,
|
||||
metricName: state.metricName,
|
||||
metricUnit: state.metricUnit,
|
||||
bestDirection: state.bestDirection,
|
||||
benchmarkCommand: state.benchmarkCommand,
|
||||
secondaryMetrics: state.secondaryMetrics.map(metric => metric.name),
|
||||
scopePaths: state.scopePaths,
|
||||
offLimits: state.offLimits,
|
||||
constraints: state.constraints,
|
||||
segmentFingerprint,
|
||||
});
|
||||
|
||||
if (isReinitializing) {
|
||||
fs.appendFileSync(jsonlPath, `${configLine}\n`);
|
||||
} else {
|
||||
fs.writeFileSync(jsonlPath, `${configLine}\n`);
|
||||
}
|
||||
|
||||
runtime.autoresearchMode = true;
|
||||
runtime.autoResumeArmed = true;
|
||||
runtime.lastAutoResumePendingRunNumber = null;
|
||||
runtime.lastRunChecks = null;
|
||||
runtime.lastRunDuration = null;
|
||||
runtime.lastRunAsi = null;
|
||||
runtime.lastRunArtifactDir = null;
|
||||
runtime.lastRunNumber = null;
|
||||
runtime.lastRunSummary = null;
|
||||
options.dashboard.updateWidget(ctx, runtime);
|
||||
options.dashboard.requestRender();
|
||||
|
||||
const lines = [
|
||||
`Experiment initialized: ${state.name}`,
|
||||
`Metric: ${state.metricName} (${state.metricUnit || "unitless"}, ${state.bestDirection} is better)`,
|
||||
`Benchmark command: ${state.benchmarkCommand}`,
|
||||
`Working directory: ${workDir}`,
|
||||
`Files in Scope: ${state.scopePaths.join(", ")}`,
|
||||
isReinitializing
|
||||
? "Previous results remain in history. This starts a new segment and requires a fresh baseline."
|
||||
: "Now run the baseline experiment and log it.",
|
||||
];
|
||||
if (state.maxExperiments !== null) {
|
||||
lines.push(`Max iterations: ${state.maxExperiments}`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: { state: cloneExperimentState(state) },
|
||||
};
|
||||
},
|
||||
renderCall(args, _options, theme): Text {
|
||||
return new Text(renderInitCall(args.name, theme), 0, 0);
|
||||
},
|
||||
renderResult(result): Text {
|
||||
const text = replaceTabs(result.content.find(part => part.type === "text")?.text ?? "");
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderInitCall(name: string, theme: Theme): string {
|
||||
return `${theme.fg("toolTitle", theme.bold("init_experiment"))} ${theme.fg("accent", truncateToWidth(replaceTabs(name), 100))}`;
|
||||
}
|
||||
|
||||
function collectLoggedRunNumbers(results: ExperimentState["results"]): Set<number> {
|
||||
const runNumbers = new Set<number>();
|
||||
for (const result of results) {
|
||||
if (result.runNumber !== null) {
|
||||
runNumbers.add(result.runNumber);
|
||||
}
|
||||
}
|
||||
return runNumbers;
|
||||
}
|
||||
@@ -0,0 +1,833 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { StringEnum } from "@oh-my-pi/pi-ai";
|
||||
import { Text } from "@oh-my-pi/pi-tui";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { ToolDefinition } from "../../extensibility/extensions";
|
||||
import type { Theme } from "../../modes/theme/theme";
|
||||
import { replaceTabs, truncateToWidth } from "../../tools/render-utils";
|
||||
import { getAutoresearchFingerprintMismatchError, pathMatchesContractPath } from "../contract";
|
||||
import { getCurrentAutoresearchBranch, parseWorkDirDirtyPaths } from "../git";
|
||||
import {
|
||||
AUTORESEARCH_COMMITTABLE_FILES,
|
||||
formatNum,
|
||||
inferMetricUnitFromName,
|
||||
isAutoresearchCommittableFile,
|
||||
isAutoresearchLocalStatePath,
|
||||
isBetter,
|
||||
mergeAsi,
|
||||
readPendingRunSummary,
|
||||
resolveWorkDir,
|
||||
validateWorkDir,
|
||||
} from "../helpers";
|
||||
import {
|
||||
cloneExperimentState,
|
||||
computeConfidence,
|
||||
currentResults,
|
||||
findBaselineMetric,
|
||||
findBaselineSecondary,
|
||||
findBestKeptMetric,
|
||||
} from "../state";
|
||||
import type {
|
||||
ASIData,
|
||||
AutoresearchToolFactoryOptions,
|
||||
ExperimentResult,
|
||||
ExperimentState,
|
||||
LogDetails,
|
||||
NumericMetricMap,
|
||||
} from "../types";
|
||||
|
||||
const EXPERIMENT_TOOL_NAMES = ["init_experiment", "run_experiment", "log_experiment"];
|
||||
|
||||
const logExperimentSchema = Type.Object({
|
||||
commit: Type.String({
|
||||
description: "Current git commit hash or placeholder.",
|
||||
}),
|
||||
metric: Type.Number({
|
||||
description: "Primary metric value for this run.",
|
||||
}),
|
||||
status: StringEnum(["keep", "discard", "crash", "checks_failed"], {
|
||||
description: "Outcome for this run.",
|
||||
}),
|
||||
description: Type.String({
|
||||
description: "Short description of the experiment.",
|
||||
}),
|
||||
metrics: Type.Optional(
|
||||
Type.Record(Type.String(), Type.Number(), {
|
||||
description: "Secondary metrics for this run.",
|
||||
}),
|
||||
),
|
||||
force: Type.Optional(
|
||||
Type.Boolean({
|
||||
description: "Allow introducing new secondary metrics.",
|
||||
}),
|
||||
),
|
||||
asi: Type.Optional(
|
||||
Type.Record(Type.String(), Type.Unknown(), {
|
||||
description: "Actionable side information captured for this run.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
interface PreservedFile {
|
||||
content: Buffer;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface KeepCommitResult {
|
||||
error?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export function createLogExperimentTool(
|
||||
options: AutoresearchToolFactoryOptions,
|
||||
): ToolDefinition<typeof logExperimentSchema, LogDetails> {
|
||||
return {
|
||||
name: "log_experiment",
|
||||
label: "Log Experiment",
|
||||
description:
|
||||
"Log the experiment result, update dashboard state, persist JSONL history, and apply git keep or revert behavior.",
|
||||
parameters: logExperimentSchema,
|
||||
defaultInactive: true,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const workDirError = validateWorkDir(ctx.cwd);
|
||||
if (workDirError) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${workDirError}` }],
|
||||
};
|
||||
}
|
||||
|
||||
const runtime = options.getRuntime(ctx);
|
||||
const state = runtime.state;
|
||||
const workDir = resolveWorkDir(ctx.cwd);
|
||||
const fingerprintError = getAutoresearchFingerprintMismatchError(state.segmentFingerprint, workDir);
|
||||
if (fingerprintError) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${fingerprintError}` }],
|
||||
};
|
||||
}
|
||||
|
||||
const pendingRun =
|
||||
runtime.lastRunSummary ?? (await readPendingRunSummary(workDir, collectLoggedRunNumbers(state.results)));
|
||||
if (!pendingRun) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: no unlogged run is available. Run run_experiment first." }],
|
||||
};
|
||||
}
|
||||
runtime.lastRunSummary = pendingRun;
|
||||
runtime.lastRunAsi = pendingRun.parsedAsi;
|
||||
runtime.lastRunChecks =
|
||||
pendingRun.checksPass === null
|
||||
? null
|
||||
: {
|
||||
pass: pendingRun.checksPass,
|
||||
output: "",
|
||||
duration: pendingRun.checksDurationSeconds ?? 0,
|
||||
};
|
||||
runtime.lastRunDuration = pendingRun.durationSeconds;
|
||||
|
||||
if (pendingRun.parsedPrimary !== null && params.metric !== pendingRun.parsedPrimary) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Error: metric does not match the parsed primary metric from the pending run.\n" +
|
||||
`Expected: ${pendingRun.parsedPrimary}\nReceived: ${params.metric}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (params.status === "keep" && !pendingRun.passed) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: cannot keep this run because the pending benchmark did not pass. Log it as crash or checks_failed instead.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (params.status === "keep" && runtime.lastRunChecks && !runtime.lastRunChecks.pass) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: cannot keep this run because autoresearch.checks.sh failed. Log it as checks_failed instead.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const observedStatusError = validateObservedStatus(params.status, pendingRun);
|
||||
if (observedStatusError) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${observedStatusError}` }],
|
||||
};
|
||||
}
|
||||
|
||||
const secondaryMetrics = buildSecondaryMetrics(params.metrics, pendingRun.parsedMetrics, state.metricName);
|
||||
const validationError = validateSecondaryMetrics(state, secondaryMetrics, params.force ?? false);
|
||||
if (validationError) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${validationError}` }],
|
||||
};
|
||||
}
|
||||
|
||||
const mergedAsi = mergeAsi(runtime.lastRunAsi, sanitizeAsi(params.asi));
|
||||
const asiValidationError = validateAsiRequirements(mergedAsi, params.status);
|
||||
if (asiValidationError) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${asiValidationError}` }],
|
||||
};
|
||||
}
|
||||
|
||||
let keepScopeValidation: { committablePaths: string[] } | undefined;
|
||||
if (params.status === "keep") {
|
||||
const scopeValidation = await validateKeepPaths(options, workDir, state);
|
||||
if (typeof scopeValidation === "string") {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${scopeValidation}` }],
|
||||
};
|
||||
}
|
||||
const currentBestMetric = findBestKeptMetric(state.results, state.currentSegment, state.bestDirection);
|
||||
if (
|
||||
currentBestMetric !== null &&
|
||||
params.metric !== currentBestMetric &&
|
||||
!isBetter(params.metric, currentBestMetric, state.bestDirection)
|
||||
) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Error: cannot keep this run because the primary metric regressed.\n" +
|
||||
`Current best: ${currentBestMetric}\nReceived: ${params.metric}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
keepScopeValidation = scopeValidation;
|
||||
}
|
||||
|
||||
const experiment: ExperimentResult = {
|
||||
runNumber: runtime.lastRunNumber ?? pendingRun.runNumber,
|
||||
commit: params.commit.slice(0, 7),
|
||||
metric: params.metric,
|
||||
metrics: secondaryMetrics,
|
||||
status: params.status,
|
||||
description: params.description,
|
||||
timestamp: Date.now(),
|
||||
segment: state.currentSegment,
|
||||
confidence: null,
|
||||
asi: mergedAsi,
|
||||
};
|
||||
|
||||
const activeBranch = await getCurrentAutoresearchBranch(options.pi, workDir);
|
||||
if (!activeBranch) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Error: autoresearch keep/discard actions require an active `autoresearch/...` branch. " +
|
||||
"Run `/autoresearch` again to restore the protected branch before logging this run.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let gitNote: string | null = null;
|
||||
if (params.status === "keep") {
|
||||
const commitResult = await commitKeptExperiment(options, workDir, state, experiment, keepScopeValidation);
|
||||
if (commitResult.error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${commitResult.error}` }],
|
||||
};
|
||||
}
|
||||
gitNote = commitResult.note ?? null;
|
||||
} else {
|
||||
const revertResult = await revertFailedExperiment(options, workDir);
|
||||
if (revertResult.error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${revertResult.error}` }],
|
||||
};
|
||||
}
|
||||
gitNote = revertResult.note ?? null;
|
||||
}
|
||||
|
||||
const previousState = cloneExperimentState(state);
|
||||
state.results.push(experiment);
|
||||
registerSecondaryMetrics(state, secondaryMetrics);
|
||||
state.bestMetric = findBaselineMetric(state.results, state.currentSegment);
|
||||
state.confidence = computeConfidence(state.results, state.currentSegment, state.bestDirection);
|
||||
experiment.confidence = state.confidence;
|
||||
|
||||
const wallClockSeconds = runtime.lastRunDuration;
|
||||
try {
|
||||
persistRun(workDir, experiment);
|
||||
} catch (error) {
|
||||
runtime.state = previousState;
|
||||
options.dashboard.updateWidget(ctx, runtime);
|
||||
options.dashboard.requestRender();
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await updateRunMetadata(runtime.lastRunArtifactDir ?? pendingRun.runDirectory, {
|
||||
commit: experiment.commit,
|
||||
confidence: experiment.confidence,
|
||||
description: experiment.description,
|
||||
gitNote,
|
||||
loggedAt: new Date(experiment.timestamp).toISOString(),
|
||||
loggedAsi: experiment.asi,
|
||||
loggedMetric: experiment.metric,
|
||||
loggedMetrics: experiment.metrics,
|
||||
runNumber: runtime.lastRunNumber ?? pendingRun.runNumber,
|
||||
status: experiment.status,
|
||||
wallClockSeconds,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn("Failed to update autoresearch run metadata after persisting JSONL history", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
runDirectory: runtime.lastRunArtifactDir ?? pendingRun.runDirectory,
|
||||
runNumber: runtime.lastRunNumber ?? pendingRun.runNumber,
|
||||
});
|
||||
}
|
||||
|
||||
runtime.runningExperiment = null;
|
||||
runtime.lastRunChecks = null;
|
||||
runtime.lastRunDuration = null;
|
||||
runtime.lastRunAsi = null;
|
||||
runtime.lastRunArtifactDir = null;
|
||||
runtime.lastRunNumber = null;
|
||||
runtime.lastRunSummary = null;
|
||||
runtime.autoResumeArmed = true;
|
||||
runtime.lastAutoResumePendingRunNumber = null;
|
||||
|
||||
const currentSegmentRuns = currentResults(state.results, state.currentSegment).length;
|
||||
const text = buildLogText(state, experiment, currentSegmentRuns, wallClockSeconds, gitNote);
|
||||
if (state.maxExperiments !== null && currentSegmentRuns >= state.maxExperiments) {
|
||||
runtime.autoresearchMode = false;
|
||||
options.pi.appendEntry(
|
||||
"autoresearch-control",
|
||||
runtime.goal ? { mode: "off", goal: runtime.goal } : { mode: "off" },
|
||||
);
|
||||
await options.pi.setActiveTools(
|
||||
options.pi.getActiveTools().filter(name => !EXPERIMENT_TOOL_NAMES.includes(name)),
|
||||
);
|
||||
}
|
||||
options.dashboard.updateWidget(ctx, runtime);
|
||||
options.dashboard.requestRender();
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: {
|
||||
experiment: {
|
||||
...experiment,
|
||||
metrics: { ...experiment.metrics },
|
||||
asi: experiment.asi ? structuredClone(experiment.asi) : undefined,
|
||||
},
|
||||
state: cloneExperimentState(state),
|
||||
wallClockSeconds,
|
||||
},
|
||||
};
|
||||
},
|
||||
renderCall(args, _options, theme): Text {
|
||||
const color = args.status === "keep" ? "success" : args.status === "discard" ? "warning" : "error";
|
||||
const description = truncateToWidth(replaceTabs(args.description), 100);
|
||||
return new Text(
|
||||
`${theme.fg("toolTitle", theme.bold("log_experiment"))} ${theme.fg(color, args.status)} ${theme.fg("muted", description)}`,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
},
|
||||
renderResult(result, _options, theme): Text {
|
||||
const details = result.details;
|
||||
if (!details) {
|
||||
return new Text(replaceTabs(result.content.find(part => part.type === "text")?.text ?? ""), 0, 0);
|
||||
}
|
||||
const summary = renderSummary(details, theme);
|
||||
return new Text(summary, 0, 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cloneMetrics(value: NumericMetricMap | undefined): NumericMetricMap {
|
||||
return value ? { ...value } : {};
|
||||
}
|
||||
|
||||
function buildSecondaryMetrics(
|
||||
overrides: NumericMetricMap | undefined,
|
||||
parsedMetrics: NumericMetricMap | null,
|
||||
primaryMetricName: string,
|
||||
): NumericMetricMap {
|
||||
const merged: NumericMetricMap = {};
|
||||
for (const [name, value] of Object.entries(parsedMetrics ?? {})) {
|
||||
if (name === "__proto__" || name === "constructor" || name === "prototype") continue;
|
||||
if (name === primaryMetricName) continue;
|
||||
merged[name] = value;
|
||||
}
|
||||
for (const [name, value] of Object.entries(cloneMetrics(overrides))) {
|
||||
if (name === "__proto__" || name === "constructor" || name === "prototype") continue;
|
||||
merged[name] = value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function sanitizeAsi(value: { [key: string]: unknown } | undefined): ASIData | undefined {
|
||||
if (!value) return undefined;
|
||||
const result: ASIData = {};
|
||||
for (const [key, entryValue] of Object.entries(value)) {
|
||||
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
|
||||
const sanitized = sanitizeAsiValue(entryValue);
|
||||
if (sanitized !== undefined) {
|
||||
result[key] = sanitized;
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function sanitizeAsiValue(value: unknown): ASIData[string] | undefined {
|
||||
if (value === null) return null;
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
||||
if (Array.isArray(value)) {
|
||||
const items = value
|
||||
.map(item => sanitizeAsiValue(item))
|
||||
.filter((item): item is NonNullable<typeof item> => item !== undefined);
|
||||
return items;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const objectValue = value as { [key: string]: unknown };
|
||||
const result: ASIData = {};
|
||||
for (const [key, entryValue] of Object.entries(objectValue)) {
|
||||
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
|
||||
const sanitized = sanitizeAsiValue(entryValue);
|
||||
if (sanitized !== undefined) {
|
||||
result[key] = sanitized;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function validateAsiRequirements(asi: ASIData | undefined, status: ExperimentResult["status"]): string | null {
|
||||
if (!asi) {
|
||||
return "asi is required. Include at minimum a non-empty hypothesis.";
|
||||
}
|
||||
if (typeof asi.hypothesis !== "string" || asi.hypothesis.trim().length === 0) {
|
||||
return "asi.hypothesis is required and must be a non-empty string.";
|
||||
}
|
||||
if (status === "keep") return null;
|
||||
if (typeof asi.rollback_reason !== "string" || asi.rollback_reason.trim().length === 0) {
|
||||
return "asi.rollback_reason is required for discard, crash, and checks_failed results.";
|
||||
}
|
||||
if (typeof asi.next_action_hint !== "string" || asi.next_action_hint.trim().length === 0) {
|
||||
return "asi.next_action_hint is required for discard, crash, and checks_failed results.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateSecondaryMetrics(state: ExperimentState, metrics: NumericMetricMap, force: boolean): string | null {
|
||||
if (state.secondaryMetrics.length === 0) return null;
|
||||
const knownNames = new Set(state.secondaryMetrics.map(metric => metric.name));
|
||||
const providedNames = new Set(Object.keys(metrics));
|
||||
|
||||
const missing = [...knownNames].filter(name => !providedNames.has(name));
|
||||
if (missing.length > 0) {
|
||||
return `missing secondary metrics: ${missing.join(", ")}`;
|
||||
}
|
||||
|
||||
const newMetrics = [...providedNames].filter(name => !knownNames.has(name));
|
||||
if (newMetrics.length > 0 && !force) {
|
||||
return `new secondary metrics require force=true: ${newMetrics.join(", ")}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function registerSecondaryMetrics(state: ExperimentState, metrics: NumericMetricMap): void {
|
||||
for (const name of Object.keys(metrics)) {
|
||||
if (state.secondaryMetrics.some(metric => metric.name === name)) continue;
|
||||
state.secondaryMetrics.push({
|
||||
name,
|
||||
unit: inferMetricUnitFromName(name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function persistRun(workDir: string, experiment: ExperimentResult): void {
|
||||
const entry = {
|
||||
run: experiment.runNumber,
|
||||
...experiment,
|
||||
};
|
||||
const jsonlPath = path.join(workDir, "autoresearch.jsonl");
|
||||
fs.appendFileSync(jsonlPath, `${JSON.stringify(entry)}\n`);
|
||||
}
|
||||
|
||||
function collectLoggedRunNumbers(results: ExperimentResult[]): Set<number> {
|
||||
const runNumbers = new Set<number>();
|
||||
for (const result of results) {
|
||||
if (result.runNumber !== null) {
|
||||
runNumbers.add(result.runNumber);
|
||||
}
|
||||
}
|
||||
return runNumbers;
|
||||
}
|
||||
|
||||
function validateObservedStatus(
|
||||
status: ExperimentResult["status"],
|
||||
pendingRun: { checksPass: boolean | null; passed: boolean },
|
||||
): string | null {
|
||||
if (pendingRun.checksPass === false) {
|
||||
return status === "checks_failed"
|
||||
? null
|
||||
: "benchmark checks failed for the pending run. Log it as checks_failed.";
|
||||
}
|
||||
if (!pendingRun.passed) {
|
||||
return status === "crash" ? null : "the pending benchmark failed. Log it as crash.";
|
||||
}
|
||||
return status === "keep" || status === "discard" ? null : "the pending benchmark passed. Log it as keep or discard.";
|
||||
}
|
||||
|
||||
async function commitKeptExperiment(
|
||||
options: AutoresearchToolFactoryOptions,
|
||||
workDir: string,
|
||||
state: ExperimentState,
|
||||
experiment: ExperimentResult,
|
||||
scopeValidation: { committablePaths: string[] } | undefined,
|
||||
): Promise<KeepCommitResult> {
|
||||
if (!scopeValidation || scopeValidation.committablePaths.length === 0) {
|
||||
return { note: "nothing to commit" };
|
||||
}
|
||||
|
||||
const addResult = await options.pi.exec("git", ["add", "--all", "--", ...scopeValidation.committablePaths], {
|
||||
cwd: workDir,
|
||||
timeout: 10_000,
|
||||
});
|
||||
if (addResult.code !== 0) {
|
||||
return {
|
||||
error: `git add failed: ${mergeStdoutStderr(addResult).trim() || `exit ${addResult.code}`}`,
|
||||
};
|
||||
}
|
||||
|
||||
const diffResult = await options.pi.exec(
|
||||
"git",
|
||||
["diff", "--cached", "--quiet", "--", ...scopeValidation.committablePaths],
|
||||
{
|
||||
cwd: workDir,
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
if (diffResult.code === 0) {
|
||||
return { note: "nothing to commit" };
|
||||
}
|
||||
|
||||
const payload: { [key: string]: string | number } = {
|
||||
status: experiment.status,
|
||||
[state.metricName]: experiment.metric,
|
||||
};
|
||||
for (const [name, value] of Object.entries(experiment.metrics)) {
|
||||
payload[name] = value;
|
||||
}
|
||||
const commitMessage = `${experiment.description}\n\nResult: ${JSON.stringify(payload)}`;
|
||||
const commitResult = await options.pi.exec(
|
||||
"git",
|
||||
["commit", "-m", commitMessage, "--", ...scopeValidation.committablePaths],
|
||||
{
|
||||
cwd: workDir,
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
if (commitResult.code !== 0) {
|
||||
return {
|
||||
error: `git commit failed: ${mergeStdoutStderr(commitResult).trim() || `exit ${commitResult.code}`}`,
|
||||
};
|
||||
}
|
||||
|
||||
const revParseResult = await options.pi.exec("git", ["rev-parse", "--short=7", "HEAD"], {
|
||||
cwd: workDir,
|
||||
timeout: 5_000,
|
||||
});
|
||||
const newCommit = revParseResult.stdout.trim();
|
||||
if (newCommit.length >= 7) {
|
||||
experiment.commit = newCommit;
|
||||
}
|
||||
const summaryLine =
|
||||
mergeStdoutStderr(commitResult)
|
||||
.split("\n")
|
||||
.find(line => line.trim().length > 0) ?? "committed";
|
||||
return { note: summaryLine.trim() };
|
||||
}
|
||||
|
||||
async function revertFailedExperiment(
|
||||
options: AutoresearchToolFactoryOptions,
|
||||
workDir: string,
|
||||
): Promise<KeepCommitResult> {
|
||||
const preservedFiles = preserveAutoresearchFiles(workDir);
|
||||
const restoreResult = await options.pi.exec(
|
||||
"git",
|
||||
["restore", "--source=HEAD", "--staged", "--worktree", "--", "."],
|
||||
{ cwd: workDir, timeout: 10_000 },
|
||||
);
|
||||
const cleanResult = await options.pi.exec("git", ["clean", "-fd", "--", "."], { cwd: workDir, timeout: 10_000 });
|
||||
const cleanIgnoredResult = await options.pi.exec("git", ["clean", "-fdX", "--", "."], {
|
||||
cwd: workDir,
|
||||
timeout: 10_000,
|
||||
});
|
||||
restoreAutoresearchFiles(preservedFiles);
|
||||
if (restoreResult.code !== 0) {
|
||||
return {
|
||||
error: `git restore failed: ${mergeStdoutStderr(restoreResult).trim() || `exit ${restoreResult.code}`}`,
|
||||
};
|
||||
}
|
||||
if (cleanResult.code !== 0) {
|
||||
return {
|
||||
error: `git clean failed: ${mergeStdoutStderr(cleanResult).trim() || `exit ${cleanResult.code}`}`,
|
||||
};
|
||||
}
|
||||
if (cleanIgnoredResult.code !== 0) {
|
||||
return {
|
||||
error: `git clean -X failed: ${mergeStdoutStderr(cleanIgnoredResult).trim() || `exit ${cleanIgnoredResult.code}`}`,
|
||||
};
|
||||
}
|
||||
const dirtyCheckResult = await options.pi.exec(
|
||||
"git",
|
||||
["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "."],
|
||||
{ cwd: workDir, timeout: 10_000 },
|
||||
);
|
||||
if (dirtyCheckResult.code !== 0) {
|
||||
return {
|
||||
error: `git status failed after cleanup: ${mergeStdoutStderr(dirtyCheckResult).trim() || `exit ${dirtyCheckResult.code}`}`,
|
||||
};
|
||||
}
|
||||
const workDirPrefix = await readGitWorkDirPrefix(options, workDir);
|
||||
const remainingDirtyPaths = parseWorkDirDirtyPaths(dirtyCheckResult.stdout, workDirPrefix).filter(
|
||||
relativePath => !isAutoresearchLocalStatePath(relativePath),
|
||||
);
|
||||
if (remainingDirtyPaths.length > 0) {
|
||||
return {
|
||||
error:
|
||||
"Autoresearch cleanup left the worktree dirty. Resolve these paths before continuing: " +
|
||||
remainingDirtyPaths.join(", "),
|
||||
};
|
||||
}
|
||||
return { note: "reverted changes" };
|
||||
}
|
||||
|
||||
function preserveAutoresearchFiles(workDir: string): PreservedFile[] {
|
||||
const files: PreservedFile[] = [];
|
||||
for (const relativePath of [...AUTORESEARCH_COMMITTABLE_FILES, "autoresearch.jsonl"]) {
|
||||
const absolutePath = path.join(workDir, relativePath);
|
||||
if (!fs.existsSync(absolutePath)) continue;
|
||||
files.push({
|
||||
content: fs.readFileSync(absolutePath),
|
||||
path: absolutePath,
|
||||
});
|
||||
}
|
||||
const localStateDir = path.join(workDir, ".autoresearch");
|
||||
if (fs.existsSync(localStateDir)) {
|
||||
collectDirectoryFiles(localStateDir, files);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function restoreAutoresearchFiles(files: PreservedFile[]): void {
|
||||
for (const file of files) {
|
||||
fs.mkdirSync(path.dirname(file.path), { recursive: true });
|
||||
fs.writeFileSync(file.path, file.content);
|
||||
}
|
||||
}
|
||||
|
||||
function mergeStdoutStderr(result: { stderr: string; stdout: string }): string {
|
||||
return `${result.stdout}${result.stderr}`;
|
||||
}
|
||||
|
||||
async function validateKeepPaths(
|
||||
options: AutoresearchToolFactoryOptions,
|
||||
workDir: string,
|
||||
state: ExperimentState,
|
||||
): Promise<{ committablePaths: string[] } | string> {
|
||||
if (state.scopePaths.length === 0) {
|
||||
return "Files in Scope is empty for the current segment. Re-run init_experiment after fixing autoresearch.md.";
|
||||
}
|
||||
|
||||
const statusResult = await options.pi.exec(
|
||||
"git",
|
||||
["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "."],
|
||||
{
|
||||
cwd: workDir,
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
if (statusResult.code !== 0) {
|
||||
return `git status failed: ${mergeStdoutStderr(statusResult).trim() || `exit ${statusResult.code}`}`;
|
||||
}
|
||||
|
||||
const workDirPrefix = await readGitWorkDirPrefix(options, workDir);
|
||||
const committablePaths: string[] = [];
|
||||
for (const normalizedPath of parseWorkDirDirtyPaths(statusResult.stdout, workDirPrefix)) {
|
||||
if (isAutoresearchLocalStatePath(normalizedPath)) {
|
||||
continue;
|
||||
}
|
||||
if (isAutoresearchCommittableFile(normalizedPath)) {
|
||||
committablePaths.push(normalizedPath);
|
||||
continue;
|
||||
}
|
||||
if (state.offLimits.some(spec => pathMatchesContractPath(normalizedPath, spec))) {
|
||||
return `cannot keep this run because ${normalizedPath} is listed under Off Limits in autoresearch.md`;
|
||||
}
|
||||
if (!state.scopePaths.some(spec => pathMatchesContractPath(normalizedPath, spec))) {
|
||||
return `cannot keep this run because ${normalizedPath} is outside Files in Scope`;
|
||||
}
|
||||
committablePaths.push(normalizedPath);
|
||||
}
|
||||
|
||||
return { committablePaths };
|
||||
}
|
||||
|
||||
function collectDirectoryFiles(directory: string, files: PreservedFile[]): void {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
collectDirectoryFiles(absolutePath, files);
|
||||
continue;
|
||||
}
|
||||
files.push({
|
||||
content: fs.readFileSync(absolutePath),
|
||||
path: absolutePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRunMetadata(
|
||||
runDirectory: string | null,
|
||||
metadata: {
|
||||
commit: string;
|
||||
confidence: number | null;
|
||||
description: string;
|
||||
gitNote: string | null;
|
||||
loggedAt: string;
|
||||
loggedAsi: ASIData | undefined;
|
||||
loggedMetric: number;
|
||||
loggedMetrics: NumericMetricMap;
|
||||
runNumber: number | null;
|
||||
status: ExperimentResult["status"];
|
||||
wallClockSeconds: number | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!runDirectory) return;
|
||||
const runJsonPath = path.join(runDirectory, "run.json");
|
||||
let existing: Record<string, unknown> = {};
|
||||
try {
|
||||
existing = (await Bun.file(runJsonPath).json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
existing = {};
|
||||
}
|
||||
await Bun.write(
|
||||
runJsonPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
...existing,
|
||||
loggedRunNumber: metadata.runNumber,
|
||||
loggedAt: metadata.loggedAt,
|
||||
loggedAsi: metadata.loggedAsi,
|
||||
loggedMetric: metadata.loggedMetric,
|
||||
loggedMetrics: metadata.loggedMetrics,
|
||||
status: metadata.status,
|
||||
description: metadata.description,
|
||||
commit: metadata.commit,
|
||||
gitNote: metadata.gitNote,
|
||||
confidence: metadata.confidence,
|
||||
wallClockSeconds: metadata.wallClockSeconds,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function buildLogText(
|
||||
state: ExperimentState,
|
||||
experiment: ExperimentResult,
|
||||
currentSegmentRuns: number,
|
||||
wallClockSeconds: number | null,
|
||||
gitNote: string | null,
|
||||
): string {
|
||||
const displayRunNumber = experiment.runNumber ?? state.results.length;
|
||||
const lines = [`Logged run #${displayRunNumber}: ${experiment.status} - ${experiment.description}`];
|
||||
if (wallClockSeconds !== null) {
|
||||
lines.push(`Wall clock: ${wallClockSeconds.toFixed(1)}s`);
|
||||
}
|
||||
if (state.bestMetric !== null) {
|
||||
lines.push(`Baseline ${state.metricName}: ${formatNum(state.bestMetric, state.metricUnit)}`);
|
||||
}
|
||||
if (currentSegmentRuns > 1 && state.bestMetric !== null && experiment.metric !== state.bestMetric) {
|
||||
const delta = ((experiment.metric - state.bestMetric) / state.bestMetric) * 100;
|
||||
const sign = delta > 0 ? "+" : "";
|
||||
lines.push(`This run: ${formatNum(experiment.metric, state.metricUnit)} (${sign}${delta.toFixed(1)}%)`);
|
||||
} else {
|
||||
lines.push(`This run: ${formatNum(experiment.metric, state.metricUnit)}`);
|
||||
}
|
||||
if (Object.keys(experiment.metrics).length > 0) {
|
||||
const baselineSecondary = findBaselineSecondary(state.results, state.currentSegment, state.secondaryMetrics);
|
||||
const parts = Object.entries(experiment.metrics).map(([name, value]) => {
|
||||
const unit = state.secondaryMetrics.find(metric => metric.name === name)?.unit ?? "";
|
||||
const baseline = baselineSecondary[name];
|
||||
if (baseline === undefined || baseline === 0 || currentSegmentRuns === 1) {
|
||||
return `${name}: ${formatNum(value, unit)}`;
|
||||
}
|
||||
const delta = ((value - baseline) / baseline) * 100;
|
||||
const sign = delta > 0 ? "+" : "";
|
||||
return `${name}: ${formatNum(value, unit)} (${sign}${delta.toFixed(1)}%)`;
|
||||
});
|
||||
lines.push(`Secondary metrics: ${parts.join(" ")}`);
|
||||
}
|
||||
if (experiment.asi) {
|
||||
const asiSummary = Object.entries(experiment.asi)
|
||||
.map(([key, value]) => `${key}: ${truncateAsiValue(value)}`)
|
||||
.join(" | ");
|
||||
lines.push(`ASI: ${asiSummary}`);
|
||||
}
|
||||
if (state.confidence !== null) {
|
||||
const status = state.confidence >= 2 ? "likely real" : state.confidence >= 1 ? "marginal" : "within noise";
|
||||
lines.push(`Confidence: ${state.confidence.toFixed(1)}x noise floor (${status})`);
|
||||
}
|
||||
if (gitNote) {
|
||||
lines.push(`Git: ${gitNote}`);
|
||||
}
|
||||
if (state.maxExperiments !== null) {
|
||||
lines.push(`Progress: ${currentSegmentRuns}/${state.maxExperiments} runs in current segment`);
|
||||
if (currentSegmentRuns >= state.maxExperiments) {
|
||||
lines.push(`Maximum experiments reached (${state.maxExperiments}). Autoresearch mode is now off.`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function readGitWorkDirPrefix(options: AutoresearchToolFactoryOptions, workDir: string): Promise<string> {
|
||||
const prefixResult = await options.pi.exec("git", ["rev-parse", "--show-prefix"], { cwd: workDir, timeout: 5_000 });
|
||||
if (prefixResult.code !== 0) return "";
|
||||
return prefixResult.stdout.trim();
|
||||
}
|
||||
|
||||
function truncateAsiValue(value: ASIData[string]): string {
|
||||
const text = typeof value === "string" ? value : JSON.stringify(value);
|
||||
return text.length > 120 ? `${text.slice(0, 117)}...` : text;
|
||||
}
|
||||
|
||||
function renderSummary(details: LogDetails, theme: Theme): string {
|
||||
const { experiment, state } = details;
|
||||
const color = experiment.status === "keep" ? "success" : experiment.status === "discard" ? "warning" : "error";
|
||||
let summary = `${theme.fg(color, experiment.status.toUpperCase())} ${theme.fg("muted", truncateToWidth(replaceTabs(experiment.description), 100))}`;
|
||||
summary += ` ${theme.fg("accent", `${state.metricName}=${formatNum(experiment.metric, state.metricUnit)}`)}`;
|
||||
if (state.bestMetric !== null) {
|
||||
summary += ` ${theme.fg("dim", `baseline ${formatNum(state.bestMetric, state.metricUnit)}`)}`;
|
||||
}
|
||||
if (state.confidence !== null) {
|
||||
summary += ` ${theme.fg("dim", `conf ${state.confidence.toFixed(1)}x`)}`;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
import * as childProcess from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { Text } from "@oh-my-pi/pi-tui";
|
||||
import { formatBytes } from "@oh-my-pi/pi-utils";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { ToolDefinition } from "../../extensibility/extensions";
|
||||
import type { Theme } from "../../modes/theme/theme";
|
||||
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateTail } from "../../session/streaming-output";
|
||||
import { replaceTabs, shortenPath, truncateToWidth } from "../../tools/render-utils";
|
||||
import { getAutoresearchFingerprintMismatchError } from "../contract";
|
||||
import {
|
||||
EXPERIMENT_MAX_BYTES,
|
||||
EXPERIMENT_MAX_LINES,
|
||||
formatElapsed,
|
||||
formatNum,
|
||||
getAutoresearchRunDirectory,
|
||||
getNextAutoresearchRunNumber,
|
||||
isAutoresearchShCommand,
|
||||
killTree,
|
||||
parseAsiLines,
|
||||
parseMetricLines,
|
||||
readPendingRunSummary,
|
||||
resolveWorkDir,
|
||||
validateWorkDir,
|
||||
} from "../helpers";
|
||||
import type { AutoresearchToolFactoryOptions, RunDetails, RunExperimentProgressDetails } from "../types";
|
||||
|
||||
const runExperimentSchema = Type.Object({
|
||||
command: Type.String({
|
||||
description: "Shell command to run for this experiment.",
|
||||
}),
|
||||
timeout_seconds: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Timeout in seconds. Defaults to 600.",
|
||||
}),
|
||||
),
|
||||
checks_timeout_seconds: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Timeout in seconds for autoresearch.checks.sh. Defaults to 300.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
interface ProcessExecutionResult {
|
||||
exitCode: number | null;
|
||||
killed: boolean;
|
||||
logPath: string;
|
||||
output: string;
|
||||
}
|
||||
|
||||
interface ChecksExecutionResult {
|
||||
code: number | null;
|
||||
killed: boolean;
|
||||
logPath: string;
|
||||
output: string;
|
||||
}
|
||||
|
||||
interface ProgressSnapshot {
|
||||
elapsed: string;
|
||||
runDirectory: string;
|
||||
fullOutputPath: string;
|
||||
tailOutput: string;
|
||||
truncation?: RunExperimentProgressDetails["truncation"];
|
||||
}
|
||||
|
||||
export function createRunExperimentTool(
|
||||
options: AutoresearchToolFactoryOptions,
|
||||
): ToolDefinition<typeof runExperimentSchema, RunDetails | RunExperimentProgressDetails> {
|
||||
return {
|
||||
name: "run_experiment",
|
||||
label: "Run Experiment",
|
||||
description:
|
||||
"Run an experiment command with timing, output capture, structured metric parsing, durable run artifacts, and optional autoresearch.checks.sh validation.",
|
||||
parameters: runExperimentSchema,
|
||||
defaultInactive: true,
|
||||
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
||||
const workDirError = validateWorkDir(ctx.cwd);
|
||||
if (workDirError) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${workDirError}` }],
|
||||
};
|
||||
}
|
||||
|
||||
const runtime = options.getRuntime(ctx);
|
||||
const state = runtime.state;
|
||||
const workDir = resolveWorkDir(ctx.cwd);
|
||||
const checksPath = path.join(workDir, "autoresearch.checks.sh");
|
||||
const autoresearchScriptPath = path.join(workDir, "autoresearch.sh");
|
||||
const fingerprintError = getAutoresearchFingerprintMismatchError(state.segmentFingerprint, workDir);
|
||||
if (fingerprintError) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${fingerprintError}` }],
|
||||
};
|
||||
}
|
||||
|
||||
if (state.benchmarkCommand && params.command.trim() !== state.benchmarkCommand) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Error: command does not match the benchmark command recorded for this segment.\n" +
|
||||
`Expected: ${state.benchmarkCommand}\nReceived: ${params.command}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (fs.existsSync(autoresearchScriptPath) && !isAutoresearchShCommand(params.command)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`Error: autoresearch.sh exists. Run it directly instead of using a different command.\n` +
|
||||
`Expected something like: bash autoresearch.sh\n` +
|
||||
`Received: ${params.command}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (state.maxExperiments !== null) {
|
||||
const segmentRuns = state.results.filter(result => result.segment === state.currentSegment).length;
|
||||
if (segmentRuns >= state.maxExperiments) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Maximum experiments reached (${state.maxExperiments}). Re-initialize to start a new segment.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const pendingRun =
|
||||
runtime.lastRunSummary ?? (await readPendingRunSummary(workDir, collectLoggedRunNumbers(state.results)));
|
||||
if (pendingRun) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`Error: run #${pendingRun.runNumber} has not been logged yet. ` +
|
||||
"Call log_experiment before starting another benchmark run.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const runNumber = getNextAutoresearchRunNumber(workDir, runtime.lastRunNumber);
|
||||
const runDirectory = getAutoresearchRunDirectory(workDir, runNumber);
|
||||
const benchmarkLogPath = path.join(runDirectory, "benchmark.log");
|
||||
const checksLogPath = path.join(runDirectory, "checks.log");
|
||||
const runJsonPath = path.join(runDirectory, "run.json");
|
||||
await fs.promises.mkdir(runDirectory, { recursive: true });
|
||||
runtime.lastRunChecks = null;
|
||||
runtime.lastRunDuration = null;
|
||||
runtime.lastRunAsi = null;
|
||||
runtime.lastRunArtifactDir = runDirectory;
|
||||
runtime.lastRunNumber = runNumber;
|
||||
runtime.lastRunSummary = null;
|
||||
await Bun.write(
|
||||
runJsonPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
runNumber,
|
||||
runDirectory,
|
||||
benchmarkLogPath,
|
||||
checksLogPath,
|
||||
command: params.command,
|
||||
startedAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
runtime.runningExperiment = {
|
||||
startedAt: Date.now(),
|
||||
command: params.command,
|
||||
runDirectory,
|
||||
runNumber,
|
||||
};
|
||||
options.dashboard.updateWidget(ctx, runtime);
|
||||
options.dashboard.requestRender();
|
||||
|
||||
const timeoutMs = Math.max(0, Math.floor((params.timeout_seconds ?? 600) * 1000));
|
||||
const startedAt = Date.now();
|
||||
let execution: ProcessExecutionResult;
|
||||
try {
|
||||
execution = await executeProcess({
|
||||
command: ["bash", "-lc", params.command],
|
||||
cwd: workDir,
|
||||
logPath: benchmarkLogPath,
|
||||
timeoutMs,
|
||||
signal,
|
||||
onProgress: details => {
|
||||
onUpdate?.({
|
||||
content: [{ type: "text", text: details.tailOutput }],
|
||||
details: {
|
||||
phase: "running",
|
||||
elapsed: details.elapsed,
|
||||
truncation: details.truncation,
|
||||
fullOutputPath: details.fullOutputPath,
|
||||
runDirectory: details.runDirectory,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
runtime.runningExperiment = null;
|
||||
options.dashboard.updateWidget(ctx, runtime);
|
||||
options.dashboard.requestRender();
|
||||
}
|
||||
|
||||
const durationSeconds = (Date.now() - startedAt) / 1000;
|
||||
runtime.lastRunDuration = durationSeconds;
|
||||
|
||||
const benchmarkPassed = execution.exitCode === 0 && !execution.killed;
|
||||
let checksPass: boolean | null = null;
|
||||
let checksTimedOut = false;
|
||||
let checksOutput = "";
|
||||
let checksDuration = 0;
|
||||
let checksLogPathValue: string | undefined;
|
||||
|
||||
if (benchmarkPassed && fs.existsSync(checksPath)) {
|
||||
const checksStartedAt = Date.now();
|
||||
const checksResult = await runChecks({
|
||||
cwd: workDir,
|
||||
pathToChecks: checksPath,
|
||||
logPath: checksLogPath,
|
||||
timeoutMs: Math.max(0, Math.floor((params.checks_timeout_seconds ?? 300) * 1000)),
|
||||
signal,
|
||||
});
|
||||
checksDuration = (Date.now() - checksStartedAt) / 1000;
|
||||
checksTimedOut = checksResult.killed;
|
||||
checksPass = checksResult.code === 0 && !checksResult.killed;
|
||||
checksOutput = checksResult.output;
|
||||
checksLogPathValue = checksResult.logPath;
|
||||
}
|
||||
|
||||
runtime.lastRunChecks =
|
||||
checksPass === null
|
||||
? null
|
||||
: {
|
||||
pass: checksPass,
|
||||
output: checksOutput,
|
||||
duration: checksDuration,
|
||||
};
|
||||
|
||||
const llmTruncation = truncateTail(execution.output, {
|
||||
maxBytes: EXPERIMENT_MAX_BYTES,
|
||||
maxLines: EXPERIMENT_MAX_LINES,
|
||||
});
|
||||
const displayTruncation = truncateTail(execution.output, {
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
maxLines: DEFAULT_MAX_LINES,
|
||||
});
|
||||
|
||||
const parsedMetricsMap = parseMetricLines(execution.output);
|
||||
const parsedMetrics = parsedMetricsMap.size > 0 ? Object.fromEntries(parsedMetricsMap.entries()) : null;
|
||||
const parsedPrimary = parsedMetricsMap.get(state.metricName) ?? null;
|
||||
const parsedAsi = parseAsiLines(execution.output);
|
||||
runtime.lastRunAsi = parsedAsi;
|
||||
|
||||
const resultDetails: RunDetails = {
|
||||
runNumber,
|
||||
runDirectory,
|
||||
benchmarkLogPath,
|
||||
checksLogPath: checksLogPathValue,
|
||||
command: params.command,
|
||||
exitCode: execution.exitCode,
|
||||
durationSeconds,
|
||||
passed: benchmarkPassed && (checksPass === null || checksPass),
|
||||
crashed: execution.exitCode !== 0 || execution.killed || checksPass === false,
|
||||
timedOut: execution.killed,
|
||||
tailOutput: displayTruncation.content,
|
||||
checksPass,
|
||||
checksTimedOut,
|
||||
checksOutput: checksOutput.split("\n").slice(-80).join("\n"),
|
||||
checksDuration,
|
||||
parsedMetrics,
|
||||
parsedPrimary,
|
||||
parsedAsi,
|
||||
metricName: state.metricName,
|
||||
metricUnit: state.metricUnit,
|
||||
truncation: llmTruncation.truncated ? llmTruncation : undefined,
|
||||
fullOutputPath: execution.logPath,
|
||||
};
|
||||
runtime.lastRunSummary = {
|
||||
checksDurationSeconds: checksDuration,
|
||||
checksPass,
|
||||
checksTimedOut,
|
||||
command: params.command,
|
||||
durationSeconds,
|
||||
parsedAsi,
|
||||
parsedMetrics,
|
||||
parsedPrimary,
|
||||
passed: resultDetails.passed,
|
||||
runDirectory,
|
||||
runNumber,
|
||||
};
|
||||
runtime.autoResumeArmed = true;
|
||||
runtime.lastAutoResumePendingRunNumber = null;
|
||||
options.dashboard.updateWidget(ctx, runtime);
|
||||
options.dashboard.requestRender();
|
||||
|
||||
await Bun.write(
|
||||
runJsonPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
runNumber,
|
||||
runDirectory,
|
||||
benchmarkLogPath,
|
||||
checksLogPath: checksLogPathValue,
|
||||
command: params.command,
|
||||
completedAt: new Date().toISOString(),
|
||||
durationSeconds,
|
||||
exitCode: execution.exitCode,
|
||||
timedOut: execution.killed,
|
||||
checks: {
|
||||
durationSeconds: checksDuration,
|
||||
passed: checksPass,
|
||||
timedOut: checksTimedOut,
|
||||
},
|
||||
parsedMetrics,
|
||||
parsedPrimary,
|
||||
parsedAsi,
|
||||
truncation: resultDetails.truncation,
|
||||
fullOutputPath: resultDetails.fullOutputPath,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: buildRunText(resultDetails, llmTruncation.content, state.bestMetric) }],
|
||||
details: resultDetails,
|
||||
};
|
||||
},
|
||||
renderCall(args, _options, theme): Text {
|
||||
const commandPreview = truncateToWidth(replaceTabs(args.command), 100);
|
||||
return new Text(
|
||||
`${theme.fg("toolTitle", theme.bold("run_experiment"))} ${theme.fg("muted", commandPreview)}`,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
},
|
||||
renderResult(result, options, theme): Text {
|
||||
if (isProgressDetails(result.details)) {
|
||||
const header = theme.fg("warning", `Running ${result.details.elapsed}...`);
|
||||
const preview = replaceTabs(result.content.find(part => part.type === "text")?.text ?? "");
|
||||
return new Text(preview ? `${header}\n${theme.fg("dim", preview)}` : header, 0, 0);
|
||||
}
|
||||
|
||||
const details = result.details;
|
||||
if (!details || !isRunDetails(details)) {
|
||||
return new Text(replaceTabs(result.content.find(part => part.type === "text")?.text ?? ""), 0, 0);
|
||||
}
|
||||
|
||||
const statusText = renderStatus(details, theme);
|
||||
if (!options.expanded && details.tailOutput.trim().length === 0) {
|
||||
return new Text(statusText, 0, 0);
|
||||
}
|
||||
|
||||
const preview = replaceTabs(
|
||||
options.expanded ? details.tailOutput : details.tailOutput.split("\n").slice(-5).join("\n"),
|
||||
);
|
||||
const suffix =
|
||||
options.expanded && details.truncation && details.fullOutputPath
|
||||
? `\n${theme.fg("warning", `Full output: ${shortenPath(details.fullOutputPath)}`)}`
|
||||
: "";
|
||||
return new Text(preview ? `${statusText}\n${theme.fg("dim", preview)}${suffix}` : statusText, 0, 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function executeProcess(options: {
|
||||
command: string[];
|
||||
cwd: string;
|
||||
logPath: string;
|
||||
timeoutMs: number;
|
||||
signal?: AbortSignal;
|
||||
onProgress?(details: ProgressSnapshot): void;
|
||||
}): Promise<ProcessExecutionResult> {
|
||||
const { promise, resolve, reject } = Promise.withResolvers<ProcessExecutionResult>();
|
||||
const child = childProcess.spawn(options.command[0] ?? "bash", options.command.slice(1), {
|
||||
cwd: options.cwd,
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const tailChunks: Buffer[] = [];
|
||||
let chunksBytes = 0;
|
||||
let killedByTimeout = false;
|
||||
let resolved = false;
|
||||
let writeStream: fs.WriteStream | undefined = fs.createWriteStream(options.logPath);
|
||||
let forceKillTimeout: NodeJS.Timeout | undefined;
|
||||
|
||||
const closeWriteStream = (): Promise<void> => {
|
||||
if (!writeStream) return Promise.resolve();
|
||||
const stream = writeStream;
|
||||
writeStream = undefined;
|
||||
return new Promise<void>((resolveClose, rejectClose) => {
|
||||
stream.end((error?: Error | null) => {
|
||||
if (error) {
|
||||
rejectClose(error);
|
||||
return;
|
||||
}
|
||||
resolveClose();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (progressTimer) clearInterval(progressTimer);
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
if (forceKillTimeout) clearTimeout(forceKillTimeout);
|
||||
options.signal?.removeEventListener("abort", abortHandler);
|
||||
};
|
||||
|
||||
const finish = (callback: () => void): void => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
cleanup();
|
||||
callback();
|
||||
};
|
||||
|
||||
const appendChunk = (data: Buffer): void => {
|
||||
writeStream?.write(data);
|
||||
tailChunks.push(data);
|
||||
chunksBytes += data.length;
|
||||
while (chunksBytes > DEFAULT_MAX_BYTES * 2 && tailChunks.length > 1) {
|
||||
const removed = tailChunks.shift();
|
||||
if (removed) chunksBytes -= removed.length;
|
||||
}
|
||||
};
|
||||
|
||||
const snapshot = (): ProgressSnapshot => {
|
||||
const tail = truncateTail(Buffer.concat(tailChunks).toString("utf8"), {
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
maxLines: DEFAULT_MAX_LINES,
|
||||
});
|
||||
return {
|
||||
elapsed: formatElapsed(Date.now() - startedAt),
|
||||
runDirectory: path.dirname(options.logPath),
|
||||
fullOutputPath: options.logPath,
|
||||
tailOutput: tail.content,
|
||||
truncation: tail.truncated ? tail : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const killTreeWithEscalation = (): void => {
|
||||
if (!child.pid) return;
|
||||
killTree(child.pid);
|
||||
forceKillTimeout = setTimeout(() => {
|
||||
if (child.pid) killTree(child.pid, "SIGKILL");
|
||||
}, 1_000);
|
||||
forceKillTimeout.unref?.();
|
||||
};
|
||||
|
||||
const startedAt = Date.now();
|
||||
const progressTimer = options.onProgress
|
||||
? setInterval(() => {
|
||||
options.onProgress?.(snapshot());
|
||||
}, 1000)
|
||||
: undefined;
|
||||
const timeoutHandle =
|
||||
options.timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
killedByTimeout = true;
|
||||
killTreeWithEscalation();
|
||||
}, options.timeoutMs)
|
||||
: undefined;
|
||||
|
||||
const abortHandler = (): void => {
|
||||
killTreeWithEscalation();
|
||||
};
|
||||
if (options.signal?.aborted) {
|
||||
abortHandler();
|
||||
} else {
|
||||
options.signal?.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
|
||||
child.stdout?.on("data", data => {
|
||||
appendChunk(data);
|
||||
});
|
||||
child.stderr?.on("data", data => {
|
||||
appendChunk(data);
|
||||
});
|
||||
child.on("error", error => {
|
||||
void closeWriteStream().finally(() => {
|
||||
finish(() => reject(error));
|
||||
});
|
||||
});
|
||||
child.on("close", async code => {
|
||||
try {
|
||||
await closeWriteStream();
|
||||
if (options.signal?.aborted) {
|
||||
finish(() => reject(new Error("aborted")));
|
||||
return;
|
||||
}
|
||||
const output = await fs.promises.readFile(options.logPath, "utf8");
|
||||
finish(() =>
|
||||
resolve({
|
||||
exitCode: code,
|
||||
killed: killedByTimeout,
|
||||
logPath: options.logPath,
|
||||
output,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
finish(() => reject(error));
|
||||
}
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function runChecks(options: {
|
||||
cwd: string;
|
||||
pathToChecks: string;
|
||||
logPath: string;
|
||||
timeoutMs: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ChecksExecutionResult> {
|
||||
const result = await executeProcess({
|
||||
command: ["bash", options.pathToChecks],
|
||||
cwd: options.cwd,
|
||||
logPath: options.logPath,
|
||||
timeoutMs: options.timeoutMs,
|
||||
signal: options.signal,
|
||||
});
|
||||
return {
|
||||
code: result.exitCode,
|
||||
killed: result.killed,
|
||||
logPath: result.logPath,
|
||||
output: result.output.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRunText(details: RunDetails, outputPreview: string, bestMetric: number | null): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`Run directory: ${details.runDirectory}`);
|
||||
if (details.timedOut) {
|
||||
lines.push(`TIMEOUT after ${details.durationSeconds.toFixed(1)}s`);
|
||||
} else if (details.exitCode !== 0) {
|
||||
lines.push(`FAILED with exit code ${details.exitCode} in ${details.durationSeconds.toFixed(1)}s`);
|
||||
} else {
|
||||
lines.push(`PASSED in ${details.durationSeconds.toFixed(1)}s`);
|
||||
}
|
||||
if (details.checksTimedOut) {
|
||||
lines.push(`Checks timed out after ${details.checksDuration.toFixed(1)}s`);
|
||||
} else if (details.checksPass === false) {
|
||||
lines.push(`Checks failed in ${details.checksDuration.toFixed(1)}s`);
|
||||
} else if (details.checksPass === true) {
|
||||
lines.push(`Checks passed in ${details.checksDuration.toFixed(1)}s`);
|
||||
}
|
||||
if (bestMetric !== null) {
|
||||
lines.push(`Current baseline ${details.metricName}: ${formatNum(bestMetric, details.metricUnit)}`);
|
||||
}
|
||||
if (details.parsedPrimary !== null) {
|
||||
lines.push(`Parsed ${details.metricName}: ${details.parsedPrimary}`);
|
||||
lines.push(`Next log_experiment metric: ${details.parsedPrimary}`);
|
||||
}
|
||||
if (details.parsedMetrics) {
|
||||
const secondaryEntries = Object.entries(details.parsedMetrics)
|
||||
.filter(([name]) => name !== details.metricName)
|
||||
.map(([name, value]) => [name, value] as const);
|
||||
const secondary = secondaryEntries.map(([name, value]) => `${name}=${value}`);
|
||||
if (secondary.length > 0) {
|
||||
lines.push(`Parsed metrics: ${secondary.join(", ")}`);
|
||||
lines.push(`Next log_experiment metrics: ${JSON.stringify(Object.fromEntries(secondaryEntries))}`);
|
||||
}
|
||||
}
|
||||
if (details.parsedAsi) {
|
||||
lines.push(`Parsed ASI keys: ${Object.keys(details.parsedAsi).join(", ")}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(outputPreview);
|
||||
if (details.truncation && details.fullOutputPath) {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`Output truncated (${formatBytes(EXPERIMENT_MAX_BYTES)} limit). Full output: ${details.fullOutputPath}`,
|
||||
);
|
||||
}
|
||||
if (details.checksLogPath) {
|
||||
lines.push(`Checks log: ${details.checksLogPath}`);
|
||||
}
|
||||
if (details.checksPass === false && details.checksOutput.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("Checks output:");
|
||||
lines.push(details.checksOutput);
|
||||
}
|
||||
return lines.join("\n").trimEnd();
|
||||
}
|
||||
|
||||
function renderStatus(details: RunDetails, theme: Theme): string {
|
||||
if (details.timedOut) {
|
||||
return theme.fg("error", `TIMEOUT ${details.durationSeconds.toFixed(1)}s`);
|
||||
}
|
||||
if (details.checksTimedOut) {
|
||||
return theme.fg("warning", `Checks timeout ${details.checksDuration.toFixed(1)}s`);
|
||||
}
|
||||
if (details.checksPass === false) {
|
||||
return theme.fg("error", `Checks failed ${details.checksDuration.toFixed(1)}s`);
|
||||
}
|
||||
if (details.exitCode !== 0) {
|
||||
return theme.fg("error", `FAIL exit=${details.exitCode} ${details.durationSeconds.toFixed(1)}s`);
|
||||
}
|
||||
const metric =
|
||||
details.parsedPrimary !== null
|
||||
? ` ${details.metricName}=${formatNum(details.parsedPrimary, details.metricUnit)}`
|
||||
: "";
|
||||
return theme.fg("success", `PASS ${details.durationSeconds.toFixed(1)}s${metric}`);
|
||||
}
|
||||
|
||||
function isRunDetails(value: unknown): value is RunDetails {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
return "command" in value && "durationSeconds" in value;
|
||||
}
|
||||
|
||||
function isProgressDetails(value: unknown): value is RunExperimentProgressDetails {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
return "phase" in value && value.phase === "running";
|
||||
}
|
||||
|
||||
function collectLoggedRunNumbers(results: Array<{ runNumber: number | null }>): Set<number> {
|
||||
const runNumbers = new Set<number>();
|
||||
for (const result of results) {
|
||||
if (result.runNumber !== null) {
|
||||
runNumbers.add(result.runNumber);
|
||||
}
|
||||
}
|
||||
return runNumbers;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
||||
import type { ExtensionAPI, ExtensionContext } from "../extensibility/extensions";
|
||||
import type { SessionEntry } from "../session/session-manager";
|
||||
import type { TruncationResult } from "../session/streaming-output";
|
||||
|
||||
export type MetricDirection = "lower" | "higher";
|
||||
export type ExperimentStatus = "keep" | "discard" | "crash" | "checks_failed";
|
||||
|
||||
export type ASIValue = string | number | boolean | null | ASIValue[] | { [key: string]: ASIValue };
|
||||
|
||||
export interface ASIData {
|
||||
[key: string]: ASIValue;
|
||||
}
|
||||
|
||||
export interface NumericMetricMap {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
export interface MetricDef {
|
||||
name: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface AutoresearchBenchmarkContract {
|
||||
command: string | null;
|
||||
primaryMetric: string | null;
|
||||
metricUnit: string;
|
||||
direction: MetricDirection | null;
|
||||
secondaryMetrics: string[];
|
||||
}
|
||||
|
||||
export interface AutoresearchContract {
|
||||
benchmark: AutoresearchBenchmarkContract;
|
||||
scopePaths: string[];
|
||||
offLimits: string[];
|
||||
constraints: string[];
|
||||
}
|
||||
|
||||
export interface ExperimentResult {
|
||||
runNumber: number | null;
|
||||
commit: string;
|
||||
metric: number;
|
||||
metrics: NumericMetricMap;
|
||||
status: ExperimentStatus;
|
||||
description: string;
|
||||
timestamp: number;
|
||||
segment: number;
|
||||
confidence: number | null;
|
||||
asi?: ASIData;
|
||||
}
|
||||
|
||||
export interface ExperimentState {
|
||||
results: ExperimentResult[];
|
||||
bestMetric: number | null;
|
||||
bestDirection: MetricDirection;
|
||||
metricName: string;
|
||||
metricUnit: string;
|
||||
secondaryMetrics: MetricDef[];
|
||||
name: string | null;
|
||||
currentSegment: number;
|
||||
maxExperiments: number | null;
|
||||
confidence: number | null;
|
||||
benchmarkCommand: string | null;
|
||||
scopePaths: string[];
|
||||
offLimits: string[];
|
||||
constraints: string[];
|
||||
segmentFingerprint: string | null;
|
||||
}
|
||||
|
||||
export interface RunExperimentProgressDetails {
|
||||
phase: "running";
|
||||
elapsed: string;
|
||||
truncation?: TruncationResult;
|
||||
fullOutputPath?: string;
|
||||
runDirectory?: string;
|
||||
}
|
||||
|
||||
export interface RunDetails {
|
||||
runNumber: number;
|
||||
runDirectory: string;
|
||||
benchmarkLogPath: string;
|
||||
checksLogPath?: string;
|
||||
command: string;
|
||||
exitCode: number | null;
|
||||
durationSeconds: number;
|
||||
passed: boolean;
|
||||
crashed: boolean;
|
||||
timedOut: boolean;
|
||||
tailOutput: string;
|
||||
checksPass: boolean | null;
|
||||
checksTimedOut: boolean;
|
||||
checksOutput: string;
|
||||
checksDuration: number;
|
||||
parsedMetrics: NumericMetricMap | null;
|
||||
parsedPrimary: number | null;
|
||||
parsedAsi: ASIData | null;
|
||||
metricName: string;
|
||||
metricUnit: string;
|
||||
truncation?: TruncationResult;
|
||||
fullOutputPath?: string;
|
||||
}
|
||||
|
||||
export interface LogDetails {
|
||||
experiment: ExperimentResult;
|
||||
state: ExperimentState;
|
||||
wallClockSeconds: number | null;
|
||||
}
|
||||
|
||||
export interface ChecksResult {
|
||||
pass: boolean;
|
||||
output: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface PendingRunSummary {
|
||||
checksDurationSeconds: number | null;
|
||||
checksPass: boolean | null;
|
||||
checksTimedOut: boolean;
|
||||
command: string;
|
||||
durationSeconds: number | null;
|
||||
parsedAsi: ASIData | null;
|
||||
parsedMetrics: NumericMetricMap | null;
|
||||
parsedPrimary: number | null;
|
||||
passed: boolean;
|
||||
runDirectory: string;
|
||||
runNumber: number;
|
||||
}
|
||||
|
||||
export interface RunningExperiment {
|
||||
startedAt: number;
|
||||
command: string;
|
||||
runDirectory: string;
|
||||
runNumber: number;
|
||||
}
|
||||
|
||||
export interface AutoresearchRuntime {
|
||||
autoresearchMode: boolean;
|
||||
autoResumeArmed: boolean;
|
||||
dashboardExpanded: boolean;
|
||||
lastAutoResumePendingRunNumber: number | null;
|
||||
lastRunChecks: ChecksResult | null;
|
||||
lastRunDuration: number | null;
|
||||
lastRunAsi: ASIData | null;
|
||||
lastRunArtifactDir: string | null;
|
||||
lastRunNumber: number | null;
|
||||
lastRunSummary: PendingRunSummary | null;
|
||||
runningExperiment: RunningExperiment | null;
|
||||
state: ExperimentState;
|
||||
goal: string | null;
|
||||
}
|
||||
|
||||
export interface AutoresearchConfig {
|
||||
maxIterations?: number;
|
||||
workingDir?: string;
|
||||
}
|
||||
|
||||
export interface AutoresearchJsonConfigEntry {
|
||||
type: "config";
|
||||
name?: string;
|
||||
metricName?: string;
|
||||
metricUnit?: string;
|
||||
bestDirection?: MetricDirection;
|
||||
benchmarkCommand?: string;
|
||||
secondaryMetrics?: string[];
|
||||
scopePaths?: string[];
|
||||
offLimits?: string[];
|
||||
constraints?: string[];
|
||||
segmentFingerprint?: string;
|
||||
}
|
||||
|
||||
export interface AutoresearchJsonRunEntry {
|
||||
run?: number;
|
||||
commit?: string;
|
||||
metric?: number;
|
||||
metrics?: NumericMetricMap;
|
||||
status?: ExperimentStatus;
|
||||
description?: string;
|
||||
timestamp?: number;
|
||||
confidence?: number | null;
|
||||
asi?: ASIData;
|
||||
}
|
||||
|
||||
export interface ReconstructedExperimentData {
|
||||
hasLog: boolean;
|
||||
state: ExperimentState;
|
||||
}
|
||||
|
||||
export interface AutoresearchControlEntryData {
|
||||
mode: "on" | "off" | "clear";
|
||||
goal?: string;
|
||||
}
|
||||
|
||||
export interface ReconstructedControlState {
|
||||
autoresearchMode: boolean;
|
||||
goal: string | null;
|
||||
lastMode: AutoresearchControlEntryData["mode"] | null;
|
||||
}
|
||||
|
||||
export interface RuntimeStore {
|
||||
clear(sessionKey: string): void;
|
||||
ensure(sessionKey: string): AutoresearchRuntime;
|
||||
}
|
||||
|
||||
export interface DashboardController {
|
||||
clear(ctx: ExtensionContext): void;
|
||||
requestRender(): void;
|
||||
showOverlay(ctx: ExtensionContext, runtime: AutoresearchRuntime): Promise<void>;
|
||||
updateWidget(ctx: ExtensionContext, runtime: AutoresearchRuntime): void;
|
||||
}
|
||||
|
||||
export interface AutoresearchToolFactoryOptions {
|
||||
dashboard: DashboardController;
|
||||
getRuntime(ctx: ExtensionContext): AutoresearchRuntime;
|
||||
pi: ExtensionAPI;
|
||||
}
|
||||
|
||||
export type AutoresearchToolResult<TDetails> = AgentToolResult<TDetails>;
|
||||
export type SessionEntries = SessionEntry[];
|
||||
@@ -28,6 +28,8 @@ export interface Args {
|
||||
mode?: Mode;
|
||||
noSession?: boolean;
|
||||
sessionDir?: string;
|
||||
providerSessionId?: string;
|
||||
fork?: string;
|
||||
models?: string[];
|
||||
tools?: string[];
|
||||
noTools?: boolean;
|
||||
@@ -79,6 +81,8 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
|
||||
} else {
|
||||
result.resume = true;
|
||||
}
|
||||
} else if (arg === "--fork" && i + 1 < args.length) {
|
||||
result.fork = args[++i];
|
||||
} else if (arg === "--provider" && i + 1 < args.length) {
|
||||
result.provider = args[++i];
|
||||
} else if (arg === "--model" && i + 1 < args.length) {
|
||||
@@ -95,6 +99,8 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
|
||||
result.systemPrompt = args[++i];
|
||||
} else if (arg === "--append-system-prompt" && i + 1 < args.length) {
|
||||
result.appendSystemPrompt = args[++i];
|
||||
} else if (arg === "--provider-session-id" && i + 1 < args.length) {
|
||||
result.providerSessionId = args[++i];
|
||||
} else if (arg === "--no-session") {
|
||||
result.noSession = true;
|
||||
} else if (arg === "--session-dir" && i + 1 < args.length) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
||||
import type { Args } from "./args";
|
||||
|
||||
export interface InitialMessageInput {
|
||||
parsed: Args;
|
||||
fileText?: string;
|
||||
fileImages?: ImageContent[];
|
||||
stdinContent?: string;
|
||||
}
|
||||
|
||||
export interface InitialMessageResult {
|
||||
initialMessage?: string;
|
||||
initialImages?: ImageContent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine stdin content, @file text, and the first CLI message into a single
|
||||
* initial prompt for non-interactive mode.
|
||||
*/
|
||||
export function buildInitialMessage({
|
||||
parsed,
|
||||
fileText,
|
||||
fileImages,
|
||||
stdinContent,
|
||||
}: InitialMessageInput): InitialMessageResult {
|
||||
const hasInitialContext = stdinContent !== undefined || fileText !== undefined || (fileImages?.length ?? 0) > 0;
|
||||
if (!hasInitialContext) {
|
||||
return {
|
||||
initialImages: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
let body = "";
|
||||
if (fileText !== undefined) {
|
||||
body += fileText;
|
||||
}
|
||||
|
||||
if (parsed.messages.length > 0) {
|
||||
body += parsed.messages[0];
|
||||
parsed.messages.shift();
|
||||
}
|
||||
|
||||
const initialMessage =
|
||||
stdinContent !== undefined
|
||||
? body.length > 0
|
||||
? `${stdinContent}\n${body}`
|
||||
: stdinContent
|
||||
: body.length > 0
|
||||
? body
|
||||
: fileImages && fileImages.length > 0
|
||||
? ""
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
initialMessage,
|
||||
initialImages: fileImages && fileImages.length > 0 ? fileImages : undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,120 +1,438 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
DEFAULT_EDITOR_KEYBINDINGS,
|
||||
type EditorAction,
|
||||
type EditorKeybindingsConfig,
|
||||
EditorKeybindingsManager,
|
||||
type Keybinding,
|
||||
type KeybindingDefinitions,
|
||||
type KeybindingsConfig,
|
||||
type KeyId,
|
||||
matchesKey,
|
||||
setEditorKeybindings,
|
||||
setKeybindings,
|
||||
TUI_KEYBINDINGS,
|
||||
KeybindingsManager as TuiKeybindingsManager,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
||||
|
||||
/**
|
||||
* Application-level actions (coding agent specific).
|
||||
* Application-level keybindings (coding agent specific).
|
||||
* Values are always `true` — used for declaration merging.
|
||||
*/
|
||||
export type AppAction =
|
||||
| "interrupt"
|
||||
| "clear"
|
||||
| "exit"
|
||||
| "suspend"
|
||||
| "cycleThinkingLevel"
|
||||
| "cycleModelForward"
|
||||
| "cycleModelBackward"
|
||||
| "selectModel"
|
||||
| "togglePlanMode"
|
||||
| "expandTools"
|
||||
| "toggleThinking"
|
||||
| "externalEditor"
|
||||
| "historySearch"
|
||||
| "followUp"
|
||||
| "dequeue"
|
||||
| "pasteImage"
|
||||
| "copyLine"
|
||||
| "copyPrompt"
|
||||
| "newSession"
|
||||
| "tree"
|
||||
| "fork"
|
||||
| "resume"
|
||||
| "toggleSTT";
|
||||
interface AppKeybindings {
|
||||
"app.interrupt": true;
|
||||
"app.clear": true;
|
||||
"app.exit": true;
|
||||
"app.suspend": true;
|
||||
"app.thinking.cycle": true;
|
||||
"app.thinking.toggle": true;
|
||||
"app.model.cycleForward": true;
|
||||
"app.model.cycleBackward": true;
|
||||
"app.model.select": true;
|
||||
"app.tools.expand": true;
|
||||
"app.editor.external": true;
|
||||
"app.message.followUp": true;
|
||||
"app.message.dequeue": true;
|
||||
"app.clipboard.pasteImage": true;
|
||||
"app.clipboard.copyLine": true;
|
||||
"app.clipboard.copyPrompt": true;
|
||||
"app.session.new": true;
|
||||
"app.session.tree": true;
|
||||
"app.session.fork": true;
|
||||
"app.session.resume": true;
|
||||
"app.session.togglePath": true;
|
||||
"app.session.toggleSort": true;
|
||||
"app.session.rename": true;
|
||||
"app.session.delete": true;
|
||||
"app.session.deleteNoninvasive": true;
|
||||
"app.tree.foldOrUp": true;
|
||||
"app.tree.unfoldOrDown": true;
|
||||
"app.plan.toggle": true;
|
||||
"app.history.search": true;
|
||||
"app.stt.toggle": true;
|
||||
}
|
||||
|
||||
export type AppKeybinding = keyof AppKeybindings;
|
||||
|
||||
declare module "@oh-my-pi/pi-tui" {
|
||||
interface Keybindings extends AppKeybindings {}
|
||||
}
|
||||
|
||||
/**
|
||||
* All configurable actions.
|
||||
* All keybindings definitions: TUI + app-specific.
|
||||
*/
|
||||
export type KeyAction = AppAction | EditorAction;
|
||||
export const KEYBINDINGS = {
|
||||
...TUI_KEYBINDINGS,
|
||||
"app.interrupt": {
|
||||
defaultKeys: "escape",
|
||||
description: "Interrupt current operation",
|
||||
},
|
||||
"app.clear": {
|
||||
defaultKeys: "ctrl+c",
|
||||
description: "Clear screen or cancel",
|
||||
},
|
||||
"app.exit": {
|
||||
defaultKeys: "ctrl+d",
|
||||
description: "Exit application",
|
||||
},
|
||||
"app.suspend": {
|
||||
defaultKeys: "ctrl+z",
|
||||
description: "Suspend application",
|
||||
},
|
||||
"app.thinking.cycle": {
|
||||
defaultKeys: "shift+tab",
|
||||
description: "Cycle thinking level",
|
||||
},
|
||||
"app.thinking.toggle": {
|
||||
defaultKeys: "ctrl+t",
|
||||
description: "Toggle thinking mode",
|
||||
},
|
||||
"app.model.cycleForward": {
|
||||
defaultKeys: "ctrl+p",
|
||||
description: "Cycle to next model",
|
||||
},
|
||||
"app.model.cycleBackward": {
|
||||
defaultKeys: "shift+ctrl+p",
|
||||
description: "Cycle to previous model",
|
||||
},
|
||||
"app.model.select": {
|
||||
defaultKeys: "ctrl+l",
|
||||
description: "Select model",
|
||||
},
|
||||
"app.tools.expand": {
|
||||
defaultKeys: "ctrl+o",
|
||||
description: "Expand tools",
|
||||
},
|
||||
"app.editor.external": {
|
||||
defaultKeys: "ctrl+g",
|
||||
description: "Open external editor",
|
||||
},
|
||||
"app.message.followUp": {
|
||||
defaultKeys: "ctrl+enter",
|
||||
description: "Send follow-up message",
|
||||
},
|
||||
"app.message.dequeue": {
|
||||
defaultKeys: "alt+up",
|
||||
description: "Dequeue message",
|
||||
},
|
||||
"app.clipboard.pasteImage": {
|
||||
defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v",
|
||||
description: "Paste image from clipboard",
|
||||
},
|
||||
"app.clipboard.copyLine": {
|
||||
defaultKeys: "alt+shift+l",
|
||||
description: "Copy current line",
|
||||
},
|
||||
"app.clipboard.copyPrompt": {
|
||||
defaultKeys: "alt+shift+c",
|
||||
description: "Copy prompt",
|
||||
},
|
||||
"app.session.new": {
|
||||
defaultKeys: [],
|
||||
description: "Create new session",
|
||||
},
|
||||
"app.session.tree": {
|
||||
defaultKeys: [],
|
||||
description: "Show session tree",
|
||||
},
|
||||
"app.session.fork": {
|
||||
defaultKeys: [],
|
||||
description: "Fork session",
|
||||
},
|
||||
"app.session.resume": {
|
||||
defaultKeys: [],
|
||||
description: "Resume session",
|
||||
},
|
||||
"app.session.togglePath": {
|
||||
defaultKeys: "ctrl+p",
|
||||
description: "Toggle session path display",
|
||||
},
|
||||
"app.session.toggleSort": {
|
||||
defaultKeys: "ctrl+s",
|
||||
description: "Toggle session sort order",
|
||||
},
|
||||
"app.session.rename": {
|
||||
defaultKeys: "ctrl+r",
|
||||
description: "Rename session",
|
||||
},
|
||||
"app.session.delete": {
|
||||
defaultKeys: "ctrl+d",
|
||||
description: "Delete session",
|
||||
},
|
||||
"app.session.deleteNoninvasive": {
|
||||
defaultKeys: "ctrl+backspace",
|
||||
description: "Delete session (non-invasive)",
|
||||
},
|
||||
"app.tree.foldOrUp": {
|
||||
defaultKeys: ["ctrl+left", "alt+left"],
|
||||
description: "Fold or move up",
|
||||
},
|
||||
"app.tree.unfoldOrDown": {
|
||||
defaultKeys: ["ctrl+right", "alt+right"],
|
||||
description: "Unfold or move down",
|
||||
},
|
||||
"app.plan.toggle": {
|
||||
defaultKeys: "alt+shift+p",
|
||||
description: "Toggle plan mode",
|
||||
},
|
||||
"app.history.search": {
|
||||
defaultKeys: "ctrl+r",
|
||||
description: "Search history",
|
||||
},
|
||||
"app.stt.toggle": {
|
||||
defaultKeys: "alt+h",
|
||||
description: "Toggle speech-to-text",
|
||||
},
|
||||
} as const satisfies KeybindingDefinitions;
|
||||
|
||||
/**
|
||||
* Full keybindings configuration (app + editor actions).
|
||||
* Migration map from old keybinding names to new namespaced IDs.
|
||||
*/
|
||||
export type KeybindingsConfig = {
|
||||
[K in KeyAction]?: KeyId | KeyId[];
|
||||
};
|
||||
const KEYBINDING_NAME_MIGRATIONS = {
|
||||
// App-specific (old names)
|
||||
interrupt: "app.interrupt",
|
||||
clear: "app.clear",
|
||||
exit: "app.exit",
|
||||
suspend: "app.suspend",
|
||||
cycleThinkingLevel: "app.thinking.cycle",
|
||||
cycleModelForward: "app.model.cycleForward",
|
||||
cycleModelBackward: "app.model.cycleBackward",
|
||||
selectModel: "app.model.select",
|
||||
togglePlanMode: "app.plan.toggle",
|
||||
historySearch: "app.history.search",
|
||||
expandTools: "app.tools.expand",
|
||||
toggleThinking: "app.thinking.toggle",
|
||||
externalEditor: "app.editor.external",
|
||||
followUp: "app.message.followUp",
|
||||
dequeue: "app.message.dequeue",
|
||||
pasteImage: "app.clipboard.pasteImage",
|
||||
copyLine: "app.clipboard.copyLine",
|
||||
copyPrompt: "app.clipboard.copyPrompt",
|
||||
newSession: "app.session.new",
|
||||
tree: "app.session.tree",
|
||||
fork: "app.session.fork",
|
||||
resume: "app.session.resume",
|
||||
toggleSTT: "app.stt.toggle",
|
||||
// TUI editor (old names for backward compatibility)
|
||||
cursorUp: "tui.editor.cursorUp",
|
||||
cursorDown: "tui.editor.cursorDown",
|
||||
cursorLeft: "tui.editor.cursorLeft",
|
||||
cursorRight: "tui.editor.cursorRight",
|
||||
cursorWordLeft: "tui.editor.cursorWordLeft",
|
||||
cursorWordRight: "tui.editor.cursorWordRight",
|
||||
cursorLineStart: "tui.editor.cursorLineStart",
|
||||
cursorLineEnd: "tui.editor.cursorLineEnd",
|
||||
jumpForward: "tui.editor.jumpForward",
|
||||
jumpBackward: "tui.editor.jumpBackward",
|
||||
pageUp: "tui.editor.pageUp",
|
||||
pageDown: "tui.editor.pageDown",
|
||||
deleteCharBackward: "tui.editor.deleteCharBackward",
|
||||
deleteCharForward: "tui.editor.deleteCharForward",
|
||||
deleteWordBackward: "tui.editor.deleteWordBackward",
|
||||
deleteWordForward: "tui.editor.deleteWordForward",
|
||||
deleteToLineStart: "tui.editor.deleteToLineStart",
|
||||
deleteToLineEnd: "tui.editor.deleteToLineEnd",
|
||||
yank: "tui.editor.yank",
|
||||
yankPop: "tui.editor.yankPop",
|
||||
undo: "tui.editor.undo",
|
||||
// TUI input (old names for backward compatibility)
|
||||
newLine: "tui.input.newLine",
|
||||
submit: "tui.input.submit",
|
||||
tab: "tui.input.tab",
|
||||
copy: "tui.input.copy",
|
||||
// TUI select (old names for backward compatibility)
|
||||
selectUp: "tui.select.up",
|
||||
selectDown: "tui.select.down",
|
||||
selectPageUp: "tui.select.pageUp",
|
||||
selectPageDown: "tui.select.pageDown",
|
||||
selectConfirm: "tui.select.confirm",
|
||||
selectCancel: "tui.select.cancel",
|
||||
// Upstream additional migrations
|
||||
toggleSessionNamedFilter: "app.session.togglePath",
|
||||
} as const satisfies Record<string, Keybinding>;
|
||||
|
||||
/**
|
||||
* Default application keybindings.
|
||||
* Check if a key is a legacy keybinding name.
|
||||
*/
|
||||
export const DEFAULT_APP_KEYBINDINGS: Record<AppAction, KeyId | KeyId[]> = {
|
||||
interrupt: "escape",
|
||||
clear: "ctrl+c",
|
||||
exit: "ctrl+d",
|
||||
suspend: "ctrl+z",
|
||||
cycleThinkingLevel: "shift+tab",
|
||||
cycleModelForward: "ctrl+p",
|
||||
cycleModelBackward: "shift+ctrl+p",
|
||||
selectModel: "ctrl+l",
|
||||
togglePlanMode: "alt+shift+p",
|
||||
historySearch: "ctrl+r",
|
||||
expandTools: "ctrl+o",
|
||||
toggleThinking: "ctrl+t",
|
||||
externalEditor: "ctrl+g",
|
||||
followUp: "ctrl+enter",
|
||||
dequeue: "alt+up",
|
||||
pasteImage: "ctrl+v",
|
||||
copyLine: "alt+shift+l",
|
||||
copyPrompt: "alt+shift+c",
|
||||
newSession: [],
|
||||
tree: [],
|
||||
fork: [],
|
||||
resume: [],
|
||||
toggleSTT: "alt+h",
|
||||
};
|
||||
function isLegacyKeybindingName(key: string): key is keyof typeof KEYBINDING_NAME_MIGRATIONS {
|
||||
return key in KEYBINDING_NAME_MIGRATIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* All default keybindings (app + editor).
|
||||
* Normalize input to KeybindingsConfig, validating types.
|
||||
*/
|
||||
export const DEFAULT_KEYBINDINGS: Required<KeybindingsConfig> = {
|
||||
...DEFAULT_EDITOR_KEYBINDINGS,
|
||||
...DEFAULT_APP_KEYBINDINGS,
|
||||
};
|
||||
function toKeybindingsConfig(value: unknown): KeybindingsConfig {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// App actions list for type checking
|
||||
const APP_ACTIONS: AppAction[] = [
|
||||
"interrupt",
|
||||
"clear",
|
||||
"exit",
|
||||
"suspend",
|
||||
"cycleThinkingLevel",
|
||||
"cycleModelForward",
|
||||
"cycleModelBackward",
|
||||
"selectModel",
|
||||
"togglePlanMode",
|
||||
"historySearch",
|
||||
"expandTools",
|
||||
"toggleThinking",
|
||||
"externalEditor",
|
||||
"followUp",
|
||||
"dequeue",
|
||||
"pasteImage",
|
||||
"copyLine",
|
||||
"copyPrompt",
|
||||
"newSession",
|
||||
"tree",
|
||||
"fork",
|
||||
"resume",
|
||||
"toggleSTT",
|
||||
];
|
||||
const config: KeybindingsConfig = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
// Allow undefined, string (KeyId), or array of strings
|
||||
if (val === undefined) {
|
||||
config[key] = undefined;
|
||||
} else if (typeof val === "string") {
|
||||
config[key] = val as KeyId;
|
||||
} else if (Array.isArray(val) && val.every(v => typeof v === "string")) {
|
||||
config[key] = val as string[] as KeyId[];
|
||||
}
|
||||
// Silently skip invalid entries
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function isAppAction(action: string): action is AppAction {
|
||||
return APP_ACTIONS.includes(action as AppAction);
|
||||
/**
|
||||
* Migrate old keybinding names to new namespaced IDs.
|
||||
* Returns both the migrated config and a flag indicating if migration occurred.
|
||||
*/
|
||||
function migrateKeybindingNames(rawConfig: unknown): {
|
||||
config: KeybindingsConfig;
|
||||
migrated: boolean;
|
||||
} {
|
||||
const config = toKeybindingsConfig(rawConfig);
|
||||
const migrated: KeybindingsConfig = {};
|
||||
let didMigrate = false;
|
||||
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (isLegacyKeybindingName(key)) {
|
||||
const newKey = KEYBINDING_NAME_MIGRATIONS[key];
|
||||
migrated[newKey] = value;
|
||||
didMigrate = true;
|
||||
} else {
|
||||
// Already a new-style key
|
||||
migrated[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { config: migrated, migrated: didMigrate };
|
||||
}
|
||||
|
||||
/**
|
||||
* Order keybindings config to match KEYBINDINGS key order.
|
||||
*/
|
||||
function orderKeybindingsConfig(config: KeybindingsConfig): KeybindingsConfig {
|
||||
const ordered: KeybindingsConfig = {};
|
||||
for (const key of Object.keys(KEYBINDINGS)) {
|
||||
const value = config[key];
|
||||
if (value !== undefined) {
|
||||
ordered[key] = value;
|
||||
}
|
||||
}
|
||||
// Add any remaining keys that aren't in KEYBINDINGS
|
||||
for (const key of Object.keys(config)) {
|
||||
if (!(key in ordered)) {
|
||||
ordered[key] = config[key];
|
||||
}
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load raw config from a file synchronously.
|
||||
* Returns parsed JSON or null if file doesn't exist or is invalid.
|
||||
*/
|
||||
function loadRawConfig(filePath: string): unknown {
|
||||
try {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) {
|
||||
return null;
|
||||
}
|
||||
logger.warn("Failed to parse keybindings config", { path: filePath, error: String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate keybindings config file from old format to new.
|
||||
* Reads from agentDir/keybindings.json, migrates old names, and writes back.
|
||||
*/
|
||||
function loadKeybindingsConfig(filePath: string, writeBack: boolean): KeybindingsConfig {
|
||||
const rawConfig = loadRawConfig(filePath);
|
||||
|
||||
if (rawConfig === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const { config: migratedConfig, migrated } = migrateKeybindingNames(rawConfig);
|
||||
if (writeBack && migrated) {
|
||||
const ordered = orderKeybindingsConfig(migratedConfig);
|
||||
try {
|
||||
writeFileSync(filePath, `${JSON.stringify(ordered, null, 2)}\n`, "utf-8");
|
||||
logger.debug("Migrated keybindings config", { path: filePath });
|
||||
} catch (error) {
|
||||
logger.warn("Failed to write migrated keybindings config", { path: filePath, error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
return migratedConfig;
|
||||
}
|
||||
|
||||
function migrateKeybindingsConfigFile(agentDir: string): void {
|
||||
const configPath = path.join(agentDir, "keybindings.json");
|
||||
loadKeybindingsConfig(configPath, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages all keybindings (app + TUI).
|
||||
* Extends the TUI KeybindingsManager with app-specific functionality.
|
||||
*/
|
||||
export class KeybindingsManager extends TuiKeybindingsManager {
|
||||
#configPath: string | undefined;
|
||||
|
||||
constructor(userBindings: KeybindingsConfig = {}, configPath?: string) {
|
||||
super(KEYBINDINGS, userBindings);
|
||||
this.#configPath = configPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from config file at agentDir/keybindings.json.
|
||||
*/
|
||||
static create(agentDir: string = getAgentDir()): KeybindingsManager {
|
||||
const configPath = path.join(agentDir, "keybindings.json");
|
||||
const userBindings = KeybindingsManager.#loadFromFile(configPath);
|
||||
const manager = new KeybindingsManager(userBindings, configPath);
|
||||
// Set globally so getKeybindings() returns this manager
|
||||
setKeybindings(manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an in-memory keybindings manager without file persistence.
|
||||
*/
|
||||
static inMemory(userBindings: KeybindingsConfig = {}): KeybindingsManager {
|
||||
return new KeybindingsManager(userBindings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload keybindings from the config file.
|
||||
*/
|
||||
reload(): void {
|
||||
if (!this.#configPath) return;
|
||||
this.setUserBindings(KeybindingsManager.#loadFromFile(this.#configPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective resolved bindings (defaults + user overrides).
|
||||
*/
|
||||
getEffectiveConfig(): KeybindingsConfig {
|
||||
return this.getResolvedBindings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display string for a keybinding (e.g., "ctrl+c/escape").
|
||||
*/
|
||||
getDisplayString(keybinding: Keybinding): string {
|
||||
const keys = this.getKeys(keybinding);
|
||||
return formatKeyHints(keys.length === 0 ? [] : keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user bindings from a file, migrating old names if needed.
|
||||
*/
|
||||
static #loadFromFile(filePath: string): KeybindingsConfig {
|
||||
return loadKeybindingsConfig(filePath, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,8 +463,6 @@ const KEY_LABELS: Record<string, string> = {
|
||||
right: "Right",
|
||||
};
|
||||
|
||||
const normalizeKeyId = (key: KeyId): KeyId => key.toLowerCase() as KeyId;
|
||||
|
||||
function formatKeyPart(part: string): string {
|
||||
const lower = part.toLowerCase();
|
||||
const modifier = MODIFIER_LABELS[lower];
|
||||
@@ -166,116 +482,5 @@ export function formatKeyHints(keys: KeyId | KeyId[]): string {
|
||||
return list.map(formatKeyHint).join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages all keybindings (app + editor).
|
||||
*/
|
||||
export class KeybindingsManager {
|
||||
#appActionToKeys: Map<AppAction, KeyId[]>;
|
||||
|
||||
private constructor(private readonly config: KeybindingsConfig) {
|
||||
this.#appActionToKeys = new Map();
|
||||
this.#buildMaps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from config file and set up editor keybindings.
|
||||
*/
|
||||
static async create(agentDir: string = getAgentDir()): Promise<KeybindingsManager> {
|
||||
const configPath = path.join(agentDir, "keybindings.json");
|
||||
const config = await KeybindingsManager.#loadFromFile(configPath);
|
||||
const manager = new KeybindingsManager(config);
|
||||
|
||||
// Set up editor keybindings globally
|
||||
const editorConfig: EditorKeybindingsConfig = {};
|
||||
for (const [action, keys] of Object.entries(config)) {
|
||||
if (!isAppAction(action)) {
|
||||
editorConfig[action as EditorAction] = keys;
|
||||
}
|
||||
}
|
||||
setEditorKeybindings(new EditorKeybindingsManager(editorConfig));
|
||||
|
||||
return manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create in-memory.
|
||||
*/
|
||||
static inMemory(config: KeybindingsConfig = {}): KeybindingsManager {
|
||||
return new KeybindingsManager(config);
|
||||
}
|
||||
|
||||
static async #loadFromFile(path: string): Promise<KeybindingsConfig> {
|
||||
try {
|
||||
return await Bun.file(path).json();
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) return {};
|
||||
logger.warn("Failed to parse keybindings config", { path, error: String(error) });
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
#buildMaps(): void {
|
||||
this.#appActionToKeys.clear();
|
||||
|
||||
// Set defaults for app actions
|
||||
for (const [action, keys] of Object.entries(DEFAULT_APP_KEYBINDINGS)) {
|
||||
const keyArray = Array.isArray(keys) ? keys : [keys];
|
||||
this.#appActionToKeys.set(
|
||||
action as AppAction,
|
||||
keyArray.map(key => normalizeKeyId(key as KeyId)),
|
||||
);
|
||||
}
|
||||
|
||||
// Override with user config (app actions only)
|
||||
for (const [action, keys] of Object.entries(this.config)) {
|
||||
if (keys === undefined || !isAppAction(action)) continue;
|
||||
const keyArray = Array.isArray(keys) ? keys : [keys];
|
||||
this.#appActionToKeys.set(
|
||||
action,
|
||||
keyArray.map(key => normalizeKeyId(key as KeyId)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if input matches an app action.
|
||||
*/
|
||||
matches(data: string, action: AppAction): boolean {
|
||||
const keys = this.#appActionToKeys.get(action);
|
||||
if (!keys) return false;
|
||||
for (const key of keys) {
|
||||
if (matchesKey(data, key)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get keys bound to an app action.
|
||||
*/
|
||||
getKeys(action: AppAction): KeyId[] {
|
||||
return this.#appActionToKeys.get(action) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display string for an action.
|
||||
*/
|
||||
getDisplayString(action: AppAction): string {
|
||||
return formatKeyHints(this.getKeys(action));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full effective config.
|
||||
*/
|
||||
getEffectiveConfig(): Required<KeybindingsConfig> {
|
||||
const result = { ...DEFAULT_KEYBINDINGS };
|
||||
for (const [action, keys] of Object.entries(this.config)) {
|
||||
if (keys !== undefined) {
|
||||
(result as KeybindingsConfig)[action as KeyAction] = keys;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
export type { EditorAction, KeyId };
|
||||
export type { Keybinding, KeybindingsConfig, KeyId };
|
||||
export { migrateKeybindingsConfigFile };
|
||||
|
||||
@@ -90,6 +90,7 @@ const OpenAICompatSchema = Type.Object({
|
||||
thinkingFormat: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("openai"),
|
||||
Type.Literal("openrouter"),
|
||||
Type.Literal("zai"),
|
||||
Type.Literal("qwen"),
|
||||
Type.Literal("qwen-chat-template"),
|
||||
|
||||
@@ -141,6 +141,55 @@ function isAlias(id: string): boolean {
|
||||
return !datePattern.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an exact model reference match.
|
||||
* Supports either a bare model id or a canonical provider/modelId reference.
|
||||
* When matching by bare id, ambiguous matches across providers are rejected.
|
||||
*/
|
||||
export function findExactModelReferenceMatch(
|
||||
modelReference: string,
|
||||
availableModels: Model<Api>[],
|
||||
): Model<Api> | undefined {
|
||||
const trimmedReference = modelReference.trim();
|
||||
if (!trimmedReference) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedReference = trimmedReference.toLowerCase();
|
||||
|
||||
const canonicalMatches = availableModels.filter(
|
||||
model => `${model.provider}/${model.id}`.toLowerCase() === normalizedReference,
|
||||
);
|
||||
if (canonicalMatches.length === 1) {
|
||||
return canonicalMatches[0];
|
||||
}
|
||||
if (canonicalMatches.length > 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const slashIndex = trimmedReference.indexOf("/");
|
||||
if (slashIndex !== -1) {
|
||||
const provider = trimmedReference.substring(0, slashIndex).trim();
|
||||
const modelId = trimmedReference.substring(slashIndex + 1).trim();
|
||||
if (provider && modelId) {
|
||||
const providerMatches = availableModels.filter(
|
||||
model =>
|
||||
model.provider.toLowerCase() === provider.toLowerCase() &&
|
||||
model.id.toLowerCase() === modelId.toLowerCase(),
|
||||
);
|
||||
if (providerMatches.length === 1) {
|
||||
return providerMatches[0];
|
||||
}
|
||||
if (providerMatches.length > 1) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const idMatches = availableModels.filter(model => model.id.toLowerCase() === normalizedReference);
|
||||
return idMatches.length === 1 ? idMatches[0] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to match a pattern to a model from the available models list.
|
||||
* Returns the matched model or undefined if no match found.
|
||||
@@ -150,17 +199,17 @@ function tryMatchModel(
|
||||
availableModels: Model<Api>[],
|
||||
context: ModelPreferenceContext,
|
||||
): Model<Api> | undefined {
|
||||
// Check for provider/modelId format (provider is everything before the first /)
|
||||
// Try exact reference match first (handles provider/modelId and bare id with ambiguity rejection)
|
||||
const exactRefMatch = findExactModelReferenceMatch(modelPattern, availableModels);
|
||||
if (exactRefMatch) {
|
||||
return exactRefMatch;
|
||||
}
|
||||
|
||||
// Check for provider/modelId format — fuzzy match within provider
|
||||
const slashIndex = modelPattern.indexOf("/");
|
||||
if (slashIndex !== -1) {
|
||||
const provider = modelPattern.substring(0, slashIndex);
|
||||
const modelId = modelPattern.substring(slashIndex + 1);
|
||||
const providerMatch = availableModels.find(
|
||||
m => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase(),
|
||||
);
|
||||
if (providerMatch) {
|
||||
return providerMatch;
|
||||
}
|
||||
|
||||
const providerModels = availableModels.filter(m => m.provider.toLowerCase() === provider.toLowerCase());
|
||||
if (providerModels.length > 0) {
|
||||
@@ -187,10 +236,9 @@ function tryMatchModel(
|
||||
return scored[0]?.model;
|
||||
}
|
||||
}
|
||||
// No exact provider/model match - fall through to other matching
|
||||
}
|
||||
|
||||
// Check for exact ID match (case-insensitive)
|
||||
// Exact ID match (case-insensitive) — with ambiguity across providers handled by preference
|
||||
const exactMatches = availableModels.filter(m => m.id.toLowerCase() === modelPattern.toLowerCase());
|
||||
if (exactMatches.length > 0) {
|
||||
return pickPreferredModel(exactMatches, context);
|
||||
|
||||
@@ -139,6 +139,43 @@ type SettingDef =
|
||||
// under `as const` while still letting SettingValue infer the correct element type.
|
||||
const EMPTY_STRING_ARRAY: string[] = [];
|
||||
const EMPTY_STRING_RECORD: Record<string, string> = {};
|
||||
export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
|
||||
{
|
||||
pattern: "^\\s*(cat|head|tail|less|more)\\s+",
|
||||
tool: "read",
|
||||
message: "Use the `read` tool instead of cat/head/tail. It provides better context and handles binary files.",
|
||||
},
|
||||
{
|
||||
pattern: "^\\s*(grep|rg|ripgrep|ag|ack)\\s+",
|
||||
tool: "grep",
|
||||
message: "Use the `grep` tool instead of grep/rg. It respects .gitignore and provides structured output.",
|
||||
},
|
||||
{
|
||||
pattern: "^\\s*(find|fd|locate)\\s+.*(-name|-iname|-type|--type|-glob)",
|
||||
tool: "find",
|
||||
message: "Use the `find` tool instead of find/fd. It respects .gitignore and is faster for glob patterns.",
|
||||
},
|
||||
{
|
||||
pattern: "^\\s*sed\\s+(-i|--in-place)",
|
||||
tool: "edit",
|
||||
message: "Use the `edit` tool instead of sed -i. It provides diff preview and fuzzy matching.",
|
||||
},
|
||||
{
|
||||
pattern: "^\\s*perl\\s+.*-[pn]?i",
|
||||
tool: "edit",
|
||||
message: "Use the `edit` tool instead of perl -i. It provides diff preview and fuzzy matching.",
|
||||
},
|
||||
{
|
||||
pattern: "^\\s*awk\\s+.*-i\\s+inplace",
|
||||
tool: "edit",
|
||||
message: "Use the `edit` tool instead of awk -i inplace. It provides diff preview and fuzzy matching.",
|
||||
},
|
||||
{
|
||||
pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+.*[^|]>\\s*\\S",
|
||||
tool: "write",
|
||||
message: "Use the `write` tool instead of echo/cat redirection. It handles encoding and provides confirmation.",
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_SCHEMA = {
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
@@ -943,16 +980,7 @@ export const SETTINGS_SCHEMA = {
|
||||
default: false,
|
||||
ui: { tab: "editing", label: "Bash Interceptor", description: "Block shell commands that have dedicated tools" },
|
||||
},
|
||||
|
||||
"bashInterceptor.simpleLs": {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
ui: {
|
||||
tab: "editing",
|
||||
label: "Intercept `ls`",
|
||||
description: "Intercept bare ls commands (when interceptor is enabled)",
|
||||
},
|
||||
},
|
||||
"bashInterceptor.patterns": { type: "array", default: DEFAULT_BASH_INTERCEPTOR_RULES },
|
||||
|
||||
// Python
|
||||
"python.toolMode": {
|
||||
|
||||
@@ -341,10 +341,7 @@ export class Settings {
|
||||
* Get bash interceptor rules (typed accessor for complex array config).
|
||||
*/
|
||||
getBashInterceptorRules(): BashInterceptorRule[] {
|
||||
const patterns = (this.#merged.bashInterceptor as { patterns?: unknown[] })?.patterns;
|
||||
if (!Array.isArray(patterns)) return [];
|
||||
|
||||
return patterns.filter((p): p is BashInterceptorRule => typeof p === "object" && p !== null && "pattern" in p);
|
||||
return this.get("bashInterceptor.patterns");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
:root {
|
||||
--line-height: 18px; /* 12px font * 1.5 */
|
||||
--sidebar-width: 400px;
|
||||
--sidebar-min-width: 240px;
|
||||
--sidebar-max-width: 840px;
|
||||
--sidebar-resizer-width: 6px;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -12,6 +16,11 @@
|
||||
background: var(--body-bg);
|
||||
}
|
||||
|
||||
body.sidebar-resizing {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
@@ -19,7 +28,9 @@
|
||||
|
||||
/* Sidebar */
|
||||
#sidebar {
|
||||
width: 400px;
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
max-width: var(--sidebar-width);
|
||||
background: var(--container-bg);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
@@ -203,8 +214,28 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#sidebar-resizer {
|
||||
width: var(--sidebar-resizer-width);
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
background: transparent;
|
||||
border-right: 1px solid transparent;
|
||||
}
|
||||
|
||||
#sidebar-resizer:hover,
|
||||
body.sidebar-resizing #sidebar-resizer {
|
||||
background: var(--selectedBg);
|
||||
border-right-color: var(--dim);
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
#content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--line-height) calc(var(--line-height) * 2);
|
||||
@@ -841,17 +872,19 @@
|
||||
@media (max-width: 900px) {
|
||||
#sidebar {
|
||||
position: fixed;
|
||||
left: -400px;
|
||||
width: 400px;
|
||||
transform: translateX(-100%);
|
||||
width: min(var(--sidebar-width), 100vw);
|
||||
min-width: 0;
|
||||
max-width: 100vw;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
height: 100vh;
|
||||
z-index: 99;
|
||||
transition: left 0.3s;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
#sidebar.open {
|
||||
left: 0;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
#sidebar-overlay.open {
|
||||
@@ -866,6 +899,10 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
#sidebar-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#content {
|
||||
padding: var(--line-height) 16px;
|
||||
}
|
||||
@@ -875,15 +912,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
#sidebar {
|
||||
width: 100vw;
|
||||
left: -100vw;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
#sidebar, #sidebar-toggle { display: none !important; }
|
||||
#sidebar, #sidebar-toggle, #sidebar-resizer { display: none !important; }
|
||||
body { background: white; color: black; }
|
||||
#content { max-width: none; }
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -28,6 +28,7 @@
|
||||
<div class="tree-container" id="tree-container"></div>
|
||||
<div class="tree-status" id="tree-status"></div>
|
||||
</aside>
|
||||
<div id="sidebar-resizer" role="separator" aria-orientation="vertical" aria-label="Resize session tree sidebar"></div>
|
||||
<main id="content">
|
||||
<div id="header-container"></div>
|
||||
<div id="messages"></div>
|
||||
|
||||
@@ -1279,6 +1279,113 @@
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.getElementById('sidebar-overlay');
|
||||
const hamburger = document.getElementById('hamburger');
|
||||
const sidebarResizer = document.getElementById('sidebar-resizer');
|
||||
const SIDEBAR_WIDTH_STORAGE_KEY = 'pi-share:v1:sidebar-width';
|
||||
const MIN_CONTENT_WIDTH = 320;
|
||||
|
||||
function isMobileLayout() {
|
||||
return window.matchMedia('(max-width: 900px)').matches;
|
||||
}
|
||||
|
||||
function getSidebarBounds() {
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
const minWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-min-width')) || 240;
|
||||
const maxWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-max-width')) || 720;
|
||||
const viewportMaxWidth = window.innerWidth - MIN_CONTENT_WIDTH;
|
||||
return {
|
||||
minWidth,
|
||||
maxWidth: Math.max(minWidth, Math.min(maxWidth, viewportMaxWidth))
|
||||
};
|
||||
}
|
||||
|
||||
function clampSidebarWidth(width) {
|
||||
const { minWidth, maxWidth } = getSidebarBounds();
|
||||
return Math.max(minWidth, Math.min(maxWidth, width));
|
||||
}
|
||||
|
||||
function applySidebarWidth(width) {
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${Math.round(clampSidebarWidth(width))}px`);
|
||||
}
|
||||
|
||||
function loadSidebarWidth() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY);
|
||||
if (raw === null) return null;
|
||||
const width = Number(raw);
|
||||
return Number.isFinite(width) ? width : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSidebarWidth(width) {
|
||||
try {
|
||||
localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(clampSidebarWidth(width))));
|
||||
} catch {
|
||||
// Ignore storage failures (e.g. private browsing restrictions)
|
||||
}
|
||||
}
|
||||
|
||||
function setupSidebarResize() {
|
||||
const savedWidth = loadSidebarWidth();
|
||||
if (savedWidth !== null) {
|
||||
applySidebarWidth(savedWidth);
|
||||
}
|
||||
|
||||
if (!sidebarResizer) return;
|
||||
|
||||
let cleanupDrag = null;
|
||||
|
||||
const stopDrag = (pointerId) => {
|
||||
if (cleanupDrag) {
|
||||
cleanupDrag(pointerId);
|
||||
cleanupDrag = null;
|
||||
}
|
||||
};
|
||||
|
||||
sidebarResizer.addEventListener('pointerdown', (e) => {
|
||||
if (isMobileLayout()) return;
|
||||
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth = sidebar.getBoundingClientRect().width;
|
||||
document.body.classList.add('sidebar-resizing');
|
||||
sidebarResizer.setPointerCapture?.(e.pointerId);
|
||||
|
||||
const onPointerMove = (event) => {
|
||||
applySidebarWidth(startWidth + (event.clientX - startX));
|
||||
};
|
||||
|
||||
cleanupDrag = (pointerIdToRelease) => {
|
||||
document.body.classList.remove('sidebar-resizing');
|
||||
sidebarResizer.releasePointerCapture?.(pointerIdToRelease);
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', onPointerUp);
|
||||
window.removeEventListener('pointercancel', onPointerCancel);
|
||||
saveSidebarWidth(sidebar.getBoundingClientRect().width);
|
||||
};
|
||||
|
||||
const onPointerUp = (event) => stopDrag(event.pointerId);
|
||||
const onPointerCancel = (event) => stopDrag(event.pointerId);
|
||||
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
window.addEventListener('pointerup', onPointerUp);
|
||||
window.addEventListener('pointercancel', onPointerCancel);
|
||||
});
|
||||
|
||||
sidebarResizer.addEventListener('dblclick', () => {
|
||||
if (isMobileLayout()) return;
|
||||
applySidebarWidth(400);
|
||||
saveSidebarWidth(400);
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (isMobileLayout()) return;
|
||||
applySidebarWidth(sidebar.getBoundingClientRect().width);
|
||||
});
|
||||
}
|
||||
|
||||
setupSidebarResize();
|
||||
|
||||
hamburger.addEventListener('click', () => {
|
||||
sidebar.classList.add('open');
|
||||
|
||||
@@ -56,7 +56,7 @@ import type { TodoItem } from "../../tools/todo-write";
|
||||
import type { EventBus } from "../../utils/event-bus";
|
||||
import type { SlashCommandInfo } from "../slash-commands";
|
||||
|
||||
export type { AppAction, KeybindingsManager } from "../../config/keybindings";
|
||||
export type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
|
||||
export type { ExecOptions, ExecResult } from "../../exec/exec";
|
||||
export type { AgentToolResult, AgentToolUpdateCallback };
|
||||
|
||||
@@ -87,6 +87,16 @@ export interface ExtensionUIDialogOptions {
|
||||
/** Raw terminal input listener for extensions. */
|
||||
export type TerminalInputHandler = (data: string) => { consume?: boolean; data?: string } | undefined;
|
||||
|
||||
export type WidgetPlacement = "aboveEditor" | "belowEditor";
|
||||
|
||||
export interface ExtensionWidgetOptions {
|
||||
placement?: WidgetPlacement;
|
||||
}
|
||||
|
||||
export type ExtensionUiComponent = Component & { dispose?(): void };
|
||||
export type ExtensionUiComponentFactory = (tui: TUI, theme: Theme) => ExtensionUiComponent;
|
||||
export type ExtensionWidgetContent = string[] | ExtensionUiComponentFactory | undefined;
|
||||
|
||||
/**
|
||||
* UI context for extensions to request interactive UI.
|
||||
* Each mode (interactive, RPC, print) provides its own implementation.
|
||||
@@ -113,15 +123,14 @@ export interface ExtensionUIContext {
|
||||
/** Set the working/loading message shown during streaming. Call with no argument to restore default. */
|
||||
setWorkingMessage(message?: string): void;
|
||||
|
||||
/** Set a widget to display above the editor. Accepts string array or component factory. */
|
||||
setWidget(key: string, content: string[] | undefined): void;
|
||||
setWidget(key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;
|
||||
/** Set a widget to display above or below the editor. Accepts string array or component factory. */
|
||||
setWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void;
|
||||
|
||||
/** Set a custom footer component, or undefined to restore the built-in footer. */
|
||||
setFooter(factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;
|
||||
setFooter(factory: ExtensionUiComponentFactory | undefined): void;
|
||||
|
||||
/** Set a custom header component, or undefined to restore the built-in header. */
|
||||
setHeader(factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;
|
||||
setHeader(factory: ExtensionUiComponentFactory | undefined): void;
|
||||
|
||||
/** Set the terminal window/tab title. */
|
||||
setTitle(title: string): void;
|
||||
@@ -133,7 +142,7 @@ export interface ExtensionUIContext {
|
||||
theme: Theme,
|
||||
keybindings: KeybindingsManager,
|
||||
done: (result: T) => void,
|
||||
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
|
||||
) => ExtensionUiComponent | Promise<ExtensionUiComponent>,
|
||||
options?: { overlay?: boolean },
|
||||
): Promise<T>;
|
||||
|
||||
@@ -303,6 +312,9 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
|
||||
parameters: TParams;
|
||||
/** If true, tool is excluded unless explicitly listed in --tools or agent's tools field */
|
||||
hidden?: boolean;
|
||||
/** If true, tool is registered but not auto-included in the initial active set.
|
||||
* The registering extension is responsible for activating/deactivating it via setActiveTools(). */
|
||||
defaultInactive?: boolean;
|
||||
/** If true, tool may stage deferred changes that require explicit resolve/discard. */
|
||||
deferrable?: boolean;
|
||||
/** MCP server name for discovery/search metadata when this tool fronts an MCP server. */
|
||||
@@ -1047,7 +1059,13 @@ export interface ExtensionAPI {
|
||||
// Actions
|
||||
// =========================================================================
|
||||
|
||||
/** Send a custom message to the session. */
|
||||
/**
|
||||
* Send a custom message to the session.
|
||||
*
|
||||
* `deliverAs: "nextTurn"` keeps the message hidden from the editable pending-message UI.
|
||||
* If `triggerTurn` is also true while the current turn is still unwinding, the session schedules
|
||||
* an internal continuation that consumes the message on the next turn.
|
||||
*/
|
||||
sendMessage<T = unknown>(
|
||||
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
|
||||
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
||||
@@ -1223,6 +1241,11 @@ type HandlerFn = (...args: unknown[]) => Promise<unknown>;
|
||||
|
||||
export type SendMessageHandler = <T = unknown>(
|
||||
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
|
||||
/**
|
||||
* `deliverAs: "nextTurn"` queues hidden custom context for the next turn.
|
||||
* When paired with `triggerTurn: true` during prompt teardown, the session schedules
|
||||
* an internal continuation without surfacing the message in the editable pending queue.
|
||||
*/
|
||||
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
||||
) => void;
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { $env, getProjectDir, logger, postmortem, setProjectDir, VERSION } from
|
||||
import chalk from "chalk";
|
||||
import type { Args } from "./cli/args";
|
||||
import { processFileArguments } from "./cli/file-processor";
|
||||
import { buildInitialMessage } from "./cli/initial-message";
|
||||
import { listModels } from "./cli/list-models";
|
||||
import { selectSession } from "./cli/session-picker";
|
||||
import { findConfigFile } from "./config";
|
||||
@@ -137,7 +138,7 @@ async function runInteractiveMode(
|
||||
}
|
||||
}
|
||||
|
||||
if (initialMessage) {
|
||||
if (initialMessage !== undefined) {
|
||||
try {
|
||||
await session.prompt(initialMessage, { images: initialImages });
|
||||
} catch (error: unknown) {
|
||||
@@ -161,33 +162,6 @@ async function runInteractiveMode(
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareInitialMessage(
|
||||
parsed: Args,
|
||||
autoResizeImages: boolean,
|
||||
): Promise<{
|
||||
initialMessage?: string;
|
||||
initialImages?: ImageContent[];
|
||||
}> {
|
||||
if (parsed.fileArgs.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const { text, images } = await processFileArguments(parsed.fileArgs, { autoResizeImages });
|
||||
|
||||
let initialMessage: string;
|
||||
if (parsed.messages.length > 0) {
|
||||
initialMessage = text + parsed.messages[0];
|
||||
parsed.messages.shift();
|
||||
} else {
|
||||
initialMessage = text;
|
||||
}
|
||||
|
||||
return {
|
||||
initialMessage,
|
||||
initialImages: images.length > 0 ? images : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePathForComparison(value: string): string {
|
||||
const resolved = path.resolve(value);
|
||||
let realPath = resolved;
|
||||
@@ -237,6 +211,21 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
|
||||
}
|
||||
|
||||
async function createSessionManager(parsed: Args, cwd: string): Promise<SessionManager | undefined> {
|
||||
if (parsed.fork) {
|
||||
if (parsed.noSession) {
|
||||
throw new Error("--fork requires session persistence");
|
||||
}
|
||||
const forkSource = parsed.fork;
|
||||
if (forkSource.includes("/") || forkSource.includes("\\") || forkSource.endsWith(".jsonl")) {
|
||||
return await SessionManager.forkFrom(forkSource, cwd, parsed.sessionDir);
|
||||
}
|
||||
const match = await resolveResumableSession(forkSource, cwd, parsed.sessionDir);
|
||||
if (!match) {
|
||||
throw new Error(`Session "${forkSource}" not found.`);
|
||||
}
|
||||
return await SessionManager.forkFrom(match.session.path, cwd, parsed.sessionDir);
|
||||
}
|
||||
|
||||
if (parsed.noSession) {
|
||||
return SessionManager.inMemory();
|
||||
}
|
||||
@@ -374,6 +363,9 @@ async function buildSessionOptions(
|
||||
if (sessionManager) {
|
||||
options.sessionManager = sessionManager;
|
||||
}
|
||||
if (parsed.providerSessionId) {
|
||||
options.providerSessionId = parsed.providerSessionId;
|
||||
}
|
||||
|
||||
// Model from CLI
|
||||
// - supports --provider <name> --model <pattern>
|
||||
@@ -565,22 +557,27 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
||||
if (parsedArgs.noPty) {
|
||||
Bun.env.PI_NO_PTY = "1";
|
||||
}
|
||||
const {
|
||||
pipedInput,
|
||||
initialMessage: initMsg,
|
||||
initialImages,
|
||||
} = await logger.timeAsync("prepareInitialMessage", async () => {
|
||||
const { pipedInput, fileText, fileImages } = await logger.timeAsync("prepareInitialMessage", async () => {
|
||||
const pipedInput = await readPipedInput();
|
||||
let { initialMessage, initialImages } = await prepareInitialMessage(
|
||||
parsedArgs,
|
||||
settings.get("images.autoResize"),
|
||||
);
|
||||
if (pipedInput) {
|
||||
initialMessage = initialMessage ? `${initialMessage}\n${pipedInput}` : pipedInput;
|
||||
if (parsedArgs.fileArgs.length === 0) {
|
||||
return { pipedInput };
|
||||
}
|
||||
return { pipedInput, initialMessage, initialImages };
|
||||
|
||||
const { text, images } = await processFileArguments(parsedArgs.fileArgs, {
|
||||
autoResizeImages: settings.get("images.autoResize"),
|
||||
});
|
||||
return {
|
||||
pipedInput,
|
||||
fileText: text,
|
||||
fileImages: images,
|
||||
};
|
||||
});
|
||||
const { initialMessage, initialImages } = buildInitialMessage({
|
||||
parsed: parsedArgs,
|
||||
fileText,
|
||||
fileImages,
|
||||
stdinContent: pipedInput,
|
||||
});
|
||||
const initialMessage = initMsg;
|
||||
const autoPrint = pipedInput !== undefined && !parsedArgs.print && parsedArgs.mode === undefined;
|
||||
const isInteractive = !parsedArgs.print && !autoPrint && parsedArgs.mode === undefined;
|
||||
const mode = parsedArgs.mode || "text";
|
||||
@@ -626,7 +623,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
||||
let sessionManager = await logger.timeAsync("createSessionManager", () => createSessionManager(parsedArgs, cwd));
|
||||
|
||||
// Handle --resume (no value): show session picker
|
||||
if (parsedArgs.resume === true) {
|
||||
if (parsedArgs.resume === true && !parsedArgs.fork) {
|
||||
const sessions = await logger.timeAsync("SessionManager.list", () =>
|
||||
SessionManager.list(cwd, parsedArgs.sessionDir),
|
||||
);
|
||||
|
||||
@@ -50,6 +50,7 @@ import { discoverAgents } from "../../task/discovery";
|
||||
import type { AgentDefinition, AgentSource } from "../../task/types";
|
||||
import { shortenPath } from "../../tools/render-utils";
|
||||
import { theme } from "../theme/theme";
|
||||
import { matchesAppInterrupt } from "../utils/keybinding-matchers";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
|
||||
type SourceTabId = "all" | AgentSource;
|
||||
@@ -993,7 +994,7 @@ export class AgentDashboard extends Container {
|
||||
}
|
||||
|
||||
if (this.#createSpec) {
|
||||
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
|
||||
if (matchesAppInterrupt(data)) {
|
||||
this.#clearCreateFlow();
|
||||
this.#buildLayout();
|
||||
return;
|
||||
@@ -1017,7 +1018,7 @@ export class AgentDashboard extends Container {
|
||||
}
|
||||
|
||||
if (this.#createInput || this.#createGenerating) {
|
||||
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
|
||||
if (matchesAppInterrupt(data)) {
|
||||
if (!this.#createGenerating) {
|
||||
this.#clearCreateFlow();
|
||||
this.#buildLayout();
|
||||
@@ -1037,7 +1038,7 @@ export class AgentDashboard extends Container {
|
||||
}
|
||||
|
||||
if (this.#editInput) {
|
||||
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
|
||||
if (matchesAppInterrupt(data)) {
|
||||
this.#cancelModelEdit();
|
||||
return;
|
||||
}
|
||||
@@ -1048,7 +1049,7 @@ export class AgentDashboard extends Container {
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
|
||||
if (matchesAppInterrupt(data)) {
|
||||
if (this.#searchQuery.length > 0) {
|
||||
this.#searchQuery = "";
|
||||
this.#applyFilters();
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import { Editor, type KeyId, matchesKey, parseKittySequence } from "@oh-my-pi/pi-tui";
|
||||
import type { AppAction } from "../../config/keybindings";
|
||||
import type { AppKeybinding } from "../../config/keybindings";
|
||||
|
||||
type ConfigurableEditorAction = Extract<
|
||||
AppAction,
|
||||
| "interrupt"
|
||||
| "clear"
|
||||
| "exit"
|
||||
| "suspend"
|
||||
| "cycleThinkingLevel"
|
||||
| "cycleModelForward"
|
||||
| "cycleModelBackward"
|
||||
| "selectModel"
|
||||
| "expandTools"
|
||||
| "toggleThinking"
|
||||
| "externalEditor"
|
||||
| "historySearch"
|
||||
| "dequeue"
|
||||
| "pasteImage"
|
||||
| "copyPrompt"
|
||||
AppKeybinding,
|
||||
| "app.interrupt"
|
||||
| "app.clear"
|
||||
| "app.exit"
|
||||
| "app.suspend"
|
||||
| "app.thinking.cycle"
|
||||
| "app.model.cycleForward"
|
||||
| "app.model.cycleBackward"
|
||||
| "app.model.select"
|
||||
| "app.tools.expand"
|
||||
| "app.thinking.toggle"
|
||||
| "app.editor.external"
|
||||
| "app.history.search"
|
||||
| "app.message.dequeue"
|
||||
| "app.clipboard.pasteImage"
|
||||
| "app.clipboard.copyPrompt"
|
||||
>;
|
||||
|
||||
const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
|
||||
interrupt: ["escape"],
|
||||
clear: ["ctrl+c"],
|
||||
exit: ["ctrl+d"],
|
||||
suspend: ["ctrl+z"],
|
||||
cycleThinkingLevel: ["shift+tab"],
|
||||
cycleModelForward: ["ctrl+p"],
|
||||
cycleModelBackward: ["shift+ctrl+p"],
|
||||
selectModel: ["ctrl+l"],
|
||||
expandTools: ["ctrl+o"],
|
||||
toggleThinking: ["ctrl+t"],
|
||||
externalEditor: ["ctrl+g"],
|
||||
historySearch: ["ctrl+r"],
|
||||
dequeue: ["alt+up"],
|
||||
pasteImage: ["ctrl+v"],
|
||||
copyPrompt: ["alt+shift+c"],
|
||||
"app.interrupt": ["escape"],
|
||||
"app.clear": ["ctrl+c"],
|
||||
"app.exit": ["ctrl+d"],
|
||||
"app.suspend": ["ctrl+z"],
|
||||
"app.thinking.cycle": ["shift+tab"],
|
||||
"app.model.cycleForward": ["ctrl+p"],
|
||||
"app.model.cycleBackward": ["shift+ctrl+p"],
|
||||
"app.model.select": ["ctrl+l"],
|
||||
"app.tools.expand": ["ctrl+o"],
|
||||
"app.thinking.toggle": ["ctrl+t"],
|
||||
"app.editor.external": ["ctrl+g"],
|
||||
"app.history.search": ["ctrl+r"],
|
||||
"app.message.dequeue": ["alt+up"],
|
||||
"app.clipboard.pasteImage": ["ctrl+v"],
|
||||
"app.clipboard.copyPrompt": ["alt+shift+c"],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -115,13 +115,13 @@ export class CustomEditor extends Editor {
|
||||
}
|
||||
|
||||
// Intercept configured image paste (async - fires and handles result)
|
||||
if (this.#matchesAction(data, "pasteImage") && this.onPasteImage) {
|
||||
if (this.#matchesAction(data, "app.clipboard.pasteImage") && this.onPasteImage) {
|
||||
void this.onPasteImage();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured external editor shortcut
|
||||
if (this.#matchesAction(data, "externalEditor") && this.onExternalEditor) {
|
||||
if (this.#matchesAction(data, "app.editor.external") && this.onExternalEditor) {
|
||||
this.onExternalEditor();
|
||||
return;
|
||||
}
|
||||
@@ -133,56 +133,56 @@ export class CustomEditor extends Editor {
|
||||
}
|
||||
|
||||
// Intercept configured suspend shortcut
|
||||
if (this.#matchesAction(data, "suspend") && this.onSuspend) {
|
||||
if (this.#matchesAction(data, "app.suspend") && this.onSuspend) {
|
||||
this.onSuspend();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured thinking block visibility toggle
|
||||
if (this.#matchesAction(data, "toggleThinking") && this.onToggleThinking) {
|
||||
if (this.#matchesAction(data, "app.thinking.toggle") && this.onToggleThinking) {
|
||||
this.onToggleThinking();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured model selector shortcut
|
||||
if (this.#matchesAction(data, "selectModel") && this.onSelectModel) {
|
||||
if (this.#matchesAction(data, "app.model.select") && this.onSelectModel) {
|
||||
this.onSelectModel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured history search shortcut
|
||||
if (this.#matchesAction(data, "historySearch") && this.onHistorySearch) {
|
||||
if (this.#matchesAction(data, "app.history.search") && this.onHistorySearch) {
|
||||
this.onHistorySearch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured tool output expansion shortcut
|
||||
if (this.#matchesAction(data, "expandTools") && this.onExpandTools) {
|
||||
if (this.#matchesAction(data, "app.tools.expand") && this.onExpandTools) {
|
||||
this.onExpandTools();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured backward model cycling (check before forward cycling)
|
||||
if (this.#matchesAction(data, "cycleModelBackward") && this.onCycleModelBackward) {
|
||||
if (this.#matchesAction(data, "app.model.cycleBackward") && this.onCycleModelBackward) {
|
||||
this.onCycleModelBackward();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured forward model cycling
|
||||
if (this.#matchesAction(data, "cycleModelForward") && this.onCycleModelForward) {
|
||||
if (this.#matchesAction(data, "app.model.cycleForward") && this.onCycleModelForward) {
|
||||
this.onCycleModelForward();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured thinking level cycling
|
||||
if (this.#matchesAction(data, "cycleThinkingLevel") && this.onCycleThinkingLevel) {
|
||||
if (this.#matchesAction(data, "app.thinking.cycle") && this.onCycleThinkingLevel) {
|
||||
this.onCycleThinkingLevel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured interrupt shortcut.
|
||||
// Default behavior keeps autocomplete dismissal, but parent can prioritize global interrupt handling.
|
||||
if (this.#matchesAction(data, "interrupt") && this.onEscape) {
|
||||
if (this.#matchesAction(data, "app.interrupt") && this.onEscape) {
|
||||
if (!this.isShowingAutocomplete() || this.shouldBypassAutocompleteOnEscape?.()) {
|
||||
this.onEscape();
|
||||
return;
|
||||
@@ -190,13 +190,13 @@ export class CustomEditor extends Editor {
|
||||
}
|
||||
|
||||
// Intercept configured clear shortcut
|
||||
if (this.#matchesAction(data, "clear") && this.onClear) {
|
||||
if (this.#matchesAction(data, "app.clear") && this.onClear) {
|
||||
this.onClear();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured exit shortcut (only when editor is empty)
|
||||
if (this.#matchesAction(data, "exit")) {
|
||||
if (this.#matchesAction(data, "app.exit")) {
|
||||
if (this.getText().length === 0 && this.onExit) {
|
||||
this.onExit();
|
||||
}
|
||||
@@ -205,13 +205,13 @@ export class CustomEditor extends Editor {
|
||||
}
|
||||
|
||||
// Intercept configured dequeue shortcut (restore queued message to editor)
|
||||
if (this.#matchesAction(data, "dequeue") && this.onDequeue) {
|
||||
if (this.#matchesAction(data, "app.message.dequeue") && this.onDequeue) {
|
||||
this.onDequeue();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured copy-prompt shortcut
|
||||
if (this.#matchesAction(data, "copyPrompt") && this.onCopyPrompt) {
|
||||
if (this.#matchesAction(data, "app.clipboard.copyPrompt") && this.onCopyPrompt) {
|
||||
this.onCopyPrompt();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { Settings } from "../../../config/settings";
|
||||
import { DynamicBorder } from "../../../modes/components/dynamic-border";
|
||||
import { theme } from "../../../modes/theme/theme";
|
||||
import { matchesAppInterrupt } from "../../../modes/utils/keybinding-matchers";
|
||||
import { ExtensionList } from "./extension-list";
|
||||
import { InspectorPanel } from "./inspector-panel";
|
||||
import { applyFilter, createInitialState, filterByProvider, refreshState, toggleProvider } from "./state-manager";
|
||||
@@ -251,7 +252,7 @@ export class ExtensionDashboard extends Container {
|
||||
}
|
||||
|
||||
// Escape - clear search first, then close
|
||||
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
|
||||
if (matchesAppInterrupt(data)) {
|
||||
if (this.#state.searchQuery.length > 0) {
|
||||
this.#state.searchQuery = "";
|
||||
this.#state.searchFiltered = this.#state.tabFiltered;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
visibleWidth,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
||||
import type { HistoryEntry, HistoryStorage } from "../../session/history-storage";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
|
||||
@@ -137,7 +138,7 @@ export class HistorySearchComponent extends Container {
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
|
||||
if (matchesAppInterrupt(keyData)) {
|
||||
this.#onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import { Container, Editor, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
|
||||
import { getEditorTheme, theme } from "../../modes/theme/theme";
|
||||
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
||||
import { getEditorCommand, openInEditor } from "../../utils/external-editor";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
|
||||
@@ -117,7 +118,7 @@ export class HookEditorComponent extends Container {
|
||||
}
|
||||
|
||||
// Escape to cancel
|
||||
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
|
||||
if (matchesAppInterrupt(keyData)) {
|
||||
this.#onCancelCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Simple text input component for hooks.
|
||||
*/
|
||||
import { Container, Input, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { Container, Input, Markdown, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
|
||||
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
||||
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
||||
import { CountdownTimer } from "./countdown-timer";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
|
||||
@@ -16,7 +17,7 @@ export class HookInputComponent extends Container {
|
||||
#input: Input;
|
||||
#onSubmitCallback: (value: string) => void;
|
||||
#onCancelCallback: () => void;
|
||||
#titleText: Text;
|
||||
#titleComponent: Markdown;
|
||||
#baseTitle: string;
|
||||
#countdown: CountdownTimer | undefined;
|
||||
|
||||
@@ -36,15 +37,15 @@ export class HookInputComponent extends Container {
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
this.#titleText = new Text(theme.fg("accent", title), 1, 0);
|
||||
this.addChild(this.#titleText);
|
||||
this.#titleComponent = new Markdown(title, 1, 0, getMarkdownTheme(), { color: t => theme.fg("accent", t) });
|
||||
this.addChild(this.#titleComponent);
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
if (opts?.timeout && opts.timeout > 0 && opts.tui) {
|
||||
this.#countdown = new CountdownTimer(
|
||||
opts.timeout,
|
||||
opts.tui,
|
||||
s => this.#titleText.setText(theme.fg("accent", `${this.#baseTitle} (${s}s)`)),
|
||||
s => this.#titleComponent.setText(`${this.#baseTitle} (${s}s)`),
|
||||
() => {
|
||||
opts.onTimeout?.();
|
||||
this.#onCancelCallback();
|
||||
@@ -65,7 +66,7 @@ export class HookInputComponent extends Container {
|
||||
this.#countdown?.reset();
|
||||
if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
|
||||
this.#onSubmitCallback(this.#input.getValue());
|
||||
} else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
|
||||
} else if (matchesAppInterrupt(keyData)) {
|
||||
this.#onCancelCallback();
|
||||
} else {
|
||||
this.#input.handleInput(keyData);
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
*/
|
||||
import {
|
||||
Container,
|
||||
Markdown,
|
||||
matchesKey,
|
||||
padding,
|
||||
renderInlineMarkdown,
|
||||
replaceTabs,
|
||||
Spacer,
|
||||
Text,
|
||||
@@ -13,7 +15,8 @@ import {
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
||||
import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
|
||||
import { CountdownTimer } from "./countdown-timer";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
|
||||
@@ -59,7 +62,7 @@ export class HookSelectorComponent extends Container {
|
||||
#outlinedList: OutlinedList | undefined;
|
||||
#onSelectCallback: (option: string) => void;
|
||||
#onCancelCallback: () => void;
|
||||
#titleText: Text;
|
||||
#titleComponent: Markdown;
|
||||
#baseTitle: string;
|
||||
#countdown: CountdownTimer | undefined;
|
||||
#onLeftCallback: (() => void) | undefined;
|
||||
@@ -85,15 +88,15 @@ export class HookSelectorComponent extends Container {
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
this.#titleText = new Text(theme.fg("accent", title), 1, 0);
|
||||
this.addChild(this.#titleText);
|
||||
this.#titleComponent = new Markdown(title, 1, 0, getMarkdownTheme(), { color: t => theme.fg("accent", t) });
|
||||
this.addChild(this.#titleComponent);
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
if (opts?.timeout && opts.timeout > 0 && opts.tui) {
|
||||
this.#countdown = new CountdownTimer(
|
||||
opts.timeout,
|
||||
opts.tui,
|
||||
s => this.#titleText.setText(theme.fg("accent", `${this.#baseTitle} (${s}s)`)),
|
||||
s => this.#titleComponent.setText(`${this.#baseTitle} (${s}s)`),
|
||||
() => {
|
||||
opts?.onTimeout?.();
|
||||
// Auto-select current option on timeout (typically the first/recommended option)
|
||||
@@ -131,12 +134,14 @@ export class HookSelectorComponent extends Container {
|
||||
);
|
||||
const endIndex = Math.min(startIndex + this.#maxVisible, this.#options.length);
|
||||
|
||||
const mdTheme = getMarkdownTheme();
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const isSelected = i === this.#selectedIndex;
|
||||
const text = isSelected
|
||||
? theme.fg("accent", `${theme.nav.cursor} `) + theme.fg("accent", this.#options[i])
|
||||
: ` ${theme.fg("text", this.#options[i])}`;
|
||||
lines.push(text);
|
||||
const label = isSelected
|
||||
? renderInlineMarkdown(this.#options[i], mdTheme, t => theme.fg("accent", t))
|
||||
: renderInlineMarkdown(this.#options[i], mdTheme, t => theme.fg("text", t));
|
||||
const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
|
||||
lines.push(prefix + label);
|
||||
}
|
||||
|
||||
if (startIndex > 0 || endIndex < this.#options.length) {
|
||||
@@ -169,7 +174,7 @@ export class HookSelectorComponent extends Container {
|
||||
this.#onLeftCallback?.();
|
||||
} else if (matchesKey(keyData, "right")) {
|
||||
this.#onRightCallback?.();
|
||||
} else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
|
||||
} else if (matchesSelectCancel(keyData)) {
|
||||
this.#onCancelCallback();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Utilities for formatting keybinding hints in the UI.
|
||||
*/
|
||||
import { type EditorAction, getEditorKeybindings, type KeyId } from "@oh-my-pi/pi-tui";
|
||||
import type { AppAction, KeybindingsManager } from "../../config/keybindings";
|
||||
import { getKeybindings, type Keybinding, type KeyId } from "@oh-my-pi/pi-tui";
|
||||
import type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
|
||||
/**
|
||||
@@ -17,14 +17,14 @@ function formatKeys(keys: KeyId[]): string {
|
||||
/**
|
||||
* Get display string for an editor action.
|
||||
*/
|
||||
export function editorKey(action: EditorAction): string {
|
||||
return formatKeys(getEditorKeybindings().getKeys(action));
|
||||
export function editorKey(action: Keybinding): string {
|
||||
return formatKeys(getKeybindings().getKeys(action));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display string for an app action.
|
||||
*/
|
||||
export function appKey(keybindings: KeybindingsManager, action: AppAction): string {
|
||||
export function appKey(keybindings: KeybindingsManager, action: AppKeybinding): string {
|
||||
return formatKeys(keybindings.getKeys(action));
|
||||
}
|
||||
|
||||
@@ -32,11 +32,11 @@ export function appKey(keybindings: KeybindingsManager, action: AppAction): stri
|
||||
* Format a keybinding hint with consistent styling: dim key, muted description.
|
||||
* Looks up the key from editor keybindings automatically.
|
||||
*
|
||||
* @param action - Editor action name (e.g., "selectConfirm", "expandTools")
|
||||
* @param action - Keybinding action name (e.g., "tui.select.confirm", "app.tools.expand")
|
||||
* @param description - Description text (e.g., "to expand", "cancel")
|
||||
* @returns Formatted string with dim key and muted description
|
||||
*/
|
||||
export function keyHint(action: EditorAction, description: string): string {
|
||||
export function keyHint(action: Keybinding, description: string): string {
|
||||
return theme.fg("dim", editorKey(action)) + theme.fg("muted", ` ${description}`);
|
||||
}
|
||||
|
||||
@@ -45,11 +45,11 @@ export function keyHint(action: EditorAction, description: string): string {
|
||||
* Requires the KeybindingsManager instance.
|
||||
*
|
||||
* @param keybindings - KeybindingsManager instance
|
||||
* @param action - App action name (e.g., "interrupt", "externalEditor")
|
||||
* @param action - App keybinding name (e.g., "app.interrupt", "app.editor.external")
|
||||
* @param description - Description text
|
||||
* @returns Formatted string with dim key and muted description
|
||||
*/
|
||||
export function appKeyHint(keybindings: KeybindingsManager, action: AppAction, description: string): string {
|
||||
export function appKeyHint(keybindings: KeybindingsManager, action: AppKeybinding, description: string): string {
|
||||
return theme.fg("dim", appKey(keybindings, action)) + theme.fg("muted", ` ${description}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getOAuthProviders } from "@oh-my-pi/pi-ai";
|
||||
import { Container, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
|
||||
import { Container, getKeybindings, Input, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { openPath } from "../../utils/open";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
@@ -151,9 +151,9 @@ export class LoginDialogComponent extends Container {
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
const kb = getEditorKeybindings();
|
||||
const kb = getKeybindings();
|
||||
|
||||
if (kb.matches(data, "selectCancel")) {
|
||||
if (kb.matches(data, "tui.select.cancel")) {
|
||||
this.#cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { analyzeAuthError, discoverOAuthEndpoints } from "../../mcp/oauth-discov
|
||||
import type { MCPHttpServerConfig, MCPServerConfig, MCPSseServerConfig, MCPStdioServerConfig } from "../../mcp/types";
|
||||
import { shortenPath } from "../../tools/render-utils";
|
||||
import { theme } from "../theme/theme";
|
||||
import { matchesAppInterrupt } from "../utils/keybinding-matchers";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
|
||||
type TransportType = "stdio" | "http" | "sse";
|
||||
@@ -452,7 +453,7 @@ export class MCPAddWizard extends Container {
|
||||
}
|
||||
|
||||
// Handle Escape (always handled by wizard)
|
||||
if (matchesKey(keyData, "escape")) {
|
||||
if (matchesAppInterrupt(keyData)) {
|
||||
if (this.#currentStep === "name") {
|
||||
// Cancel wizard
|
||||
this.#onCancelCallback();
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import { getSupportedEfforts, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
|
||||
import { Container, Input, matchesKey, Spacer, type Tab, TabBar, Text, type TUI, visibleWidth } from "@oh-my-pi/pi-tui";
|
||||
import {
|
||||
Container,
|
||||
getKeybindings,
|
||||
Input,
|
||||
matchesKey,
|
||||
Spacer,
|
||||
type Tab,
|
||||
TabBar,
|
||||
Text,
|
||||
type TUI,
|
||||
visibleWidth,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { MODEL_ROLE_IDS, MODEL_ROLES, type ModelRegistry, type ModelRole } from "../../config/model-registry";
|
||||
import { resolveModelRoleValue } from "../../config/model-resolver";
|
||||
import type { Settings } from "../../config/settings";
|
||||
@@ -647,7 +658,7 @@ export class ModelSelectorComponent extends Container {
|
||||
}
|
||||
|
||||
// Escape or Ctrl+C - close selector
|
||||
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
|
||||
if (getKeybindings().matches(keyData, "tui.select.cancel")) {
|
||||
this.#onCancelCallback();
|
||||
return;
|
||||
}
|
||||
@@ -698,7 +709,7 @@ export class ModelSelectorComponent extends Container {
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
|
||||
if (getKeybindings().matches(keyData, "tui.select.cancel")) {
|
||||
if (this.#menuStep === "thinking" && this.#menuSelectedRole !== null) {
|
||||
this.#menuStep = "role";
|
||||
const roleIndex = MENU_ROLE_ACTIONS.findIndex(action => action.role === this.#menuSelectedRole);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getOAuthProviders, type OAuthProviderInfo } from "@oh-my-pi/pi-ai";
|
||||
import { Container, matchesKey, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
|
||||
import type { AuthStorage } from "../../session/auth-storage";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
/**
|
||||
@@ -202,7 +203,7 @@ export class OAuthSelectorComponent extends Container {
|
||||
}
|
||||
}
|
||||
// Escape or Ctrl+C
|
||||
else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
|
||||
else if (matchesSelectCancel(keyData)) {
|
||||
this.stopValidation();
|
||||
this.#onCancelCallback();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
visibleWidth,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
||||
import type { SessionInfo } from "../../session/session-manager";
|
||||
import { fuzzyFilter } from "../../utils/fuzzy";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
@@ -219,7 +220,7 @@ class SessionList implements Component {
|
||||
return;
|
||||
}
|
||||
// Escape - cancel
|
||||
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
|
||||
if (matchesAppInterrupt(keyData)) {
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
} from "../../config/settings-schema";
|
||||
import { SETTING_TABS, TAB_METADATA } from "../../config/settings-schema";
|
||||
import { getCurrentThemeName, getSelectListTheme, getSettingsListTheme, theme } from "../../modes/theme/theme";
|
||||
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
||||
import { getTabBarTheme } from "../shared";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
import { PluginSettingsComponent } from "./plugin-settings";
|
||||
@@ -521,7 +522,7 @@ export class SettingsSelectorComponent extends Container {
|
||||
}
|
||||
|
||||
// Escape at top level cancels
|
||||
if ((matchesKey(data, "escape") || matchesKey(data, "esc")) && !this.#currentSubmenu) {
|
||||
if (matchesAppInterrupt(data) && !this.#currentSubmenu) {
|
||||
this.callbacks.onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { Container, matchesKey, padding } from "@oh-my-pi/pi-tui";
|
||||
import type { StatusLineSegmentId } from "../../config/settings-schema";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
||||
import { ALL_SEGMENT_IDS } from "./status-line/segments";
|
||||
|
||||
// Segment display names and short descriptions
|
||||
@@ -239,7 +240,7 @@ export class StatusLineSegmentEditorComponent extends Container {
|
||||
const left = this.#getSegmentsForColumn("left").map(s => s.id);
|
||||
const right = this.#getSegmentsForColumn("right").map(s => s.id);
|
||||
this.callbacks.onSave(left, right);
|
||||
} else if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
|
||||
} else if (matchesAppInterrupt(data)) {
|
||||
this.callbacks.onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import type { TreeFilterMode } from "../../config/settings-schema";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
||||
import type { SessionTreeNode } from "../../session/session-manager";
|
||||
import { shortenPath } from "../../tools/render-utils";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
@@ -702,7 +703,7 @@ class TreeList implements Component {
|
||||
if (selected && this.onSelect) {
|
||||
this.onSelect(selected.node.entry.id);
|
||||
}
|
||||
} else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
|
||||
} else if (matchesAppInterrupt(keyData)) {
|
||||
if (this.#searchQuery) {
|
||||
this.#searchQuery = "";
|
||||
this.#applyFilter();
|
||||
@@ -807,7 +808,7 @@ class LabelInput implements Component {
|
||||
if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
|
||||
const value = this.#input.getValue().trim();
|
||||
this.onSubmit?.(this.entryId, value || undefined);
|
||||
} else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
|
||||
} else if (matchesAppInterrupt(keyData)) {
|
||||
this.onCancel?.();
|
||||
} else {
|
||||
this.#input.handleInput(keyData);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type Component, Container, matchesKey, Spacer, Text, truncateToWidth } from "@oh-my-pi/pi-tui";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
|
||||
interface UserMessageItem {
|
||||
@@ -91,14 +92,8 @@ class UserMessageList implements Component {
|
||||
this.onSelect(selected.id);
|
||||
}
|
||||
}
|
||||
// Escape - cancel
|
||||
else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
}
|
||||
}
|
||||
// Ctrl+C - cancel
|
||||
else if (matchesKey(keyData, "ctrl+c")) {
|
||||
// Escape / cancel
|
||||
else if (matchesSelectCancel(keyData)) {
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Container, Markdown, Spacer } from "@oh-my-pi/pi-tui";
|
||||
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
||||
|
||||
// OSC 133 shell integration: marks prompt zones for terminal multiplexers
|
||||
const OSC133_ZONE_START = "\x1b]133;A\x07";
|
||||
const OSC133_ZONE_END = "\x1b]133;B\x07";
|
||||
const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
|
||||
|
||||
/**
|
||||
* Component that renders a user message
|
||||
*/
|
||||
@@ -19,4 +24,15 @@ export class UserMessageComponent extends Container {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
override render(width: number): string[] {
|
||||
const lines = super.render(width);
|
||||
if (lines.length === 0) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
lines[0] = OSC133_ZONE_START + lines[0];
|
||||
lines[lines.length - 1] = lines[lines.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Component, OverlayHandle, TUI } from "@oh-my-pi/pi-tui";
|
||||
import { Spacer, Text } from "@oh-my-pi/pi-tui";
|
||||
import { Container, Spacer, Text } from "@oh-my-pi/pi-tui";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
import { KeybindingsManager } from "../../config/keybindings";
|
||||
import type {
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
ExtensionError,
|
||||
ExtensionUIContext,
|
||||
ExtensionUIDialogOptions,
|
||||
ExtensionUiComponent,
|
||||
ExtensionWidgetContent,
|
||||
ExtensionWidgetOptions,
|
||||
TerminalInputHandler,
|
||||
} from "../../extensibility/extensions";
|
||||
import { HookEditorComponent } from "../../modes/components/hook-editor";
|
||||
@@ -18,8 +21,12 @@ import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, them
|
||||
import type { InteractiveModeContext } from "../../modes/types";
|
||||
import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
|
||||
|
||||
const MAX_WIDGET_LINES = 10;
|
||||
|
||||
export class ExtensionUiController {
|
||||
#extensionTerminalInputUnsubscribers = new Set<() => void>();
|
||||
#hookWidgetsAbove = new Map<string, ExtensionUiComponent>();
|
||||
#hookWidgetsBelow = new Map<string, ExtensionUiComponent>();
|
||||
constructor(private ctx: InteractiveModeContext) {}
|
||||
|
||||
/**
|
||||
@@ -35,7 +42,7 @@ export class ExtensionUiController {
|
||||
onTerminalInput: handler => this.addExtensionTerminalInputListener(handler),
|
||||
setStatus: (key, text) => this.setHookStatus(key, text),
|
||||
setWorkingMessage: message => this.ctx.setWorkingMessage(message),
|
||||
setWidget: (key, content) => this.setHookWidget(key, content),
|
||||
setWidget: (key, content, options) => this.setHookWidget(key, content, options),
|
||||
setTitle: title => setTerminalTitle(title),
|
||||
custom: (factory, options) => this.showHookCustom(factory, options),
|
||||
setEditorText: text => this.ctx.editor.setText(text),
|
||||
@@ -151,6 +158,7 @@ export class ExtensionUiController {
|
||||
|
||||
// Create new session
|
||||
this.clearExtensionTerminalInputListeners();
|
||||
this.clearHookWidgets();
|
||||
const success = await this.ctx.session.newSession({ parentSession: options?.parentSession });
|
||||
if (!success) {
|
||||
return { cancelled: true };
|
||||
@@ -228,6 +236,7 @@ export class ExtensionUiController {
|
||||
await this.ctx.executeCompaction(instructionsOrOptions, false);
|
||||
},
|
||||
switchSession: async sessionPath => {
|
||||
this.clearHookWidgets();
|
||||
const result = await this.ctx.session.switchSession(sessionPath);
|
||||
if (!result) {
|
||||
return { cancelled: true };
|
||||
@@ -253,11 +262,73 @@ export class ExtensionUiController {
|
||||
});
|
||||
}
|
||||
|
||||
setHookWidget(key: string, content: unknown): void {
|
||||
this.ctx.statusLine.setHookStatus(key, content === undefined || content === null ? undefined : String(content));
|
||||
setHookWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void {
|
||||
const placement = options?.placement ?? "aboveEditor";
|
||||
this.#removeHookWidget(this.#hookWidgetsAbove, key);
|
||||
this.#removeHookWidget(this.#hookWidgetsBelow, key);
|
||||
|
||||
if (content === undefined) {
|
||||
this.#rebuildHookWidgets();
|
||||
return;
|
||||
}
|
||||
|
||||
const target = placement === "belowEditor" ? this.#hookWidgetsBelow : this.#hookWidgetsAbove;
|
||||
target.set(key, this.#createHookWidget(content));
|
||||
this.#rebuildHookWidgets();
|
||||
}
|
||||
|
||||
#removeHookWidget(widgets: Map<string, ExtensionUiComponent>, key: string): void {
|
||||
const existing = widgets.get(key);
|
||||
existing?.dispose?.();
|
||||
widgets.delete(key);
|
||||
}
|
||||
|
||||
#createHookWidget(content: ExtensionWidgetContent): ExtensionUiComponent {
|
||||
if (Array.isArray(content)) {
|
||||
const container = new Container();
|
||||
for (const line of content.slice(0, MAX_WIDGET_LINES)) {
|
||||
container.addChild(new Text(line, 1, 0));
|
||||
}
|
||||
if (content.length > MAX_WIDGET_LINES) {
|
||||
container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0));
|
||||
}
|
||||
return container;
|
||||
}
|
||||
if (content === undefined) {
|
||||
throw new Error("Widget content missing");
|
||||
}
|
||||
return content(this.ctx.ui, theme);
|
||||
}
|
||||
|
||||
#rebuildHookWidgets(): void {
|
||||
this.#renderHookWidgetContainer(this.ctx.hookWidgetContainerAbove, this.#hookWidgetsAbove, true, true);
|
||||
this.#renderHookWidgetContainer(this.ctx.hookWidgetContainerBelow, this.#hookWidgetsBelow, false, false);
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
#renderHookWidgetContainer(
|
||||
container: Container,
|
||||
widgets: Map<string, ExtensionUiComponent>,
|
||||
spacerWhenEmpty: boolean,
|
||||
leadingSpacer: boolean,
|
||||
): void {
|
||||
container.clear();
|
||||
|
||||
if (widgets.size === 0) {
|
||||
if (spacerWhenEmpty) {
|
||||
container.addChild(new Spacer(1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (leadingSpacer) {
|
||||
container.addChild(new Spacer(1));
|
||||
}
|
||||
for (const widget of widgets.values()) {
|
||||
container.addChild(widget);
|
||||
}
|
||||
}
|
||||
|
||||
initializeHookRunner(uiContext: ExtensionUIContext, _hasUI: boolean): void {
|
||||
const extensionRunner = this.ctx.session.extensionRunner;
|
||||
if (!extensionRunner) {
|
||||
@@ -354,6 +425,7 @@ export class ExtensionUiController {
|
||||
|
||||
// Create new session
|
||||
this.clearExtensionTerminalInputListeners();
|
||||
this.clearHookWidgets();
|
||||
const success = await this.ctx.session.newSession({ parentSession: options?.parentSession });
|
||||
if (!success) {
|
||||
return { cancelled: true };
|
||||
@@ -433,6 +505,7 @@ export class ExtensionUiController {
|
||||
if (this.ctx.isBackgrounded) {
|
||||
return { cancelled: true };
|
||||
}
|
||||
this.clearHookWidgets();
|
||||
const result = await this.ctx.session.switchSession(sessionPath);
|
||||
if (!result) {
|
||||
return { cancelled: true };
|
||||
@@ -856,6 +929,18 @@ export class ExtensionUiController {
|
||||
};
|
||||
}
|
||||
|
||||
clearHookWidgets(): void {
|
||||
for (const widget of this.#hookWidgetsAbove.values()) {
|
||||
widget.dispose?.();
|
||||
}
|
||||
for (const widget of this.#hookWidgetsBelow.values()) {
|
||||
widget.dispose?.();
|
||||
}
|
||||
this.#hookWidgetsAbove.clear();
|
||||
this.#hookWidgetsBelow.clear();
|
||||
this.#rebuildHookWidgets();
|
||||
}
|
||||
|
||||
clearExtensionTerminalInputListeners(): void {
|
||||
for (const unsubscribe of this.#extensionTerminalInputUnsubscribers) {
|
||||
unsubscribe();
|
||||
|
||||
@@ -26,7 +26,7 @@ export class InputController {
|
||||
constructor(private ctx: InteractiveModeContext) {}
|
||||
|
||||
setupKeyHandlers(): void {
|
||||
this.ctx.editor.setActionKeys("interrupt", this.ctx.keybindings.getKeys("interrupt"));
|
||||
this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
|
||||
this.ctx.editor.shouldBypassAutocompleteOnEscape = () =>
|
||||
Boolean(
|
||||
this.ctx.loadingAnimation ||
|
||||
@@ -83,68 +83,74 @@ export class InputController {
|
||||
}
|
||||
};
|
||||
|
||||
this.ctx.editor.setActionKeys("clear", this.ctx.keybindings.getKeys("clear"));
|
||||
this.ctx.editor.setActionKeys("app.clear", this.ctx.keybindings.getKeys("app.clear"));
|
||||
this.ctx.editor.onClear = () => this.handleCtrlC();
|
||||
this.ctx.editor.setActionKeys("exit", this.ctx.keybindings.getKeys("exit"));
|
||||
this.ctx.editor.setActionKeys("app.exit", this.ctx.keybindings.getKeys("app.exit"));
|
||||
this.ctx.editor.onExit = () => this.handleCtrlD();
|
||||
this.ctx.editor.setActionKeys("suspend", this.ctx.keybindings.getKeys("suspend"));
|
||||
this.ctx.editor.setActionKeys("app.suspend", this.ctx.keybindings.getKeys("app.suspend"));
|
||||
this.ctx.editor.onSuspend = () => this.handleCtrlZ();
|
||||
this.ctx.editor.setActionKeys("cycleThinkingLevel", this.ctx.keybindings.getKeys("cycleThinkingLevel"));
|
||||
this.ctx.editor.setActionKeys("app.thinking.cycle", this.ctx.keybindings.getKeys("app.thinking.cycle"));
|
||||
this.ctx.editor.onCycleThinkingLevel = () => this.cycleThinkingLevel();
|
||||
this.ctx.editor.setActionKeys("cycleModelForward", this.ctx.keybindings.getKeys("cycleModelForward"));
|
||||
this.ctx.editor.setActionKeys("app.model.cycleForward", this.ctx.keybindings.getKeys("app.model.cycleForward"));
|
||||
this.ctx.editor.onCycleModelForward = () => this.cycleRoleModel();
|
||||
this.ctx.editor.setActionKeys("cycleModelBackward", this.ctx.keybindings.getKeys("cycleModelBackward"));
|
||||
this.ctx.editor.setActionKeys("app.model.cycleBackward", this.ctx.keybindings.getKeys("app.model.cycleBackward"));
|
||||
this.ctx.editor.onCycleModelBackward = () => this.cycleRoleModel({ temporary: true });
|
||||
this.ctx.editor.onQuickSelectModel = () => this.ctx.showModelSelector({ temporaryOnly: true });
|
||||
|
||||
// Global debug handler on TUI (works regardless of focus)
|
||||
this.ctx.ui.onDebug = () => this.ctx.showDebugSelector();
|
||||
this.ctx.editor.setActionKeys("selectModel", this.ctx.keybindings.getKeys("selectModel"));
|
||||
this.ctx.editor.setActionKeys("app.model.select", this.ctx.keybindings.getKeys("app.model.select"));
|
||||
this.ctx.editor.onSelectModel = () => this.ctx.showModelSelector();
|
||||
this.ctx.editor.setActionKeys("historySearch", this.ctx.keybindings.getKeys("historySearch"));
|
||||
this.ctx.editor.setActionKeys("app.history.search", this.ctx.keybindings.getKeys("app.history.search"));
|
||||
this.ctx.editor.onHistorySearch = () => this.ctx.showHistorySearch();
|
||||
this.ctx.editor.setActionKeys("toggleThinking", this.ctx.keybindings.getKeys("toggleThinking"));
|
||||
this.ctx.editor.setActionKeys("app.thinking.toggle", this.ctx.keybindings.getKeys("app.thinking.toggle"));
|
||||
this.ctx.editor.onToggleThinking = () => this.ctx.toggleThinkingBlockVisibility();
|
||||
this.ctx.editor.setActionKeys("externalEditor", this.ctx.keybindings.getKeys("externalEditor"));
|
||||
this.ctx.editor.setActionKeys("app.editor.external", this.ctx.keybindings.getKeys("app.editor.external"));
|
||||
this.ctx.editor.onExternalEditor = () => void this.openExternalEditor();
|
||||
this.ctx.editor.onShowHotkeys = () => this.ctx.handleHotkeysCommand();
|
||||
this.ctx.editor.setActionKeys("pasteImage", this.ctx.keybindings.getKeys("pasteImage"));
|
||||
this.ctx.editor.setActionKeys(
|
||||
"app.clipboard.pasteImage",
|
||||
this.ctx.keybindings.getKeys("app.clipboard.pasteImage"),
|
||||
);
|
||||
this.ctx.editor.onPasteImage = () => this.handleImagePaste();
|
||||
this.ctx.editor.setActionKeys("copyPrompt", this.ctx.keybindings.getKeys("copyPrompt"));
|
||||
this.ctx.editor.setActionKeys(
|
||||
"app.clipboard.copyPrompt",
|
||||
this.ctx.keybindings.getKeys("app.clipboard.copyPrompt"),
|
||||
);
|
||||
this.ctx.editor.onCopyPrompt = () => this.handleCopyPrompt();
|
||||
this.ctx.editor.setActionKeys("expandTools", this.ctx.keybindings.getKeys("expandTools"));
|
||||
this.ctx.editor.setActionKeys("app.tools.expand", this.ctx.keybindings.getKeys("app.tools.expand"));
|
||||
this.ctx.editor.onExpandTools = () => this.toggleToolOutputExpansion();
|
||||
this.ctx.editor.setActionKeys("dequeue", this.ctx.keybindings.getKeys("dequeue"));
|
||||
this.ctx.editor.setActionKeys("app.message.dequeue", this.ctx.keybindings.getKeys("app.message.dequeue"));
|
||||
this.ctx.editor.onDequeue = () => this.handleDequeue();
|
||||
|
||||
this.ctx.editor.clearCustomKeyHandlers();
|
||||
// Wire up extension shortcuts
|
||||
this.registerExtensionShortcuts();
|
||||
|
||||
const planModeKeys = this.ctx.keybindings.getKeys("togglePlanMode");
|
||||
const planModeKeys = this.ctx.keybindings.getKeys("app.plan.toggle");
|
||||
for (const key of planModeKeys) {
|
||||
this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handlePlanModeCommand());
|
||||
}
|
||||
|
||||
for (const key of this.ctx.keybindings.getKeys("newSession")) {
|
||||
for (const key of this.ctx.keybindings.getKeys("app.session.new")) {
|
||||
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.handleClearCommand());
|
||||
}
|
||||
for (const key of this.ctx.keybindings.getKeys("tree")) {
|
||||
for (const key of this.ctx.keybindings.getKeys("app.session.tree")) {
|
||||
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showTreeSelector());
|
||||
}
|
||||
for (const key of this.ctx.keybindings.getKeys("fork")) {
|
||||
for (const key of this.ctx.keybindings.getKeys("app.session.fork")) {
|
||||
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showUserMessageSelector());
|
||||
}
|
||||
for (const key of this.ctx.keybindings.getKeys("resume")) {
|
||||
for (const key of this.ctx.keybindings.getKeys("app.session.resume")) {
|
||||
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionSelector());
|
||||
}
|
||||
for (const key of this.ctx.keybindings.getKeys("followUp")) {
|
||||
for (const key of this.ctx.keybindings.getKeys("app.message.followUp")) {
|
||||
this.ctx.editor.setCustomKeyHandler(key, () => void this.handleFollowUp());
|
||||
}
|
||||
for (const key of this.ctx.keybindings.getKeys("toggleSTT")) {
|
||||
for (const key of this.ctx.keybindings.getKeys("app.stt.toggle")) {
|
||||
this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handleSTTToggle());
|
||||
}
|
||||
for (const key of this.ctx.keybindings.getKeys("copyLine")) {
|
||||
for (const key of this.ctx.keybindings.getKeys("app.clipboard.copyLine")) {
|
||||
this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyCurrentLine());
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,12 @@ import chalk from "chalk";
|
||||
import { KeybindingsManager } from "../config/keybindings";
|
||||
import { renderPromptTemplate } from "../config/prompt-templates";
|
||||
import { type Settings, settings } from "../config/settings";
|
||||
import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../extensibility/extensions";
|
||||
import type {
|
||||
ExtensionUIContext,
|
||||
ExtensionUIDialogOptions,
|
||||
ExtensionWidgetContent,
|
||||
ExtensionWidgetOptions,
|
||||
} from "../extensibility/extensions";
|
||||
import type { CompactOptions } from "../extensibility/extensions/types";
|
||||
import { BUILTIN_SLASH_COMMANDS, loadSlashCommands } from "../extensibility/slash-commands";
|
||||
import { resolveLocalUrlToPath } from "../internal-urls";
|
||||
@@ -93,6 +98,8 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
btwContainer: Container;
|
||||
editor: CustomEditor;
|
||||
editorContainer: Container;
|
||||
hookWidgetContainerAbove: Container;
|
||||
hookWidgetContainerBelow: Container;
|
||||
statusLine: StatusLineComponent;
|
||||
|
||||
isInitialized = false;
|
||||
@@ -216,6 +223,9 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
} catch (error) {
|
||||
logger.warn("History storage unavailable", { error: String(error) });
|
||||
}
|
||||
this.hookWidgetContainerAbove = new Container();
|
||||
this.hookWidgetContainerAbove.addChild(new Spacer(1));
|
||||
this.hookWidgetContainerBelow = new Container();
|
||||
this.editorContainer = new Container();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.statusLine = new StatusLineComponent(session);
|
||||
@@ -263,7 +273,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
async init(): Promise<void> {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
this.keybindings = await logger.timeAsync("InteractiveMode.init:keybindings", () => KeybindingsManager.create());
|
||||
this.keybindings = logger.time("InteractiveMode.init:keybindings", () => KeybindingsManager.create());
|
||||
|
||||
// Register session manager flush for signal handlers (SIGINT, SIGTERM, SIGHUP)
|
||||
this.#cleanupUnsubscribe = postmortem.register("session-manager-flush", () => this.sessionManager.flush());
|
||||
@@ -329,8 +339,9 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.ui.addChild(this.todoContainer);
|
||||
this.ui.addChild(this.btwContainer);
|
||||
this.ui.addChild(this.statusLine); // Only renders hook statuses (main status in editor border)
|
||||
this.ui.addChild(new Spacer(1));
|
||||
this.ui.addChild(this.hookWidgetContainerAbove);
|
||||
this.ui.addChild(this.editorContainer);
|
||||
this.ui.addChild(this.hookWidgetContainerBelow);
|
||||
this.ui.setFocus(this.editor);
|
||||
|
||||
this.#inputController.setupKeyHandlers();
|
||||
@@ -837,6 +848,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.#sttController = undefined;
|
||||
}
|
||||
this.#extensionUiController.clearExtensionTerminalInputListeners();
|
||||
this.#extensionUiController.clearHookWidgets();
|
||||
this.statusLine.dispose();
|
||||
if (this.#resizeHandler) {
|
||||
process.stdout.removeListener("resize", this.#resizeHandler);
|
||||
@@ -1359,8 +1371,8 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
return this.#extensionUiController.emitCustomToolSessionEvent(reason, previousSessionFile);
|
||||
}
|
||||
|
||||
setHookWidget(key: string, content: unknown): void {
|
||||
this.#extensionUiController.setHookWidget(key, content);
|
||||
setHookWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void {
|
||||
this.#extensionUiController.setHookWidget(key, content, options);
|
||||
}
|
||||
|
||||
setHookStatus(key: string, text: string | undefined): void {
|
||||
|
||||
@@ -146,7 +146,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
|
||||
});
|
||||
|
||||
// Send initial message with attachments
|
||||
if (initialMessage) {
|
||||
if (initialMessage !== undefined) {
|
||||
await session.prompt(initialMessage, { images: initialImages });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
type AutocompleteItem,
|
||||
type AutocompleteProvider,
|
||||
CombinedAutocompleteProvider,
|
||||
getEditorKeybindings,
|
||||
getKeybindings,
|
||||
type SlashCommand,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { formatKeyHints, type KeybindingsManager } from "../config/keybindings";
|
||||
@@ -174,26 +174,26 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
|
||||
export function createPromptActionAutocompleteProvider(
|
||||
options: PromptActionAutocompleteOptions,
|
||||
): PromptActionAutocompleteProvider {
|
||||
const editorKeybindings = getEditorKeybindings();
|
||||
const editorKeybindings = getKeybindings();
|
||||
const actions: PromptActionDefinition[] = [
|
||||
{
|
||||
id: "copy-line",
|
||||
label: "Copy current line",
|
||||
description: formatKeyHints(options.keybindings.getKeys("copyLine")),
|
||||
description: formatKeyHints(options.keybindings.getKeys("app.clipboard.copyLine")),
|
||||
keywords: ["copy", "line", "clipboard", "current"],
|
||||
execute: options.copyCurrentLine,
|
||||
},
|
||||
{
|
||||
id: "copy-prompt",
|
||||
label: "Copy whole prompt",
|
||||
description: formatKeyHints(options.keybindings.getKeys("copyPrompt")),
|
||||
description: formatKeyHints(options.keybindings.getKeys("app.clipboard.copyPrompt")),
|
||||
keywords: ["copy", "prompt", "clipboard", "message"],
|
||||
execute: options.copyPrompt,
|
||||
},
|
||||
{
|
||||
id: "undo",
|
||||
label: "Undo",
|
||||
description: formatKeyHints(editorKeybindings.getKeys("undo")),
|
||||
description: formatKeyHints(editorKeybindings.getKeys("tui.editor.undo")),
|
||||
keywords: ["undo", "revert", "edit", "history"],
|
||||
execute: options.undo,
|
||||
},
|
||||
@@ -214,14 +214,14 @@ export function createPromptActionAutocompleteProvider(
|
||||
{
|
||||
id: "cursor-line-start",
|
||||
label: "Move cursor to beginning of line",
|
||||
description: formatKeyHints(editorKeybindings.getKeys("cursorLineStart")),
|
||||
description: formatKeyHints(editorKeybindings.getKeys("tui.editor.cursorLineStart")),
|
||||
keywords: ["move", "cursor", "line", "start", "beginning", "home"],
|
||||
execute: options.moveCursorToLineStart,
|
||||
},
|
||||
{
|
||||
id: "cursor-line-end",
|
||||
label: "Move cursor to end of line",
|
||||
description: formatKeyHints(editorKeybindings.getKeys("cursorLineEnd")),
|
||||
description: formatKeyHints(editorKeybindings.getKeys("tui.editor.cursorLineEnd")),
|
||||
keywords: ["move", "cursor", "line", "end"],
|
||||
execute: options.moveCursorToLineEnd,
|
||||
},
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
* - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
|
||||
*/
|
||||
import { readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
|
||||
import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../extensibility/extensions";
|
||||
import type {
|
||||
ExtensionUIContext,
|
||||
ExtensionUIDialogOptions,
|
||||
ExtensionWidgetOptions,
|
||||
} from "../../extensibility/extensions";
|
||||
import { type Theme, theme } from "../../modes/theme/theme";
|
||||
import type { AgentSession } from "../../session/agent-session";
|
||||
import type {
|
||||
@@ -263,7 +267,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||
// Not supported in RPC mode
|
||||
}
|
||||
|
||||
setWidget(key: string, content: unknown): void {
|
||||
setWidget(key: string, content: unknown, options?: ExtensionWidgetOptions): void {
|
||||
// Only support string arrays in RPC mode - factory functions are ignored
|
||||
if (content === undefined || Array.isArray(content)) {
|
||||
this.output({
|
||||
@@ -272,6 +276,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||
method: "setWidget",
|
||||
widgetKey: key,
|
||||
widgetLines: content as string[] | undefined,
|
||||
widgetPlacement: options?.placement,
|
||||
} as RpcExtensionUIRequest);
|
||||
}
|
||||
// Component factories are not supported in RPC mode - would need TUI access
|
||||
|
||||
@@ -223,6 +223,7 @@ export type RpcExtensionUIRequest =
|
||||
method: "setWidget";
|
||||
widgetKey: string;
|
||||
widgetLines: string[] | undefined;
|
||||
widgetPlacement?: "aboveEditor" | "belowEditor";
|
||||
}
|
||||
| { type: "extension_ui_request"; id: string; method: "setTitle"; title: string }
|
||||
| { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string };
|
||||
|
||||
@@ -1679,6 +1679,7 @@ export function getCurrentThemeName(): string | undefined {
|
||||
var currentSymbolPresetOverride: SymbolPreset | undefined;
|
||||
var currentColorBlindMode: boolean = false;
|
||||
var themeWatcher: fs.FSWatcher | undefined;
|
||||
var themeReloadTimer: NodeJS.Timeout | undefined;
|
||||
var sigwinchHandler: (() => void) | undefined;
|
||||
var autoDetectedTheme: boolean = false;
|
||||
var autoDarkTheme: string = "dark";
|
||||
@@ -1888,11 +1889,7 @@ export function isValidSymbolPreset(preset: string): preset is SymbolPreset {
|
||||
}
|
||||
|
||||
async function startThemeWatcher(): Promise<void> {
|
||||
// Stop existing watcher if any
|
||||
if (themeWatcher) {
|
||||
themeWatcher.close();
|
||||
themeWatcher = undefined;
|
||||
}
|
||||
stopThemeWatcher();
|
||||
|
||||
// Only watch if it's a custom theme (not built-in)
|
||||
if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") {
|
||||
@@ -1900,54 +1897,62 @@ async function startThemeWatcher(): Promise<void> {
|
||||
}
|
||||
|
||||
const customThemesDir = getCustomThemesDir();
|
||||
const themeFile = path.join(customThemesDir, `${currentThemeName}.json`);
|
||||
const watchedThemeName = currentThemeName;
|
||||
const watchedFileName = `${watchedThemeName}.json`;
|
||||
const themeFile = path.join(customThemesDir, watchedFileName);
|
||||
|
||||
// Only watch if the file exists
|
||||
if (!fs.existsSync(themeFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
themeWatcher = fs.watch(themeFile, eventType => {
|
||||
if (eventType === "change") {
|
||||
// Debounce rapid changes
|
||||
setTimeout(() => {
|
||||
loadTheme(currentThemeName!, getCurrentThemeOptions())
|
||||
.then(loadedTheme => {
|
||||
theme = loadedTheme;
|
||||
if (onThemeChangeCallback) {
|
||||
onThemeChangeCallback();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
logger.debug("Theme reload error during file change", { error: String(err) });
|
||||
});
|
||||
}, 100);
|
||||
} else if (eventType === "rename") {
|
||||
// File was deleted or renamed - fall back to default theme
|
||||
setTimeout(() => {
|
||||
if (!fs.existsSync(themeFile)) {
|
||||
currentThemeName = "dark";
|
||||
loadTheme("dark", getCurrentThemeOptions())
|
||||
.then(loadedTheme => {
|
||||
theme = loadedTheme;
|
||||
if (onThemeChangeCallback) {
|
||||
onThemeChangeCallback();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
logger.debug("Theme reload error during rename fallback", { error: String(err) });
|
||||
});
|
||||
if (themeWatcher) {
|
||||
themeWatcher.close();
|
||||
themeWatcher = undefined;
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
const scheduleReload = () => {
|
||||
if (themeReloadTimer) {
|
||||
clearTimeout(themeReloadTimer);
|
||||
}
|
||||
themeReloadTimer = setTimeout(() => {
|
||||
themeReloadTimer = undefined;
|
||||
|
||||
// Ignore stale timers after switching themes or stopping the watcher
|
||||
if (currentThemeName !== watchedThemeName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the last successfully loaded theme active if the file is temporarily missing
|
||||
if (!fs.existsSync(themeFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadTheme(watchedThemeName, getCurrentThemeOptions())
|
||||
.then(loadedTheme => {
|
||||
theme = loadedTheme;
|
||||
if (onThemeChangeCallback) {
|
||||
onThemeChangeCallback();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore errors (file might be in invalid state while being edited)
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
try {
|
||||
themeWatcher = fs.watch(customThemesDir, (_eventType, filename) => {
|
||||
if (currentThemeName !== watchedThemeName) {
|
||||
return;
|
||||
}
|
||||
if (!filename) {
|
||||
scheduleReload();
|
||||
return;
|
||||
}
|
||||
const changedFile = String(filename);
|
||||
if (changedFile !== watchedFileName) {
|
||||
return;
|
||||
}
|
||||
scheduleReload();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.debug("Failed to start theme watcher", { error: String(err) });
|
||||
} catch {
|
||||
// Ignore errors starting watcher
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2023,6 +2028,10 @@ function stopSigwinchListener(): void {
|
||||
}
|
||||
|
||||
export function stopThemeWatcher(): void {
|
||||
if (themeReloadTimer) {
|
||||
clearTimeout(themeReloadTimer);
|
||||
themeReloadTimer = undefined;
|
||||
}
|
||||
if (themeWatcher) {
|
||||
themeWatcher.close();
|
||||
themeWatcher = undefined;
|
||||
|
||||
@@ -3,7 +3,12 @@ import type { AssistantMessage, ImageContent, Message, UsageReport } from "@oh-m
|
||||
import type { Component, Container, Loader, Spacer, Text, TUI } from "@oh-my-pi/pi-tui";
|
||||
import type { KeybindingsManager } from "../config/keybindings";
|
||||
import type { Settings } from "../config/settings";
|
||||
import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../extensibility/extensions";
|
||||
import type {
|
||||
ExtensionUIContext,
|
||||
ExtensionUIDialogOptions,
|
||||
ExtensionWidgetContent,
|
||||
ExtensionWidgetOptions,
|
||||
} from "../extensibility/extensions";
|
||||
import type { CompactOptions } from "../extensibility/extensions/types";
|
||||
import type { MCPManager } from "../mcp";
|
||||
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
|
||||
@@ -59,6 +64,8 @@ export interface InteractiveModeContext {
|
||||
btwContainer: Container;
|
||||
editor: CustomEditor;
|
||||
editorContainer: Container;
|
||||
hookWidgetContainerAbove: Container;
|
||||
hookWidgetContainerBelow: Container;
|
||||
statusLine: StatusLineComponent;
|
||||
|
||||
// Session access
|
||||
@@ -226,7 +233,7 @@ export interface InteractiveModeContext {
|
||||
reason: "start" | "switch" | "branch" | "tree" | "shutdown",
|
||||
previousSessionFile?: string,
|
||||
): Promise<void>;
|
||||
setHookWidget(key: string, content: unknown): void;
|
||||
setHookWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void;
|
||||
setHookStatus(key: string, text: string | undefined): void;
|
||||
showHookSelector(
|
||||
title: string,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { AppAction, KeybindingsManager } from "../../config/keybindings";
|
||||
import type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
|
||||
|
||||
export interface HotkeysMarkdownBindings {
|
||||
keybindings: Pick<KeybindingsManager, "getDisplayString">;
|
||||
}
|
||||
|
||||
function appKey(bindings: HotkeysMarkdownBindings, action: AppAction): string {
|
||||
function appKey(bindings: HotkeysMarkdownBindings, action: AppKeybinding): string {
|
||||
return bindings.keybindings.getDisplayString(action) || "Disabled";
|
||||
}
|
||||
|
||||
@@ -26,29 +26,29 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
|
||||
"| `Ctrl+W` / `Option+Backspace` | Delete word backwards |",
|
||||
"| `Ctrl+U` | Delete to start of line |",
|
||||
"| `Ctrl+K` | Delete to end of line |",
|
||||
`| \`${appKey(bindings, "copyLine")}\` | Copy current line |`,
|
||||
`| \`${appKey(bindings, "copyPrompt")}\` | Copy whole prompt |`,
|
||||
`| \`${appKey(bindings, "app.clipboard.copyLine")}\` | Copy current line |`,
|
||||
`| \`${appKey(bindings, "app.clipboard.copyPrompt")}\` | Copy whole prompt |`,
|
||||
"",
|
||||
"**Other**",
|
||||
"| Key | Action |",
|
||||
"|-----|--------|",
|
||||
"| `Tab` | Path completion / accept autocomplete |",
|
||||
`| \`${appKey(bindings, "interrupt")}\` | Cancel autocomplete / interrupt active work |`,
|
||||
`| \`${appKey(bindings, "clear")}\` | Clear editor (first) / exit (second) |`,
|
||||
`| \`${appKey(bindings, "exit")}\` | Exit (when editor is empty) |`,
|
||||
`| \`${appKey(bindings, "suspend")}\` | Suspend to background |`,
|
||||
`| \`${appKey(bindings, "cycleThinkingLevel")}\` | Cycle thinking level |`,
|
||||
`| \`${appKey(bindings, "cycleModelForward")}\` | Cycle role models (slow/default/smol) |`,
|
||||
`| \`${appKey(bindings, "cycleModelBackward")}\` | Cycle role models (temporary) |`,
|
||||
`| \`${appKey(bindings, "app.interrupt")}\` | Cancel autocomplete / interrupt active work |`,
|
||||
`| \`${appKey(bindings, "app.clear")}\` | Clear editor (first) / exit (second) |`,
|
||||
`| \`${appKey(bindings, "app.exit")}\` | Exit (when editor is empty) |`,
|
||||
`| \`${appKey(bindings, "app.suspend")}\` | Suspend to background |`,
|
||||
`| \`${appKey(bindings, "app.thinking.cycle")}\` | Cycle thinking level |`,
|
||||
`| \`${appKey(bindings, "app.model.cycleForward")}\` | Cycle role models (slow/default/smol) |`,
|
||||
`| \`${appKey(bindings, "app.model.cycleBackward")}\` | Cycle role models (temporary) |`,
|
||||
"| `Alt+P` | Select model (temporary) |",
|
||||
`| \`${appKey(bindings, "selectModel")}\` | Select model (set roles) |`,
|
||||
`| \`${appKey(bindings, "togglePlanMode")}\` | Toggle plan mode |`,
|
||||
`| \`${appKey(bindings, "historySearch")}\` | Search prompt history |`,
|
||||
`| \`${appKey(bindings, "expandTools")}\` | Toggle tool output expansion |`,
|
||||
`| \`${appKey(bindings, "toggleThinking")}\` | Toggle thinking block visibility |`,
|
||||
`| \`${appKey(bindings, "externalEditor")}\` | Edit message in external editor |`,
|
||||
`| \`${appKey(bindings, "pasteImage")}\` | Paste image from clipboard |`,
|
||||
`| \`${appKey(bindings, "toggleSTT")}\` | Toggle speech-to-text recording |`,
|
||||
`| \`${appKey(bindings, "app.model.select")}\` | Select model (set roles) |`,
|
||||
`| \`${appKey(bindings, "app.plan.toggle")}\` | Toggle plan mode |`,
|
||||
`| \`${appKey(bindings, "app.history.search")}\` | Search prompt history |`,
|
||||
`| \`${appKey(bindings, "app.tools.expand")}\` | Toggle tool output expansion |`,
|
||||
`| \`${appKey(bindings, "app.thinking.toggle")}\` | Toggle thinking block visibility |`,
|
||||
`| \`${appKey(bindings, "app.editor.external")}\` | Edit message in external editor |`,
|
||||
`| \`${appKey(bindings, "app.clipboard.pasteImage")}\` | Paste image from clipboard |`,
|
||||
`| \`${appKey(bindings, "app.stt.toggle")}\` | Toggle speech-to-text recording |`,
|
||||
"| `#` | Open prompt actions |",
|
||||
"| `/` | Slash commands |",
|
||||
"| `!` | Run bash command |",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getKeybindings, matchesKey } from "@oh-my-pi/pi-tui";
|
||||
|
||||
/**
|
||||
* Match the coding-agent interrupt key.
|
||||
*
|
||||
* Interactive mode installs a keybinding manager that exposes `app.interrupt`
|
||||
* globally, but some isolated component tests still run with only TUI
|
||||
* keybindings registered. In that case, fall back to raw Escape matching.
|
||||
*/
|
||||
export function matchesAppInterrupt(data: string): boolean {
|
||||
const keybindings = getKeybindings();
|
||||
const interruptKeys = keybindings.getKeys("app.interrupt");
|
||||
if (interruptKeys.length > 0) {
|
||||
return keybindings.matches(data, "app.interrupt");
|
||||
}
|
||||
return matchesKey(data, "escape") || matchesKey(data, "esc");
|
||||
}
|
||||
|
||||
export function matchesSelectCancel(data: string): boolean {
|
||||
return getKeybindings().matches(data, "tui.select.cancel");
|
||||
}
|
||||
@@ -474,7 +474,7 @@ export class UiHelpers {
|
||||
const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`);
|
||||
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
|
||||
}
|
||||
const dequeueKey = this.ctx.keybindings.getDisplayString("dequeue") || "Alt+Up";
|
||||
const dequeueKey = this.ctx.keybindings.getDisplayString("app.message.dequeue") || "Alt+Up";
|
||||
const hintText = theme.fg("dim", `${theme.tree.hook} ${dequeueKey} to edit`);
|
||||
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
|
||||
}
|
||||
|
||||
@@ -16,9 +16,12 @@ import type { HashMismatch } from "./types";
|
||||
|
||||
export type Anchor = { line: number; hash: string };
|
||||
export type HashlineEdit =
|
||||
| { op: "replace"; pos: Anchor; end?: Anchor; lines: string[] }
|
||||
| { op: "append"; pos?: Anchor; lines: string[] }
|
||||
| { op: "prepend"; pos?: Anchor; lines: string[] };
|
||||
| { op: "replace_line"; pos: Anchor; lines: string[] }
|
||||
| { op: "replace_range"; pos: Anchor; end: Anchor; lines: string[] }
|
||||
| { op: "append_at"; pos: Anchor; lines: string[] }
|
||||
| { op: "prepend_at"; pos: Anchor; lines: string[] }
|
||||
| { op: "append_file"; lines: string[] }
|
||||
| { op: "prepend_file"; lines: string[] };
|
||||
|
||||
const NIBBLE_STR = "ZPMQVRWSNKTXJBYH";
|
||||
|
||||
@@ -455,18 +458,6 @@ function maybeWarnSuspiciousUnicodeEscapePlaceholder(edits: HashlineEdit[], warn
|
||||
// Edit Application
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const MIN_AUTOCORRECT_LENGTH = 2;
|
||||
|
||||
function shouldAutocorrect(line: string, otherLine: string): boolean {
|
||||
if (!line || line !== otherLine) return false;
|
||||
line = line.trim();
|
||||
if (line.length < MIN_AUTOCORRECT_LENGTH) {
|
||||
// if brace, we allow
|
||||
return line.endsWith("}") || line.endsWith(")");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an array of hashline edits to file content.
|
||||
*
|
||||
@@ -513,28 +504,29 @@ export function applyHashlineEdits(
|
||||
}
|
||||
for (const edit of edits) {
|
||||
switch (edit.op) {
|
||||
case "replace": {
|
||||
if (edit.end) {
|
||||
const startValid = validateRef(edit.pos);
|
||||
const endValid = validateRef(edit.end);
|
||||
if (!startValid || !endValid) continue;
|
||||
if (edit.pos.line > edit.end.line) {
|
||||
throw new Error(`Range start line ${edit.pos.line} must be <= end line ${edit.end.line}`);
|
||||
}
|
||||
} else {
|
||||
if (!validateRef(edit.pos)) continue;
|
||||
case "replace_line": {
|
||||
if (!validateRef(edit.pos)) continue;
|
||||
break;
|
||||
}
|
||||
case "replace_range": {
|
||||
const startValid = validateRef(edit.pos);
|
||||
const endValid = validateRef(edit.end);
|
||||
if (!startValid || !endValid) continue;
|
||||
if (edit.pos.line > edit.end.line) {
|
||||
throw new Error(`Range start line ${edit.pos.line} must be <= end line ${edit.end.line}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "append": {
|
||||
if (edit.pos && !validateRef(edit.pos)) continue;
|
||||
case "append_at":
|
||||
case "prepend_at": {
|
||||
if (!validateRef(edit.pos)) continue;
|
||||
if (edit.lines.length === 0) {
|
||||
edit.lines = [""]; // insert an empty line
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "prepend": {
|
||||
if (edit.pos && !validateRef(edit.pos)) continue;
|
||||
case "append_file":
|
||||
case "prepend_file": {
|
||||
if (edit.lines.length === 0) {
|
||||
edit.lines = [""]; // insert an empty line
|
||||
}
|
||||
@@ -547,6 +539,38 @@ export function applyHashlineEdits(
|
||||
}
|
||||
maybeAutocorrectEscapedTabIndentation(edits, warnings);
|
||||
maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings);
|
||||
|
||||
// Warn when a replace_range/replace_line's last inserted line duplicates the next surviving line.
|
||||
// This catches the common boundary-overreach pattern where the agent includes a closing delimiter
|
||||
// in the replacement but sets `end` to the line before the delimiter, causing duplication.
|
||||
for (const edit of edits) {
|
||||
let endLine: number;
|
||||
switch (edit.op) {
|
||||
case "replace_line":
|
||||
endLine = edit.pos.line;
|
||||
break;
|
||||
case "replace_range":
|
||||
endLine = edit.end.line;
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
if (edit.lines.length === 0) continue;
|
||||
const nextSurvivingIdx = endLine; // 0-indexed: endLine (1-indexed) is the next line after `end`
|
||||
if (nextSurvivingIdx >= originalFileLines.length) continue;
|
||||
const nextSurvivingLine = originalFileLines[nextSurvivingIdx];
|
||||
const lastInsertedLine = edit.lines[edit.lines.length - 1];
|
||||
const trimmedNext = nextSurvivingLine.trim();
|
||||
const trimmedLast = lastInsertedLine.trim();
|
||||
// Only warn for non-trivial lines to avoid false positives on blank lines or bare punctuation
|
||||
if (trimmedLast.length > 0 && trimmedLast === trimmedNext) {
|
||||
const tag = formatLineTag(endLine + 1, nextSurvivingLine);
|
||||
warnings.push(
|
||||
`Possible boundary duplication: your last replacement line \`${trimmedLast}\` is identical to the next surviving line ${tag}. ` +
|
||||
`If you meant to replace the entire block, set \`end\` to ${tag} instead.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Deduplicate identical edits targeting the same line(s)
|
||||
const seenEditKeys = new Map<string, number>();
|
||||
const dedupIndices = new Set<number>();
|
||||
@@ -554,25 +578,22 @@ export function applyHashlineEdits(
|
||||
const edit = edits[i];
|
||||
let lineKey: string;
|
||||
switch (edit.op) {
|
||||
case "replace":
|
||||
if (!edit.end) {
|
||||
lineKey = `s:${edit.pos.line}`;
|
||||
} else {
|
||||
lineKey = `r:${edit.pos.line}:${edit.end.line}`;
|
||||
}
|
||||
case "replace_line":
|
||||
lineKey = `s:${edit.pos.line}`;
|
||||
break;
|
||||
case "append":
|
||||
if (edit.pos) {
|
||||
lineKey = `i:${edit.pos.line}`;
|
||||
break;
|
||||
}
|
||||
case "replace_range":
|
||||
lineKey = `r:${edit.pos.line}:${edit.end.line}`;
|
||||
break;
|
||||
case "append_at":
|
||||
lineKey = `i:${edit.pos.line}`;
|
||||
break;
|
||||
case "prepend_at":
|
||||
lineKey = `ib:${edit.pos.line}`;
|
||||
break;
|
||||
case "append_file":
|
||||
lineKey = "ieof";
|
||||
break;
|
||||
case "prepend":
|
||||
if (edit.pos) {
|
||||
lineKey = `ib:${edit.pos.line}`;
|
||||
break;
|
||||
}
|
||||
case "prepend_file":
|
||||
lineKey = "ibef";
|
||||
break;
|
||||
}
|
||||
@@ -594,20 +615,28 @@ export function applyHashlineEdits(
|
||||
let sortLine: number;
|
||||
let precedence: number;
|
||||
switch (edit.op) {
|
||||
case "replace":
|
||||
if (!edit.end) {
|
||||
sortLine = edit.pos.line;
|
||||
} else {
|
||||
sortLine = edit.end.line;
|
||||
}
|
||||
case "replace_line":
|
||||
sortLine = edit.pos.line;
|
||||
precedence = 0;
|
||||
break;
|
||||
case "append":
|
||||
sortLine = edit.pos ? edit.pos.line : fileLines.length + 1;
|
||||
case "replace_range":
|
||||
sortLine = edit.end.line;
|
||||
precedence = 0;
|
||||
break;
|
||||
case "append_at":
|
||||
sortLine = edit.pos.line;
|
||||
precedence = 1;
|
||||
break;
|
||||
case "prepend":
|
||||
sortLine = edit.pos ? edit.pos.line : 0;
|
||||
case "prepend_at":
|
||||
sortLine = edit.pos.line;
|
||||
precedence = 2;
|
||||
break;
|
||||
case "append_file":
|
||||
sortLine = fileLines.length + 1;
|
||||
precedence = 1;
|
||||
break;
|
||||
case "prepend_file":
|
||||
sortLine = 0;
|
||||
precedence = 2;
|
||||
break;
|
||||
}
|
||||
@@ -619,101 +648,84 @@ export function applyHashlineEdits(
|
||||
// Apply edits bottom-up
|
||||
for (const { edit, idx } of annotated) {
|
||||
switch (edit.op) {
|
||||
case "replace": {
|
||||
if (!edit.end) {
|
||||
const origLines = originalFileLines.slice(edit.pos.line - 1, edit.pos.line);
|
||||
const newLines = edit.lines;
|
||||
if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
|
||||
noopEdits.push({
|
||||
editIndex: idx,
|
||||
loc: `${edit.pos.line}#${edit.pos.hash}`,
|
||||
current: origLines.join("\n"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
fileLines.splice(edit.pos.line - 1, 1, ...newLines);
|
||||
trackFirstChanged(edit.pos.line);
|
||||
} else {
|
||||
const count = edit.end.line - edit.pos.line + 1;
|
||||
const newLines = [...edit.lines];
|
||||
const trailingReplacementLine = newLines[newLines.length - 1]?.trimEnd();
|
||||
const nextSurvivingLine = fileLines[edit.end.line]?.trimEnd();
|
||||
if (
|
||||
shouldAutocorrect(trailingReplacementLine, nextSurvivingLine) &&
|
||||
// Safety: only correct when end-line content differs from the duplicate.
|
||||
// If end already points to the boundary, matching next line is coincidence.
|
||||
fileLines[edit.end.line - 1]?.trimEnd() !== trailingReplacementLine
|
||||
) {
|
||||
newLines.pop();
|
||||
warnings.push(
|
||||
`Auto-corrected range replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}: removed trailing replacement line "${trailingReplacementLine}" that duplicated next surviving line`,
|
||||
);
|
||||
}
|
||||
const leadingReplacementLine = newLines[0]?.trimEnd();
|
||||
const prevSurvivingLine = fileLines[edit.pos.line - 2]?.trimEnd();
|
||||
if (
|
||||
shouldAutocorrect(leadingReplacementLine, prevSurvivingLine) &&
|
||||
// Safety: only correct when pos-line content differs from the duplicate.
|
||||
// If pos already points to the boundary, matching prev line is coincidence.
|
||||
fileLines[edit.pos.line - 1]?.trimEnd() !== leadingReplacementLine
|
||||
) {
|
||||
newLines.shift();
|
||||
warnings.push(
|
||||
`Auto-corrected range replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}: removed leading replacement line "${leadingReplacementLine}" that duplicated preceding surviving line`,
|
||||
);
|
||||
}
|
||||
fileLines.splice(edit.pos.line - 1, count, ...newLines);
|
||||
trackFirstChanged(edit.pos.line);
|
||||
case "replace_line": {
|
||||
const origLines = originalFileLines.slice(edit.pos.line - 1, edit.pos.line);
|
||||
const newLines = edit.lines;
|
||||
if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
|
||||
noopEdits.push({
|
||||
editIndex: idx,
|
||||
loc: `${edit.pos.line}#${edit.pos.hash}`,
|
||||
current: origLines.join("\n"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
fileLines.splice(edit.pos.line - 1, 1, ...newLines);
|
||||
trackFirstChanged(edit.pos.line);
|
||||
break;
|
||||
}
|
||||
case "append": {
|
||||
case "replace_range": {
|
||||
const count = edit.end.line - edit.pos.line + 1;
|
||||
fileLines.splice(edit.pos.line - 1, count, ...edit.lines);
|
||||
trackFirstChanged(edit.pos.line);
|
||||
break;
|
||||
}
|
||||
case "append_at": {
|
||||
const inserted = edit.lines;
|
||||
if (inserted.length === 0) {
|
||||
noopEdits.push({
|
||||
editIndex: idx,
|
||||
loc: edit.pos ? `${edit.pos.line}#${edit.pos.hash}` : "EOF",
|
||||
current: edit.pos ? originalFileLines[edit.pos.line - 1] : "",
|
||||
loc: `${edit.pos.line}#${edit.pos.hash}`,
|
||||
current: originalFileLines[edit.pos.line - 1],
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (edit.pos) {
|
||||
fileLines.splice(edit.pos.line, 0, ...inserted);
|
||||
trackFirstChanged(edit.pos.line + 1);
|
||||
} else {
|
||||
if (fileLines.length === 1 && fileLines[0] === "") {
|
||||
fileLines.splice(0, 1, ...inserted);
|
||||
trackFirstChanged(1);
|
||||
} else {
|
||||
fileLines.splice(fileLines.length, 0, ...inserted);
|
||||
trackFirstChanged(fileLines.length - inserted.length + 1);
|
||||
}
|
||||
}
|
||||
fileLines.splice(edit.pos.line, 0, ...inserted);
|
||||
trackFirstChanged(edit.pos.line + 1);
|
||||
break;
|
||||
}
|
||||
case "prepend": {
|
||||
case "prepend_at": {
|
||||
const inserted = edit.lines;
|
||||
if (inserted.length === 0) {
|
||||
noopEdits.push({
|
||||
editIndex: idx,
|
||||
loc: edit.pos ? `${edit.pos.line}#${edit.pos.hash}` : "BOF",
|
||||
current: edit.pos ? originalFileLines[edit.pos.line - 1] : "",
|
||||
loc: `${edit.pos.line}#${edit.pos.hash}`,
|
||||
current: originalFileLines[edit.pos.line - 1],
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (edit.pos) {
|
||||
fileLines.splice(edit.pos.line - 1, 0, ...inserted);
|
||||
trackFirstChanged(edit.pos.line);
|
||||
} else {
|
||||
if (fileLines.length === 1 && fileLines[0] === "") {
|
||||
fileLines.splice(0, 1, ...inserted);
|
||||
} else {
|
||||
fileLines.splice(0, 0, ...inserted);
|
||||
}
|
||||
fileLines.splice(edit.pos.line - 1, 0, ...inserted);
|
||||
trackFirstChanged(edit.pos.line);
|
||||
break;
|
||||
}
|
||||
case "append_file": {
|
||||
const inserted = edit.lines;
|
||||
if (inserted.length === 0) {
|
||||
noopEdits.push({ editIndex: idx, loc: "EOF", current: "" });
|
||||
break;
|
||||
}
|
||||
if (fileLines.length === 1 && fileLines[0] === "") {
|
||||
fileLines.splice(0, 1, ...inserted);
|
||||
trackFirstChanged(1);
|
||||
} else {
|
||||
fileLines.splice(fileLines.length, 0, ...inserted);
|
||||
trackFirstChanged(fileLines.length - inserted.length + 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "prepend_file": {
|
||||
const inserted = edit.lines;
|
||||
if (inserted.length === 0) {
|
||||
noopEdits.push({ editIndex: idx, loc: "BOF", current: "" });
|
||||
break;
|
||||
}
|
||||
if (fileLines.length === 1 && fileLines[0] === "") {
|
||||
fileLines.splice(0, 1, ...inserted);
|
||||
} else {
|
||||
fileLines.splice(0, 0, ...inserted);
|
||||
}
|
||||
trackFirstChanged(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -174,16 +174,35 @@ export function hashlineParseText(edit: string[] | string | null): string[] {
|
||||
return stripNewLinePrefixes(edit);
|
||||
}
|
||||
|
||||
const linesSchema = Type.Union([
|
||||
Type.Array(Type.String(), { description: "content (preferred format)" }),
|
||||
Type.String(),
|
||||
Type.Null(),
|
||||
]);
|
||||
|
||||
const locSchema = Type.Union(
|
||||
[
|
||||
Type.Literal("append"),
|
||||
Type.Literal("prepend"),
|
||||
Type.Object({ append: Type.String({ description: "anchor" }) }),
|
||||
Type.Object({ prepend: Type.String({ description: "anchor" }) }),
|
||||
Type.Object({
|
||||
line: Type.String({ description: "anchor" }),
|
||||
}),
|
||||
Type.Object({
|
||||
block: Type.Object({
|
||||
pos: Type.String({ description: "anchor" }),
|
||||
end: Type.String({ description: "limit position" }),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
{ description: "insert location" },
|
||||
);
|
||||
|
||||
const hashlineEditSchema = Type.Object(
|
||||
{
|
||||
op: StringEnum(["replace", "append", "prepend"]),
|
||||
pos: Type.Optional(Type.String({ description: "anchor" })),
|
||||
end: Type.Optional(Type.String({ description: "limit position" })),
|
||||
lines: Type.Union([
|
||||
Type.Array(Type.String(), { description: "content (preferred format)" }),
|
||||
Type.String(),
|
||||
Type.Null(),
|
||||
]),
|
||||
loc: locSchema,
|
||||
content: linesSchema,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
@@ -206,45 +225,48 @@ export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Map flat tool-schema edits (tag/end) into typed HashlineEdit objects.
|
||||
* Map loc/content tool-schema edits into typed HashlineEdit objects.
|
||||
*
|
||||
* Resilient: as long as at least one anchor exists, we execute.
|
||||
* - replace + tag only → single-line replace
|
||||
* - replace + tag + end → range replace
|
||||
* - append + tag or end → append after that anchor
|
||||
* - prepend + tag or end → prepend before that anchor
|
||||
* - no anchors → file-level append/prepend (only for those ops)
|
||||
*
|
||||
* Unknown ops default to "replace".
|
||||
* Each edit entry has a `loc` (where to edit) and `content` (what to insert/replace).
|
||||
* loc can be:
|
||||
* - "append" / "prepend" — file-level insert
|
||||
* - { append: anchor } / { prepend: anchor } — insert relative to anchor
|
||||
* - { replace_line: anchor } — replace one line
|
||||
* - { replace_block: { pos, end } } — replace inclusive range
|
||||
*/
|
||||
function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
|
||||
const result: HashlineEdit[] = [];
|
||||
for (const edit of edits) {
|
||||
const lines = hashlineParseText(edit.lines);
|
||||
const tag = edit.pos ? tryParseTag(edit.pos) : undefined;
|
||||
const end = edit.end ? tryParseTag(edit.end) : undefined;
|
||||
const lines = hashlineParseText(edit.content);
|
||||
const loc = edit.loc;
|
||||
|
||||
// Normalize op — default unknown values to "replace"
|
||||
const op = edit.op === "append" || edit.op === "prepend" ? edit.op : "replace";
|
||||
switch (op) {
|
||||
case "replace": {
|
||||
if (tag && end) {
|
||||
result.push({ op: "replace", pos: tag, end, lines });
|
||||
} else if (tag || end) {
|
||||
result.push({ op: "replace", pos: tag || end!, lines });
|
||||
} else {
|
||||
throw new Error("Replace requires at least one anchor (tag or end).");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "append": {
|
||||
result.push({ op: "append", pos: tag ?? end, lines });
|
||||
break;
|
||||
}
|
||||
case "prepend": {
|
||||
result.push({ op: "prepend", pos: end ?? tag, lines });
|
||||
break;
|
||||
if (loc === "append") {
|
||||
result.push({ op: "append_file", lines });
|
||||
} else if (loc === "prepend") {
|
||||
result.push({ op: "prepend_file", lines });
|
||||
} else if (typeof loc === "object") {
|
||||
if ("append" in loc) {
|
||||
const anchor = tryParseTag(loc.append);
|
||||
if (!anchor) throw new Error("append requires a valid anchor.");
|
||||
result.push({ op: "append_at", pos: anchor, lines });
|
||||
} else if ("prepend" in loc) {
|
||||
const anchor = tryParseTag(loc.prepend);
|
||||
if (!anchor) throw new Error("prepend requires a valid anchor.");
|
||||
result.push({ op: "prepend_at", pos: anchor, lines });
|
||||
} else if ("line" in loc) {
|
||||
const anchor = tryParseTag(loc.line);
|
||||
if (!anchor) throw new Error("line requires a valid anchor.");
|
||||
result.push({ op: "replace_line", pos: anchor, lines });
|
||||
} else if ("block" in loc) {
|
||||
const posAnchor = tryParseTag(loc.block.pos);
|
||||
const endAnchor = tryParseTag(loc.block.end);
|
||||
if (!posAnchor || !endAnchor) throw new Error("block requires valid pos and end anchors.");
|
||||
result.push({ op: "replace_range", pos: posAnchor, end: endAnchor, lines });
|
||||
} else {
|
||||
throw new Error("Unknown loc shape. Expected append, prepend, line, or block.");
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Invalid loc value: ${JSON.stringify(loc)}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -552,12 +574,10 @@ export class EditTool implements AgentTool<TInput> {
|
||||
const lines: string[] = [];
|
||||
for (const edit of edits) {
|
||||
// For file creation, only anchorless appends/prepends are valid
|
||||
if ((edit.op === "append" || edit.op === "prepend") && !edit.pos && !edit.end) {
|
||||
if (edit.op === "prepend") {
|
||||
lines.unshift(...hashlineParseText(edit.lines));
|
||||
} else {
|
||||
lines.push(...hashlineParseText(edit.lines));
|
||||
}
|
||||
if (edit.loc === "append") {
|
||||
lines.push(...hashlineParseText(edit.content));
|
||||
} else if (edit.loc === "prepend") {
|
||||
lines.unshift(...hashlineParseText(edit.content));
|
||||
} else {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
@@ -582,7 +602,7 @@ export class EditTool implements AgentTool<TInput> {
|
||||
const originalNormalized = normalizeToLF(text);
|
||||
let normalizedText = originalNormalized;
|
||||
|
||||
// Apply anchor-based edits first (replace, append, prepend)
|
||||
// Apply anchor-based edits first (replace, append_at, prepend_at)
|
||||
const anchorResult = applyHashlineEdits(normalizedText, anchorEdits);
|
||||
normalizedText = anchorResult.lines;
|
||||
|
||||
@@ -612,20 +632,18 @@ export class EditTool implements AgentTool<TInput> {
|
||||
for (const edit of anchorEdits) {
|
||||
refs.length = 0;
|
||||
switch (edit.op) {
|
||||
case "replace":
|
||||
if (edit.end) {
|
||||
refs.push(edit.end, edit.pos);
|
||||
} else {
|
||||
refs.push(edit.pos);
|
||||
}
|
||||
case "replace_line":
|
||||
refs.push(edit.pos);
|
||||
break;
|
||||
case "append":
|
||||
if (edit.pos) refs.push(edit.pos);
|
||||
case "replace_range":
|
||||
refs.push(edit.end, edit.pos);
|
||||
break;
|
||||
case "prepend":
|
||||
if (edit.pos) refs.push(edit.pos);
|
||||
case "append_at":
|
||||
case "prepend_at":
|
||||
refs.push(edit.pos);
|
||||
break;
|
||||
default:
|
||||
case "append_file":
|
||||
case "prepend_file":
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -157,20 +157,28 @@ function formatStreamingHashlineEdits(edits: Partial<HashlineToolEdit>[], uiThem
|
||||
return { srcLabel: "• (incomplete edit)", dst: "" };
|
||||
}
|
||||
|
||||
const contentLines = Array.isArray(edit.lines) ? (edit.lines as string[]).join("\n") : "";
|
||||
const contentLines = Array.isArray(edit.content) ? (edit.content as string[]).join("\n") : "";
|
||||
const loc = edit.loc;
|
||||
|
||||
const op = typeof edit.op === "string" ? edit.op : "?";
|
||||
const pos = typeof edit.pos === "string" ? edit.pos : undefined;
|
||||
const end = typeof edit.end === "string" ? edit.end : undefined;
|
||||
|
||||
if (pos && end && pos !== end) {
|
||||
return { srcLabel: `• ${op} ${pos}…${end}`, dst: contentLines };
|
||||
if (loc === "append" || loc === "prepend") {
|
||||
return { srcLabel: `• ${loc} (file-level)`, dst: contentLines };
|
||||
}
|
||||
const anchor = pos ?? end;
|
||||
if (anchor) {
|
||||
return { srcLabel: `\u2022 ${op} ${anchor}`, dst: contentLines };
|
||||
if (typeof loc === "object" && loc) {
|
||||
if ("block" in loc && typeof loc.block === "object" && loc.block) {
|
||||
const rb = loc.block as { pos?: string; end?: string };
|
||||
return { srcLabel: `• block ${rb.pos ?? "?"}…${rb.end ?? "?"}`, dst: contentLines };
|
||||
}
|
||||
if ("line" in loc) {
|
||||
return { srcLabel: `• line ${(loc as { line: string }).line}`, dst: contentLines };
|
||||
}
|
||||
if ("append" in loc) {
|
||||
return { srcLabel: `• append ${(loc as { append: string }).append}`, dst: contentLines };
|
||||
}
|
||||
if ("prepend" in loc) {
|
||||
return { srcLabel: `• prepend ${(loc as { prepend: string }).prepend}`, dst: contentLines };
|
||||
}
|
||||
}
|
||||
return { srcLabel: `\u2022 ${op} (file-level)`, dst: contentLines };
|
||||
return { srcLabel: "• (unknown edit)", dst: contentLines };
|
||||
}
|
||||
}
|
||||
function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string {
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
Applies precise, surgical file edits by referencing `LINE#ID` tags from `read` output. Each tag uniquely identifies a line, so edits remain stable even when lines shift.
|
||||
Applies precise file edits using `LINE#ID` anchors from `read` output.
|
||||
|
||||
Read the file first to get fresh tags. Submit one `edit` call per file with all operations batched — tags shift after each edit, so multiple calls require re-reading between them.
|
||||
Read the file first. Copy anchors exactly from the latest `read` output. In one `edit` call, batch all edits for one file. After any successful edit, re-read before editing that file again.
|
||||
|
||||
This matters: your output is checked against the real file state. Invalid anchors, duplicated boundary lines, or semantically equivalent rewrites will fail.
|
||||
|
||||
<operations>
|
||||
**`path`** — the path to the file to edit.
|
||||
**`move`** — if set, move the file to the given path.
|
||||
**`delete`** — if true, delete the file.
|
||||
**Top level**
|
||||
- `path` — file path
|
||||
- `move` — optional rename target
|
||||
- `delete` — optional whole-file delete
|
||||
- `edits` — array of `{ loc, content }` entries
|
||||
|
||||
**`edits[n].pos`** — the anchor line. Meaning depends on `op`:
|
||||
- if `replace`: first line to rewrite
|
||||
- if `prepend`: line to insert new lines **before**; omit for beginning of file
|
||||
- if `append`: line to insert new lines **after**; omit for end of file
|
||||
**`edits[n].end`** — range replace only. The last line of the range (inclusive). Omit for single-line replace.
|
||||
**`edits[n].lines`** — the replacement content:
|
||||
- for `replace`: the exact lines that will replace `[pos, end??pos]` inclusively (or the single `pos` line when `end` is omitted)
|
||||
- for `prepend`/`append`: the new lines to insert
|
||||
- `[""]` — blank line
|
||||
- `null` or `[]` — delete if replace
|
||||
- If `lines` contains content that already exists after `end`, those lines **will be duplicated** in the output.
|
||||
- Keep `lines` to exactly what belongs inside the consumed range.
|
||||
- Ops are applied bottom-up. Tags **MUST** be referenced from the most recent `read` output.
|
||||
**Edit entry**: `{ loc, content }`
|
||||
- `loc` — where to apply the edit (see below)
|
||||
- `content` — replacement/inserted lines (array of strings preferred, `null` to delete)
|
||||
|
||||
**`loc` values**
|
||||
- `"append"` / `"prepend"` — insert at end/start of file
|
||||
- `{ append: "N#ID" }` / `{ prepend: "N#ID" }` — insert after/before anchored line
|
||||
- `{ line: "N#ID" }` — replace exactly one anchored line
|
||||
- `{ block: { pos: "N#ID", end: "N#ID" } }` — replace inclusive `pos..end`
|
||||
</operations>
|
||||
|
||||
<examples>
|
||||
@@ -45,58 +45,14 @@ All examples below reference the same file, `util.ts`:
|
||||
{{hlinefull 18 "}"}}
|
||||
```
|
||||
|
||||
<example name="single-line replace">
|
||||
Change the timeout from `5000` to `30_000`:
|
||||
<example name="replace a block body">
|
||||
Replace only the catch body. Do not target the shared boundary line `} catch (err) {`.
|
||||
```
|
||||
{
|
||||
path: "util.ts",
|
||||
edits: [{
|
||||
op: "replace",
|
||||
pos: {{hlineref 2 "const timeout = 5000;"}},
|
||||
lines: ["const timeout = 30_000;"]
|
||||
}]
|
||||
}
|
||||
```
|
||||
</example>
|
||||
|
||||
<example name="delete lines">
|
||||
Single line — `lines: null` deletes entirely:
|
||||
```
|
||||
{
|
||||
path: "util.ts",
|
||||
edits: [{
|
||||
op: "replace",
|
||||
pos: {{hlineref 1 "// @ts-ignore"}},
|
||||
lines: null
|
||||
}]
|
||||
}
|
||||
```
|
||||
Range — remove the legacy block (lines 10–11):
|
||||
```
|
||||
{
|
||||
path: "util.ts",
|
||||
edits: [{
|
||||
op: "replace",
|
||||
pos: {{hlineref 10 "\t// TODO: remove after migration"}},
|
||||
end: {{hlineref 11 "\tlegacy();"}},
|
||||
lines: null
|
||||
}]
|
||||
}
|
||||
```
|
||||
</example>
|
||||
|
||||
<example name="rewrite a block body — shape (a)">
|
||||
Replace the catch body with smarter error handling. Shape (a): `pos` is the first body line, `end` is the last body line. The catch header (line 14) and its closer (line 17) are outside the range and stay untouched.
|
||||
|
||||
When changing body content, replace the **entire** body span — not just one line inside it. Patching one line leaves the rest of the body stale.
|
||||
```
|
||||
{
|
||||
path: "util.ts",
|
||||
edits: [{
|
||||
op: "replace",
|
||||
pos: {{hlineref 15 "\t\tconsole.error(err);"}},
|
||||
end: {{hlineref 16 "\t\treturn null;"}},
|
||||
lines: [
|
||||
loc: { block: { pos: {{hlineref 15 "\t\tconsole.error(err);"}}, end: {{hlineref 16 "\t\treturn null;"}} } },
|
||||
content: [
|
||||
"\t\tif (isEnoent(err)) return null;",
|
||||
"\t\tthrow err;"
|
||||
]
|
||||
@@ -105,62 +61,38 @@ When changing body content, replace the **entire** body span — not just one li
|
||||
```
|
||||
</example>
|
||||
|
||||
<example name="replace whole block — shape (b)">
|
||||
Simplify `beta()` to a one-liner. Shape (b): `pos`=header, `end`=closer, re-emit all in `lines`.
|
||||
|
||||
Bad — `end` stops at the inner `\t}` on line 17, so the outer `}` on line 18 survives. Result: two consecutive `}` lines.
|
||||
<example name="replace one line">
|
||||
```
|
||||
{
|
||||
path: "util.ts",
|
||||
edits: [{
|
||||
op: "replace",
|
||||
pos: {{hlineref 9 "function beta() {"}},
|
||||
end: {{hlineref 17 "\t}"}},
|
||||
lines: [
|
||||
"function beta() {",
|
||||
"\treturn parse(data);",
|
||||
"}"
|
||||
]
|
||||
}]
|
||||
}
|
||||
```
|
||||
Good — `end` includes the function's own `}` on line 18, so the old closer is consumed:
|
||||
```
|
||||
{
|
||||
path: "util.ts",
|
||||
edits: [{
|
||||
op: "replace",
|
||||
pos: {{hlineref 9 "function beta() {"}},
|
||||
end: {{hlineref 18 "}"}},
|
||||
lines: [
|
||||
"function beta() {",
|
||||
"\treturn parse(data);",
|
||||
"}"
|
||||
]
|
||||
loc: { line: {{hlineref 2 "const timeout = 5000;"}} },
|
||||
content: ["const timeout = 30_000;"]
|
||||
}]
|
||||
}
|
||||
```
|
||||
</example>
|
||||
|
||||
<example name="avoid shared boundary lines">
|
||||
Do not anchor `replace` on a mixed boundary line such as `} catch (err) {`, `} else {`, `}),`, or `},{`. Those lines belong to two adjacent structures at once.
|
||||
|
||||
Bad — if you need to change code on both sides of that line, replacing just the boundary span will usually leave one side's syntax behind.
|
||||
|
||||
Good — choose one of two safe shapes instead:
|
||||
- move inward and replace only body-owned lines
|
||||
- expand outward and replace one whole owned block, consuming its real closer/separator too
|
||||
</example>
|
||||
|
||||
<example name="insert between sibling declarations">
|
||||
Add a `gamma()` function between `alpha()` and `beta()`. Use `prepend` on the next declaration — not `append` on the previous block's closing brace — so the anchor is a stable declaration boundary.
|
||||
<example name="delete a range">
|
||||
```
|
||||
{
|
||||
path: "util.ts",
|
||||
edits: [{
|
||||
op: "prepend",
|
||||
pos: {{hlineref 9 "function beta() {"}},
|
||||
lines: [
|
||||
loc: { block: { pos: {{hlineref 10 "\t// TODO: remove after migration"}}, end: {{hlineref 11 "\tlegacy();"}} } },
|
||||
content: null
|
||||
}]
|
||||
}
|
||||
```
|
||||
</example>
|
||||
|
||||
<example name="insert before sibling">
|
||||
When adding a sibling declaration, prefer `prepend` on the next declaration.
|
||||
```
|
||||
{
|
||||
path: "util.ts",
|
||||
edits: [{
|
||||
loc: { prepend: {{hlineref 9 "function beta() {"}} },
|
||||
content: [
|
||||
"function gamma() {",
|
||||
"\tvalidate();",
|
||||
"}",
|
||||
@@ -169,22 +101,17 @@ Add a `gamma()` function between `alpha()` and `beta()`. Use `prepend` on the ne
|
||||
}]
|
||||
}
|
||||
```
|
||||
Use a trailing `""` to preserve the blank line between sibling declarations.
|
||||
</example>
|
||||
</examples>
|
||||
|
||||
<critical>
|
||||
- You **MUST NOT** use this tool to reformat, reindent, or adjust whitespace — run the project's formatter instead.
|
||||
- Every tag **MUST** be copied exactly from your most recent `read` output as `N#ID`. Stale or mistyped tags cause mismatches.
|
||||
- Edit payload: `{ path, edits[] }`. Each entry: `op`, `lines`, optional `pos`/`end`. No extra keys.
|
||||
- For `append`/`prepend`, `lines` **MUST** contain only the newly introduced content. Do not re-emit surrounding content, or terminators that already exist.
|
||||
- When changing existing code near a block tail or closing delimiter, default to `replace` over the owned span instead of inserting around the boundary.
|
||||
- When adding a sibling declaration, default to `prepend` on the next sibling declaration instead of `append` on the previous block's closing brace.
|
||||
- **Block boundaries travel together.** For a block `{ header / body / closer }`, there are exactly two valid replace shapes: (a) replace only the body — `pos`=first body line, `end`=last body line, leave the header and closer untouched; or (b) replace the whole block — `pos`=header, `end`=closer, re-emit all three in `lines`. Never split them: do not set `end` to the closer while omitting it from `lines` (deletes it), and do not emit the closer in `lines` without including it in `end` (duplicates it). This applies to every block terminator: `}`, `continue`, `break`, `return`, `throw`.
|
||||
- **Never target shared boundary lines.** Do not use `replace` spans that start, end, or pivot on a line that closes one construct and opens/separates another, such as `},{`, `}),`, `} else {`, or `} catch (err) {`. Those lines are not owned by a single block. Move the range inward to body-only lines, or widen it to consume one whole owned construct including its true trailing delimiter.
|
||||
- **`lines` must not extend past `end`.** `lines` replaces exactly `pos..end`. Content after `end` survives. If you include lines in `lines` that exist after `end`, they will appear twice. Either extend `end` to cover all lines you are re-emitting, or remove the extra lines from `lines`.
|
||||
- `lines` entries **MUST** be literal file content with indentation copied exactly from the `read` output. If the file uses tabs, use a real tab character.
|
||||
- After any successful `edit` call on a file, the next change to that same file **MUST** start with a fresh `read`. Do not chain a second `edit` call off stale mental state, even if the intended range is nearby.
|
||||
- If you need a second change in the same local region, default to one wider `replace` over the whole owned block instead of a sequence of micro-edits on adjacent lines. Repeated small patches in a moving region are unstable.
|
||||
- If a local region is already malformed or a prior patch partially landed, stop nibbling at it. Re-read the file and replace the full owned block from a stable boundary; for a small file, prefer rewriting the file over stacking more tiny repairs.
|
||||
- Make the minimum exact edit. Do not rewrite nearby code unless the consumed range requires it.
|
||||
- Use anchors exactly as `N#ID` from the latest `read` output.
|
||||
- `block` requires both `pos` and `end`. Other anchored ops require one anchor.
|
||||
- Replace exactly the owned span. If `content` re-emits content beyond `end`, it will duplicate.
|
||||
- **Boundary duplication trap**: when replacing a block, `end` must be the **last line of the block** (e.g. the closing `}`), not the last *content* line before it. Otherwise the closing delimiter survives and your replacement adds a second copy.
|
||||
- Do not target shared boundary lines such as `} else {`, `} catch (…) {`, `}),`, or `},{`.
|
||||
- For a block, either replace only the body or replace the whole block. Do not split block boundaries.
|
||||
- `content` must be literal file content with matching indentation. If the file uses tabs, use real tabs.
|
||||
- Do not use this tool to reformat or clean up unrelated code.
|
||||
</critical>
|
||||
@@ -13,6 +13,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { $env, getAgentDbPath, getAgentDir, getProjectDir, logger, postmortem } from "@oh-my-pi/pi-utils";
|
||||
import chalk from "chalk";
|
||||
import { AsyncJobManager } from "./async";
|
||||
import { createAutoresearchExtension } from "./autoresearch";
|
||||
import { loadCapability } from "./capability";
|
||||
import { type Rule, ruleCapability } from "./capability/rule";
|
||||
import { ModelRegistry } from "./config/model-registry";
|
||||
@@ -143,6 +144,9 @@ export interface CreateAgentSessionOptions {
|
||||
|
||||
/** System prompt. String replaces default, function receives default and returns final. */
|
||||
systemPrompt?: string | ((defaultPrompt: string) => string);
|
||||
/** Optional provider-facing session identifier for prompt caches and sticky auth selection.
|
||||
* Keeps persisted session files isolated while reusing provider-side caches. */
|
||||
providerSessionId?: string;
|
||||
|
||||
/** Custom tools to register (in addition to built-in tools). Accepts both CustomTool and ToolDefinition. */
|
||||
customTools?: (CustomTool | ToolDefinition)[];
|
||||
@@ -666,7 +670,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
logger.time("sessionManager", () =>
|
||||
SessionManager.create(cwd, SessionManager.getDefaultSessionDir(cwd, agentDir)),
|
||||
);
|
||||
const sessionId = sessionManager.getSessionId();
|
||||
const providerSessionId = options.providerSessionId ?? sessionManager.getSessionId();
|
||||
const modelApiKeyAvailability = new Map<string, boolean>();
|
||||
const getModelAvailabilityKey = (candidate: Model): string =>
|
||||
`${candidate.provider}\u0000${candidate.baseUrl ?? ""}`;
|
||||
@@ -677,15 +681,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
return cached;
|
||||
}
|
||||
|
||||
const hasKey = !!(await modelRegistry.getApiKey(candidate, sessionId));
|
||||
const hasKey = !!(await modelRegistry.getApiKey(candidate, providerSessionId));
|
||||
modelApiKeyAvailability.set(availabilityKey, hasKey);
|
||||
return hasKey;
|
||||
};
|
||||
|
||||
// Check if session has existing data to restore
|
||||
const existingSession = logger.time("loadSession", () => sessionManager.buildSessionContext());
|
||||
const hasExistingSession = existingSession.messages.length > 0;
|
||||
const hasThinkingEntry = sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
|
||||
const existingBranch = sessionManager.getBranch();
|
||||
const hasExistingSession = existingBranch.length > 0;
|
||||
const hasThinkingEntry = existingBranch.some(entry => entry.type === "thinking_level_change");
|
||||
const hasServiceTierEntry = existingBranch.some(entry => entry.type === "service_tier_change");
|
||||
|
||||
const hasExplicitModel = options.model !== undefined || options.modelPattern !== undefined;
|
||||
const modelMatchPreferences = {
|
||||
@@ -1010,6 +1016,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
}
|
||||
|
||||
const inlineExtensions: ExtensionFactory[] = options.extensions ? [...options.extensions] : [];
|
||||
inlineExtensions.push(createAutoresearchExtension);
|
||||
if (customTools.length > 0) {
|
||||
inlineExtensions.push(createCustomToolsExtension(customTools));
|
||||
}
|
||||
@@ -1283,9 +1290,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
const normalizedRequested = requestedToolNames.filter(name => toolRegistry.has(name));
|
||||
const includeExitPlanMode = requestedToolNames.includes("exit_plan_mode");
|
||||
const mcpDiscoveryEnabled = settings.get("mcp.discoveryMode") ?? false;
|
||||
const defaultInactiveToolNames = new Set(
|
||||
registeredTools.filter(tool => tool.definition.defaultInactive).map(tool => tool.definition.name),
|
||||
);
|
||||
const requestedActiveToolNames = includeExitPlanMode
|
||||
? normalizedRequested
|
||||
: normalizedRequested.filter(name => name !== "exit_plan_mode");
|
||||
const initialRequestedActiveToolNames = options.toolNames
|
||||
? requestedActiveToolNames
|
||||
: requestedActiveToolNames.filter(name => !defaultInactiveToolNames.has(name));
|
||||
const explicitlyRequestedMCPToolNames = options.toolNames
|
||||
? requestedActiveToolNames.filter(name => name.startsWith("mcp_"))
|
||||
: [];
|
||||
@@ -1300,7 +1313,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
: [];
|
||||
let initialSelectedMCPToolNames: string[] = [];
|
||||
let defaultSelectedMCPToolNames: string[] = [];
|
||||
let initialToolNames = [...requestedActiveToolNames];
|
||||
let initialToolNames = [...initialRequestedActiveToolNames];
|
||||
if (mcpDiscoveryEnabled) {
|
||||
const restoredSelectedMCPToolNames = existingSession.selectedMCPToolNames.filter(name => toolRegistry.has(name));
|
||||
defaultSelectedMCPToolNames = [
|
||||
@@ -1311,7 +1324,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
: [...new Set([...restoredSelectedMCPToolNames, ...defaultSelectedMCPToolNames])];
|
||||
initialToolNames = [
|
||||
...new Set([
|
||||
...requestedActiveToolNames.filter(name => !name.startsWith("mcp_")),
|
||||
...initialRequestedActiveToolNames.filter(name => !name.startsWith("mcp_")),
|
||||
...initialSelectedMCPToolNames,
|
||||
]),
|
||||
];
|
||||
@@ -1320,7 +1333,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
// Custom tools and extension-registered tools are always included regardless of toolNames filter
|
||||
const alwaysInclude: string[] = [
|
||||
...(options.customTools?.map(t => (isCustomTool(t) ? t.name : t.name)) ?? []),
|
||||
...registeredTools.map(t => t.definition.name),
|
||||
...registeredTools.filter(t => !t.definition.defaultInactive).map(t => t.definition.name),
|
||||
];
|
||||
for (const name of alwaysInclude) {
|
||||
if (mcpDiscoveryEnabled && name.startsWith("mcp_")) {
|
||||
@@ -1417,6 +1430,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
openaiWebsocketSetting === "on" ? true : openaiWebsocketSetting === "off" ? false : undefined;
|
||||
const serviceTierSetting = settings.get("serviceTier");
|
||||
|
||||
const initialServiceTier = hasServiceTierEntry
|
||||
? existingSession.serviceTier
|
||||
: serviceTierSetting === "none"
|
||||
? undefined
|
||||
: serviceTierSetting;
|
||||
|
||||
agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt,
|
||||
@@ -1426,7 +1445,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
},
|
||||
convertToLlm: convertToLlmFinal,
|
||||
onPayload,
|
||||
sessionId: sessionManager.getSessionId(),
|
||||
sessionId: providerSessionId,
|
||||
transformContext,
|
||||
steeringMode: settings.get("steeringMode") ?? "one-at-a-time",
|
||||
followUpMode: settings.get("followUpMode") ?? "one-at-a-time",
|
||||
@@ -1438,14 +1457,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
minP: settings.get("minP") >= 0 ? settings.get("minP") : undefined,
|
||||
presencePenalty: settings.get("presencePenalty") >= 0 ? settings.get("presencePenalty") : undefined,
|
||||
repetitionPenalty: settings.get("repetitionPenalty") >= 0 ? settings.get("repetitionPenalty") : undefined,
|
||||
serviceTier: serviceTierSetting === "none" ? undefined : serviceTierSetting,
|
||||
serviceTier: initialServiceTier,
|
||||
kimiApiFormat: settings.get("providers.kimiApiFormat") ?? "anthropic",
|
||||
preferWebsockets: preferOpenAICodexWebsockets,
|
||||
getToolContext: tc => toolContextStore.getContext(tc),
|
||||
getApiKey: async provider => {
|
||||
// Use the provider argument from the in-flight request;
|
||||
// agent.state.model may already be switched mid-turn.
|
||||
const key = await modelRegistry.getApiKeyForProvider(provider, sessionId);
|
||||
// Use the provider-facing session id for sticky credential selection so cache keys
|
||||
// and provider auth affinity stay aligned across fresh benchmark sessions.
|
||||
const key = await modelRegistry.getApiKeyForProvider(provider, providerSessionId);
|
||||
if (!key) {
|
||||
throw new Error(`No API key found for provider "${provider}"`);
|
||||
}
|
||||
@@ -1476,9 +1495,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
// Restore messages if session has existing data
|
||||
if (hasExistingSession) {
|
||||
agent.replaceMessages(existingSession.messages);
|
||||
if (!hasThinkingEntry) {
|
||||
sessionManager.appendThinkingLevelChange(thinkingLevel);
|
||||
}
|
||||
} else {
|
||||
// Save initial model and thinking level for new sessions so they can be restored on resume
|
||||
if (model) {
|
||||
@@ -1509,6 +1525,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
mcpDiscoveryEnabled,
|
||||
initialSelectedMCPToolNames,
|
||||
defaultSelectedMCPToolNames,
|
||||
persistInitialMCPToolSelection: !hasExistingSession,
|
||||
defaultSelectedMCPServerNames: [...discoveryDefaultServers],
|
||||
ttsrManager,
|
||||
obfuscator,
|
||||
@@ -1519,8 +1536,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
if (model?.api === "openai-codex-responses") {
|
||||
try {
|
||||
await logger.timeAsync("prewarmCodexWebsocket", prewarmOpenAICodexResponses, model, {
|
||||
apiKey: await modelRegistry.getApiKey(model, sessionId),
|
||||
sessionId,
|
||||
apiKey: await modelRegistry.getApiKey(model, providerSessionId),
|
||||
sessionId: providerSessionId,
|
||||
preferWebsockets: preferOpenAICodexWebsockets,
|
||||
providerSessionState: session.providerSessionState,
|
||||
});
|
||||
|
||||
@@ -225,6 +225,8 @@ export interface AgentSessionConfig {
|
||||
mcpDiscoveryEnabled?: boolean;
|
||||
/** MCP tool names to activate for the current session when discovery mode is enabled. */
|
||||
initialSelectedMCPToolNames?: string[];
|
||||
/** Whether constructor-provided MCP defaults should be persisted immediately. */
|
||||
persistInitialMCPToolSelection?: boolean;
|
||||
/** MCP server names whose tools should seed discovery-mode sessions whenever those servers are connected. */
|
||||
defaultSelectedMCPServerNames?: string[];
|
||||
/** MCP tool names that should seed brand-new sessions created from this AgentSession. */
|
||||
@@ -364,6 +366,7 @@ export class AgentSession {
|
||||
#followUpMessages: string[] = [];
|
||||
/** Messages queued to be included with the next user prompt as context ("asides"). */
|
||||
#pendingNextTurnMessages: CustomMessage[] = [];
|
||||
#scheduledHiddenNextTurnGeneration: number | undefined = undefined;
|
||||
#planModeState: PlanModeState | undefined;
|
||||
#planReferenceSent = false;
|
||||
#planReferencePath = "local://PLAN.md";
|
||||
@@ -484,8 +487,11 @@ export class AgentSession {
|
||||
this.#pruneSelectedMCPToolNames();
|
||||
const persistedSelectedMCPToolNames = this.sessionManager.buildSessionContext().selectedMCPToolNames;
|
||||
const currentSelectedMCPToolNames = this.getSelectedMCPToolNames();
|
||||
const persistInitialMCPToolSelection =
|
||||
config.persistInitialMCPToolSelection ?? this.sessionManager.getBranch().length === 0;
|
||||
if (
|
||||
this.#mcpDiscoveryEnabled &&
|
||||
persistInitialMCPToolSelection &&
|
||||
!this.#selectedMCPToolNamesMatch(persistedSelectedMCPToolNames, currentSelectedMCPToolNames)
|
||||
) {
|
||||
this.sessionManager.appendMCPToolSelection(currentSelectedMCPToolNames);
|
||||
@@ -782,7 +788,6 @@ export class AgentSession {
|
||||
attempt: this.#retryAttempt,
|
||||
});
|
||||
this.#retryAttempt = 0;
|
||||
this.#resolveRetry();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,6 +863,7 @@ export class AgentSession {
|
||||
const didRetry = await this.#handleRetryableError(msg);
|
||||
if (didRetry) return; // Retry was initiated, don't proceed to compaction
|
||||
}
|
||||
this.#resolveRetry();
|
||||
|
||||
if (msg.stopReason === "aborted" && this.#checkpointState) {
|
||||
this.#checkpointState = undefined;
|
||||
@@ -2567,6 +2573,74 @@ export class AgentSession {
|
||||
});
|
||||
}
|
||||
|
||||
#queueHiddenNextTurnMessage(message: CustomMessage, triggerTurn: boolean): void {
|
||||
this.#pendingNextTurnMessages.push(message);
|
||||
if (!triggerTurn) return;
|
||||
const generation = this.#promptGeneration;
|
||||
if (this.#scheduledHiddenNextTurnGeneration === generation) {
|
||||
return;
|
||||
}
|
||||
this.#scheduledHiddenNextTurnGeneration = generation;
|
||||
this.#schedulePostPromptTask(
|
||||
async () => {
|
||||
if (this.#scheduledHiddenNextTurnGeneration === generation) {
|
||||
this.#scheduledHiddenNextTurnGeneration = undefined;
|
||||
}
|
||||
if (this.#pendingNextTurnMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.#promptQueuedHiddenNextTurnMessages();
|
||||
} catch {
|
||||
// Leave the hidden next-turn messages queued for the next explicit prompt.
|
||||
}
|
||||
},
|
||||
{
|
||||
generation,
|
||||
onSkip: () => {
|
||||
if (this.#scheduledHiddenNextTurnGeneration === generation) {
|
||||
this.#scheduledHiddenNextTurnGeneration = undefined;
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async #promptQueuedHiddenNextTurnMessages(): Promise<void> {
|
||||
if (this.#pendingNextTurnMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queuedMessages = [...this.#pendingNextTurnMessages];
|
||||
this.#pendingNextTurnMessages = [];
|
||||
const message = queuedMessages[queuedMessages.length - 1];
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prependMessages = queuedMessages.slice(0, -1);
|
||||
const textContent = this.#getCustomMessageTextContent(message);
|
||||
try {
|
||||
await this.#promptWithMessage(message, textContent, {
|
||||
prependMessages,
|
||||
skipPostPromptRecoveryWait: true,
|
||||
});
|
||||
} catch (error) {
|
||||
this.#pendingNextTurnMessages = [...queuedMessages, ...this.#pendingNextTurnMessages];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
#getCustomMessageTextContent(message: Pick<CustomMessage, "content">): string {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content;
|
||||
}
|
||||
return message.content
|
||||
.filter((content): content is TextContent => content.type === "text")
|
||||
.map(content => content.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw an error if the text is an extension command.
|
||||
*/
|
||||
@@ -2607,7 +2681,7 @@ export class AgentSession {
|
||||
};
|
||||
if (this.isStreaming) {
|
||||
if (options?.deliverAs === "nextTurn") {
|
||||
this.#pendingNextTurnMessages.push(appMessage);
|
||||
this.#queueHiddenNextTurnMessage(appMessage, options?.triggerTurn ?? false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2619,6 +2693,22 @@ export class AgentSession {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.deliverAs === "nextTurn") {
|
||||
if (options?.triggerTurn) {
|
||||
await this.agent.prompt(appMessage);
|
||||
return;
|
||||
}
|
||||
this.agent.appendMessage(appMessage);
|
||||
this.sessionManager.appendCustomMessageEntry(
|
||||
message.customType,
|
||||
message.content,
|
||||
message.display,
|
||||
message.details,
|
||||
message.attribution ?? "agent",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.triggerTurn) {
|
||||
await this.agent.prompt(appMessage);
|
||||
return;
|
||||
@@ -2686,9 +2776,9 @@ export class AgentSession {
|
||||
return { steering, followUp };
|
||||
}
|
||||
|
||||
/** Number of pending messages (includes both steering and follow-up) */
|
||||
/** Number of pending messages (includes steering, follow-up, and next-turn messages) */
|
||||
get queuedMessageCount(): number {
|
||||
return this.#steeringMessages.length + this.#followUpMessages.length;
|
||||
return this.#steeringMessages.length + this.#followUpMessages.length + this.#pendingNextTurnMessages.length;
|
||||
}
|
||||
|
||||
/** Get pending messages (read-only) */
|
||||
@@ -2830,6 +2920,7 @@ export class AgentSession {
|
||||
async abort(): Promise<void> {
|
||||
this.abortRetry();
|
||||
this.#promptGeneration++;
|
||||
this.#scheduledHiddenNextTurnGeneration = undefined;
|
||||
this.#resolveTtsrResume();
|
||||
this.#cancelPostPromptTasks();
|
||||
this.agent.abort();
|
||||
@@ -2879,6 +2970,7 @@ export class AgentSession {
|
||||
this.#steeringMessages = [];
|
||||
this.#followUpMessages = [];
|
||||
this.#pendingNextTurnMessages = [];
|
||||
this.#scheduledHiddenNextTurnGeneration = undefined;
|
||||
|
||||
this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
|
||||
this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
|
||||
@@ -3612,6 +3704,7 @@ export class AgentSession {
|
||||
this.#steeringMessages = [];
|
||||
this.#followUpMessages = [];
|
||||
this.#pendingNextTurnMessages = [];
|
||||
this.#scheduledHiddenNextTurnGeneration = undefined;
|
||||
this.#todoReminderCount = 0;
|
||||
|
||||
// Inject the handoff document as a custom message
|
||||
@@ -4495,7 +4588,9 @@ export class AgentSession {
|
||||
}
|
||||
|
||||
#isTransientErrorMessage(errorMessage: string): boolean {
|
||||
return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|unable to connect|fetch failed|retry delay|stream stall/i.test(
|
||||
// Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504,
|
||||
// service unavailable, network/connection errors, fetch failed, terminated, retry delay exceeded
|
||||
return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall/i.test(
|
||||
errorMessage,
|
||||
);
|
||||
}
|
||||
@@ -4959,6 +5054,7 @@ export class AgentSession {
|
||||
this.#steeringMessages = [];
|
||||
this.#followUpMessages = [];
|
||||
this.#pendingNextTurnMessages = [];
|
||||
this.#scheduledHiddenNextTurnGeneration = undefined;
|
||||
|
||||
// Flush pending writes before switching
|
||||
await this.sessionManager.flush();
|
||||
@@ -5002,21 +5098,18 @@ export class AgentSession {
|
||||
const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
|
||||
const hasServiceTierEntry = this.sessionManager.getBranch().some(entry => entry.type === "service_tier_change");
|
||||
const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
|
||||
|
||||
if (hasThinkingEntry) {
|
||||
this.setThinkingLevel(sessionContext.thinkingLevel as ThinkingLevel | undefined);
|
||||
} else {
|
||||
const effectiveDefaultThinkingLevel = resolveThinkingLevelForModel(this.model, defaultThinkingLevel);
|
||||
this.#thinkingLevel = effectiveDefaultThinkingLevel;
|
||||
this.agent.setThinkingLevel(toReasoningEffort(effectiveDefaultThinkingLevel));
|
||||
this.sessionManager.appendThinkingLevelChange(effectiveDefaultThinkingLevel);
|
||||
}
|
||||
|
||||
if (hasServiceTierEntry) {
|
||||
this.agent.serviceTier = sessionContext.serviceTier;
|
||||
} else {
|
||||
this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
|
||||
}
|
||||
const configuredServiceTier = this.settings.get("serviceTier");
|
||||
const nextThinkingLevel = resolveThinkingLevelForModel(
|
||||
this.model,
|
||||
hasThinkingEntry ? (sessionContext.thinkingLevel as ThinkingLevel | undefined) : defaultThinkingLevel,
|
||||
);
|
||||
this.#thinkingLevel = nextThinkingLevel;
|
||||
this.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
|
||||
this.agent.serviceTier = hasServiceTierEntry
|
||||
? sessionContext.serviceTier
|
||||
: configuredServiceTier === "none"
|
||||
? undefined
|
||||
: configuredServiceTier;
|
||||
|
||||
this.#reconnectToAgent();
|
||||
return true;
|
||||
@@ -5058,6 +5151,7 @@ export class AgentSession {
|
||||
|
||||
// Clear pending messages (bound to old session state)
|
||||
this.#pendingNextTurnMessages = [];
|
||||
this.#scheduledHiddenNextTurnGeneration = undefined;
|
||||
|
||||
// Flush pending writes before branching
|
||||
await this.sessionManager.flush();
|
||||
|
||||
@@ -1379,6 +1379,7 @@ export class SessionManager {
|
||||
#sessionName: string | undefined;
|
||||
#sessionFile: string | undefined;
|
||||
#flushed: boolean = false;
|
||||
#needsFullRewriteOnNextPersist: boolean = false;
|
||||
#fileEntries: FileEntry[] = [];
|
||||
#byId: Map<string, SessionEntry> = new Map();
|
||||
#labelsById: Map<string, string> = new Map();
|
||||
@@ -1441,9 +1442,7 @@ export class SessionManager {
|
||||
this.#sessionId = header?.id ?? Snowflake.next();
|
||||
this.#sessionName = header?.title;
|
||||
|
||||
if (migrateToCurrentVersion(this.#fileEntries)) {
|
||||
await this.#rewriteFile();
|
||||
}
|
||||
this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
|
||||
|
||||
await resolveBlobRefsInEntries(this.#fileEntries, this.#blobStore);
|
||||
|
||||
@@ -1630,6 +1629,7 @@ export class SessionManager {
|
||||
this.#labelsById.clear();
|
||||
this.#leafId = null;
|
||||
this.#flushed = false;
|
||||
this.#needsFullRewriteOnNextPersist = false;
|
||||
this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
|
||||
|
||||
if (this.persist) {
|
||||
@@ -1772,6 +1772,7 @@ export class SessionManager {
|
||||
this.#fileEntries.map(entry => prepareEntryForPersistence(entry, this.#blobStore)),
|
||||
);
|
||||
await this.#writeEntriesAtomically(entries);
|
||||
this.#needsFullRewriteOnNextPersist = false;
|
||||
this.#flushed = true;
|
||||
});
|
||||
}
|
||||
@@ -1786,7 +1787,7 @@ export class SessionManager {
|
||||
*/
|
||||
async ensureOnDisk(): Promise<void> {
|
||||
if (!this.persist || !this.#sessionFile) return;
|
||||
if (this.#flushed) return;
|
||||
if (this.#flushed && !this.#needsFullRewriteOnNextPersist) return;
|
||||
await this.#rewriteFile();
|
||||
}
|
||||
|
||||
@@ -1919,12 +1920,12 @@ export class SessionManager {
|
||||
|
||||
const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
|
||||
if (!hasAssistant) {
|
||||
// Mark as not flushed so when assistant arrives, all entries get written
|
||||
// Mark as not flushed so when assistant arrives, all entries get written.
|
||||
this.#flushed = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.#flushed) {
|
||||
if (this.#needsFullRewriteOnNextPersist || !this.#flushed) {
|
||||
// Full flush: rewrite the entire file atomically to avoid
|
||||
// duplicating entries if the file already exists (e.g. from ensureOnDisk).
|
||||
void this.#rewriteFile();
|
||||
|
||||
@@ -16,13 +16,12 @@
|
||||
*/
|
||||
|
||||
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { TERMINAL, Text } from "@oh-my-pi/pi-tui";
|
||||
import { type Component, Container, Markdown, renderInlineMarkdown, TERMINAL, Text } from "@oh-my-pi/pi-tui";
|
||||
import { untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { renderPromptTemplate } from "../config/prompt-templates";
|
||||
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
||||
import { type Theme, theme } from "../modes/theme/theme";
|
||||
import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme";
|
||||
import askDescription from "../prompts/tools/ask.md" with { type: "text" };
|
||||
import { renderStatusLine } from "../tui";
|
||||
import type { ToolSession } from ".";
|
||||
@@ -581,11 +580,17 @@ interface AskRenderArgs {
|
||||
}
|
||||
|
||||
/** Render custom input as a single block with continuation lines (not one entry per line) */
|
||||
function renderCustomInput(uiTheme: Theme, prefix: string, customInput: string, isLastEntry: boolean): string {
|
||||
function renderCustomInput(
|
||||
uiTheme: Theme,
|
||||
prefix: string,
|
||||
customInput: string,
|
||||
isLastEntry: boolean,
|
||||
includeLeadingNewline = true,
|
||||
): string {
|
||||
const lines = customInput.split("\n");
|
||||
const branch = isLastEntry ? uiTheme.tree.last : uiTheme.tree.branch;
|
||||
const firstLine = lines[0] ?? "";
|
||||
let text = `\n${prefix}${uiTheme.fg("dim", branch)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", firstLine)}`;
|
||||
let text = `${includeLeadingNewline ? "\n" : ""}${prefix}${uiTheme.fg("dim", branch)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", firstLine)}`;
|
||||
const continuationIndent = isLastEntry ? " " : `${uiTheme.fg("dim", uiTheme.tree.vertical)} `;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
text += `\n${prefix}${continuationIndent} ${uiTheme.fg("toolOutput", lines[i])}`;
|
||||
@@ -596,10 +601,13 @@ function renderCustomInput(uiTheme: Theme, prefix: string, customInput: string,
|
||||
export const askToolRenderer = {
|
||||
renderCall(args: AskRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
||||
const label = formatTitle("Ask", uiTheme);
|
||||
const mdTheme = getMarkdownTheme();
|
||||
const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) };
|
||||
|
||||
// Multi-part questions
|
||||
if (args.questions && args.questions.length > 0) {
|
||||
let text = `${label} ${uiTheme.fg("muted", `${args.questions.length} questions`)}`;
|
||||
const container = new Container();
|
||||
container.addChild(new Text(`${label} ${uiTheme.fg("muted", `${args.questions.length} questions`)}`, 0, 0));
|
||||
|
||||
for (let i = 0; i < args.questions.length; i++) {
|
||||
const q = args.questions[i];
|
||||
@@ -607,25 +615,29 @@ export const askToolRenderer = {
|
||||
const qBranch = isLastQ ? uiTheme.tree.last : uiTheme.tree.branch;
|
||||
const continuation = isLastQ ? " " : uiTheme.tree.vertical;
|
||||
|
||||
// Question line with metadata
|
||||
const meta: string[] = [];
|
||||
if (q.multi) meta.push("multi");
|
||||
if (q.options?.length) meta.push(`options:${q.options.length}`);
|
||||
const metaStr = meta.length > 0 ? uiTheme.fg("dim", ` · ${meta.join(" · ")}`) : "";
|
||||
|
||||
text += `\n ${uiTheme.fg("dim", qBranch)} ${uiTheme.fg("dim", `[${q.id}]`)} ${uiTheme.fg("accent", q.question)}${metaStr}`;
|
||||
container.addChild(
|
||||
new Text(` ${uiTheme.fg("dim", qBranch)} ${uiTheme.fg("dim", `[${q.id}]`)}${metaStr}`, 0, 0),
|
||||
);
|
||||
container.addChild(new Markdown(q.question, 3, 0, mdTheme, accentStyle));
|
||||
|
||||
// Options under question
|
||||
if (q.options?.length) {
|
||||
let optText = "";
|
||||
for (let j = 0; j < q.options.length; j++) {
|
||||
const opt = q.options[j];
|
||||
const isLastOpt = j === q.options.length - 1;
|
||||
const optBranch = isLastOpt ? uiTheme.tree.last : uiTheme.tree.branch;
|
||||
text += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${uiTheme.fg("muted", opt.label)}`;
|
||||
const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
|
||||
optText += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
|
||||
}
|
||||
container.addChild(new Text(optText, 0, 0));
|
||||
}
|
||||
}
|
||||
return new Text(text, 0, 0);
|
||||
return container;
|
||||
}
|
||||
|
||||
// Single question
|
||||
@@ -633,22 +645,26 @@ export const askToolRenderer = {
|
||||
return new Text(formatErrorMessage("No question provided", uiTheme), 0, 0);
|
||||
}
|
||||
|
||||
let text = `${label} ${uiTheme.fg("accent", args.question)}`;
|
||||
const container = new Container();
|
||||
const meta: string[] = [];
|
||||
if (args.multi) meta.push("multi");
|
||||
if (args.options?.length) meta.push(`options:${args.options.length}`);
|
||||
text += formatMeta(meta, uiTheme);
|
||||
container.addChild(new Text(`${label}${formatMeta(meta, uiTheme)}`, 0, 0));
|
||||
container.addChild(new Markdown(args.question, 1, 0, mdTheme, accentStyle));
|
||||
|
||||
if (args.options?.length) {
|
||||
let optText = "";
|
||||
for (let i = 0; i < args.options.length; i++) {
|
||||
const opt = args.options[i];
|
||||
const isLast = i === args.options.length - 1;
|
||||
const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
|
||||
text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${uiTheme.fg("muted", opt.label)}`;
|
||||
const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
|
||||
optText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
|
||||
}
|
||||
container.addChild(new Text(optText, 0, 0));
|
||||
}
|
||||
|
||||
return new Text(text, 0, 0);
|
||||
return container;
|
||||
},
|
||||
|
||||
renderResult(
|
||||
@@ -657,6 +673,9 @@ export const askToolRenderer = {
|
||||
uiTheme: Theme,
|
||||
): Component {
|
||||
const { details } = result;
|
||||
const mdTheme = getMarkdownTheme();
|
||||
const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) };
|
||||
|
||||
if (!details) {
|
||||
const txt = result.content[0];
|
||||
const fallback = txt?.type === "text" && txt.text ? txt.text : "";
|
||||
@@ -677,7 +696,8 @@ export const askToolRenderer = {
|
||||
},
|
||||
uiTheme,
|
||||
);
|
||||
let text = header;
|
||||
const container = new Container();
|
||||
container.addChild(new Text(header, 0, 0));
|
||||
|
||||
for (let i = 0; i < details.results.length; i++) {
|
||||
const r = details.results[i];
|
||||
@@ -689,23 +709,38 @@ export const askToolRenderer = {
|
||||
? uiTheme.styledSymbol("status.success", "success")
|
||||
: uiTheme.styledSymbol("status.warning", "warning");
|
||||
|
||||
text += `\n ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)} ${uiTheme.fg("accent", r.question)}`;
|
||||
container.addChild(
|
||||
new Text(` ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)}`, 0, 0),
|
||||
);
|
||||
container.addChild(new Markdown(r.question, 3, 0, mdTheme, accentStyle));
|
||||
|
||||
if (r.selectedOptions.length > 0) {
|
||||
for (let j = 0; j < r.selectedOptions.length; j++) {
|
||||
const isLast = j === r.selectedOptions.length - 1 && r.customInput === undefined;
|
||||
const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
|
||||
text += `\n${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", r.selectedOptions[j])}`;
|
||||
}
|
||||
const answerLines: string[] = [];
|
||||
for (let j = 0; j < r.selectedOptions.length; j++) {
|
||||
const isLast = j === r.selectedOptions.length - 1 && r.customInput === undefined;
|
||||
const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
|
||||
const selectedLabel = renderInlineMarkdown(r.selectedOptions[j], mdTheme, t =>
|
||||
uiTheme.fg("toolOutput", t),
|
||||
);
|
||||
answerLines.push(
|
||||
`${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`,
|
||||
);
|
||||
}
|
||||
if (answerLines.length > 0) {
|
||||
container.addChild(new Text(answerLines.join("\n"), 0, 0));
|
||||
}
|
||||
if (r.customInput !== undefined) {
|
||||
text += renderCustomInput(uiTheme, continuation, r.customInput, true);
|
||||
container.addChild(new Text(renderCustomInput(uiTheme, continuation, r.customInput, true, false), 0, 0));
|
||||
} else if (r.selectedOptions.length === 0) {
|
||||
text += `\n${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
|
||||
container.addChild(
|
||||
new Text(
|
||||
`${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new Text(text, 0, 0);
|
||||
return container;
|
||||
}
|
||||
|
||||
// Single question result
|
||||
@@ -717,26 +752,39 @@ export const askToolRenderer = {
|
||||
|
||||
const hasSelection =
|
||||
details.customInput !== undefined || (details.selectedOptions && details.selectedOptions.length > 0);
|
||||
const header = renderStatusLine(
|
||||
{ icon: hasSelection ? "success" : "warning", title: "Ask", description: details.question },
|
||||
uiTheme,
|
||||
);
|
||||
|
||||
let text = header;
|
||||
const header = renderStatusLine({ icon: hasSelection ? "success" : "warning", title: "Ask" }, uiTheme);
|
||||
const container = new Container();
|
||||
container.addChild(new Text(header, 0, 0));
|
||||
container.addChild(new Markdown(details.question, 1, 0, mdTheme, accentStyle));
|
||||
|
||||
const answerLines: string[] = [];
|
||||
if (details.selectedOptions && details.selectedOptions.length > 0) {
|
||||
for (let i = 0; i < details.selectedOptions.length; i++) {
|
||||
const isLast = i === details.selectedOptions.length - 1 && details.customInput === undefined;
|
||||
const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
|
||||
text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", details.selectedOptions[i])}`;
|
||||
const selectedLabel = renderInlineMarkdown(details.selectedOptions[i], mdTheme, t =>
|
||||
uiTheme.fg("toolOutput", t),
|
||||
);
|
||||
answerLines.push(
|
||||
` ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (answerLines.length > 0) {
|
||||
container.addChild(new Text(answerLines.join("\n"), 0, 0));
|
||||
}
|
||||
if (details.customInput !== undefined) {
|
||||
text += renderCustomInput(uiTheme, " ", details.customInput, true);
|
||||
container.addChild(new Text(renderCustomInput(uiTheme, " ", details.customInput, true, false), 0, 0));
|
||||
} else if (!details.selectedOptions || details.selectedOptions.length === 0) {
|
||||
text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
|
||||
container.addChild(
|
||||
new Text(
|
||||
` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return new Text(text, 0, 0);
|
||||
return container;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,45 +5,7 @@
|
||||
* this interceptor provides helpful error messages directing them to use
|
||||
* the specialized tools instead.
|
||||
*/
|
||||
import type { BashInterceptorRule } from "../config/settings-schema";
|
||||
|
||||
export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
|
||||
{
|
||||
pattern: "^\\s*(cat|head|tail|less|more)\\s+",
|
||||
tool: "read",
|
||||
message: "Use the `read` tool instead of cat/head/tail. It provides better context and handles binary files.",
|
||||
},
|
||||
{
|
||||
pattern: "^\\s*(grep|rg|ripgrep|ag|ack)\\s+",
|
||||
tool: "grep",
|
||||
message: "Use the `grep` tool instead of grep/rg. It respects .gitignore and provides structured output.",
|
||||
},
|
||||
{
|
||||
pattern: "^\\s*(find|fd|locate)\\s+.*(-name|-iname|-type|--type|-glob)",
|
||||
tool: "find",
|
||||
message: "Use the `find` tool instead of find/fd. It respects .gitignore and is faster for glob patterns.",
|
||||
},
|
||||
{
|
||||
pattern: "^\\s*sed\\s+(-i|--in-place)",
|
||||
tool: "edit",
|
||||
message: "Use the `edit` tool instead of sed -i. It provides diff preview and fuzzy matching.",
|
||||
},
|
||||
{
|
||||
pattern: "^\\s*perl\\s+.*-[pn]?i",
|
||||
tool: "edit",
|
||||
message: "Use the `edit` tool instead of perl -i. It provides diff preview and fuzzy matching.",
|
||||
},
|
||||
{
|
||||
pattern: "^\\s*awk\\s+.*-i\\s+inplace",
|
||||
tool: "edit",
|
||||
message: "Use the `edit` tool instead of awk -i inplace. It provides diff preview and fuzzy matching.",
|
||||
},
|
||||
{
|
||||
pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+.*[^|]>\\s*\\S",
|
||||
tool: "write",
|
||||
message: "Use the `write` tool instead of echo/cat redirection. It handles encoding and provides confirmation.",
|
||||
},
|
||||
];
|
||||
import { type BashInterceptorRule, DEFAULT_BASH_INTERCEPTOR_RULES } from "../config/settings-schema";
|
||||
|
||||
export interface InterceptionResult {
|
||||
/** If true, the bash command should be blocked */
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
const EXIT_STDIO_GRACE_MS = 100;
|
||||
|
||||
/**
|
||||
* Wait for a child process to terminate without hanging on inherited stdio handles.
|
||||
*
|
||||
* Daemonized descendants can inherit the child's stdout/stderr pipe handles. In that
|
||||
* case the child emits `exit`, but `close` can hang forever even though the original
|
||||
* process is already gone. We wait briefly for stdio to end, then forcibly stop
|
||||
* tracking the inherited handles.
|
||||
*/
|
||||
export function waitForChildProcess(child: ChildProcess): Promise<number | null> {
|
||||
const { promise, resolve, reject } = Promise.withResolvers<number | null>();
|
||||
|
||||
let settled = false;
|
||||
let exited = false;
|
||||
let exitCode: number | null = null;
|
||||
let postExitTimer: NodeJS.Timeout | undefined;
|
||||
let stdoutEnded = child.stdout === null;
|
||||
let stderrEnded = child.stderr === null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (postExitTimer) {
|
||||
clearTimeout(postExitTimer);
|
||||
postExitTimer = undefined;
|
||||
}
|
||||
child.removeListener("error", onError);
|
||||
child.removeListener("exit", onExit);
|
||||
child.removeListener("close", onClose);
|
||||
child.stdout?.removeListener("end", onStdoutEnd);
|
||||
child.stderr?.removeListener("end", onStderrEnd);
|
||||
};
|
||||
|
||||
const finalize = (code: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
child.stdout?.destroy();
|
||||
child.stderr?.destroy();
|
||||
resolve(code);
|
||||
};
|
||||
|
||||
const maybeFinalizeAfterExit = () => {
|
||||
if (!exited || settled) return;
|
||||
if (stdoutEnded && stderrEnded) {
|
||||
finalize(exitCode);
|
||||
}
|
||||
};
|
||||
|
||||
const onStdoutEnd = () => {
|
||||
stdoutEnded = true;
|
||||
maybeFinalizeAfterExit();
|
||||
};
|
||||
|
||||
const onStderrEnd = () => {
|
||||
stderrEnded = true;
|
||||
maybeFinalizeAfterExit();
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const onExit = (code: number | null) => {
|
||||
exited = true;
|
||||
exitCode = code;
|
||||
maybeFinalizeAfterExit();
|
||||
if (!settled) {
|
||||
postExitTimer = setTimeout(() => finalize(code), EXIT_STDIO_GRACE_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = (code: number | null) => {
|
||||
finalize(code);
|
||||
};
|
||||
|
||||
child.stdout?.once("end", onStdoutEnd);
|
||||
child.stderr?.once("end", onStderrEnd);
|
||||
child.once("error", onError);
|
||||
child.once("exit", onExit);
|
||||
child.once("close", onClose);
|
||||
|
||||
return promise;
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
* Tests for AgentSession concurrent prompt guard.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Agent, AgentBusyError, type AgentTool } from "@oh-my-pi/pi-agent-core";
|
||||
import { type AssistantMessage, getBundledModel, type ToolCall } from "@oh-my-pi/pi-ai";
|
||||
import { type AssistantMessage, getBundledModel, type Message, type ToolCall } from "@oh-my-pi/pi-ai";
|
||||
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
|
||||
import type { Rule } from "@oh-my-pi/pi-coding-agent/capability/rule";
|
||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
@@ -15,6 +15,7 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { TtsrManager } from "@oh-my-pi/pi-coding-agent/export/ttsr";
|
||||
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
||||
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { Snowflake } from "@oh-my-pi/pi-utils";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
@@ -62,6 +63,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
if (tempDir && fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function createSession() {
|
||||
@@ -111,6 +113,16 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
return session;
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 500): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return;
|
||||
await Bun.sleep(10);
|
||||
}
|
||||
|
||||
throw new Error("Timed out waiting for condition");
|
||||
}
|
||||
|
||||
it("should throw when prompt() called while streaming", async () => {
|
||||
await createSession();
|
||||
|
||||
@@ -163,6 +175,82 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
await firstPrompt.catch(() => {});
|
||||
});
|
||||
|
||||
it("delivers hidden nextTurn stop reactions through the next LLM call without exposing them in the visible queue", async () => {
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
|
||||
let firstStream: MockAssistantStream | undefined;
|
||||
const callMessages: Message[][] = [];
|
||||
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "Test",
|
||||
tools: [],
|
||||
},
|
||||
convertToLlm,
|
||||
streamFn: (_model, context) => {
|
||||
callMessages.push([...context.messages]);
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||
if (callMessages.length > 1) {
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Resumed") });
|
||||
return;
|
||||
}
|
||||
});
|
||||
firstStream = stream;
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settings = Settings.isolated();
|
||||
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
|
||||
authStorages.push(authStorage);
|
||||
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settings,
|
||||
modelRegistry,
|
||||
});
|
||||
|
||||
const firstPrompt = session.prompt("First message");
|
||||
await waitFor(() => session.isStreaming && firstStream !== undefined && callMessages.length === 1);
|
||||
|
||||
await session.sendCustomMessage(
|
||||
{
|
||||
customType: "autoresearch-resume",
|
||||
content: "Hidden stop reaction",
|
||||
display: false,
|
||||
attribution: "agent",
|
||||
},
|
||||
{ deliverAs: "nextTurn", triggerTurn: true },
|
||||
);
|
||||
|
||||
expect(session.queuedMessageCount).toBe(0);
|
||||
expect(session.getQueuedMessages()).toEqual({ steering: [], followUp: [] });
|
||||
|
||||
firstStream?.push({ type: "done", reason: "stop", message: createAssistantMessage("Done") });
|
||||
await firstPrompt;
|
||||
await session.waitForIdle();
|
||||
|
||||
expect(callMessages).toHaveLength(2);
|
||||
expect(
|
||||
callMessages[1]?.some(message => {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content.includes("Hidden stop reaction");
|
||||
}
|
||||
|
||||
return message.content.some(
|
||||
content => content.type === "text" && content.text.includes("Hidden stop reaction"),
|
||||
);
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow prompt() after previous completes", async () => {
|
||||
// Create session with a stream that completes immediately
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
|
||||
|
||||
@@ -2,9 +2,8 @@ import { afterEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
||||
import { Agent } from "@oh-my-pi/pi-agent-core";
|
||||
import type { Model } from "@oh-my-pi/pi-ai";
|
||||
import { Agent, type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import { Effort, type Model } from "@oh-my-pi/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { Settings } from "../src/config/settings";
|
||||
import type { CustomTool } from "../src/extensibility/custom-tools/types";
|
||||
@@ -398,7 +397,7 @@ describe("AgentSession MCP discovery", () => {
|
||||
expect(sessionManager.buildSessionContext().selectedMCPToolNames).toEqual([]);
|
||||
});
|
||||
|
||||
it("persists corrected empty MCP selections when restored tools are unavailable", async () => {
|
||||
it("restores unavailable MCP selections in memory without rewriting the persisted session selection", async () => {
|
||||
const readTool = createBasicTool("read", "Read");
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
sessionManager.appendMCPToolSelection(["mcp_docs_search"]);
|
||||
@@ -422,7 +421,7 @@ describe("AgentSession MCP discovery", () => {
|
||||
sessions.push(session);
|
||||
|
||||
expect(session.getSelectedMCPToolNames()).toEqual([]);
|
||||
expect(sessionManager.buildSessionContext().selectedMCPToolNames).toEqual([]);
|
||||
expect(sessionManager.buildSessionContext().selectedMCPToolNames).toEqual(["mcp_docs_search"]);
|
||||
});
|
||||
|
||||
it("restores MCP discovery selections when branching to a context without them", async () => {
|
||||
@@ -562,6 +561,11 @@ describe("AgentSession MCP discovery", () => {
|
||||
content: "start",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: "follow up",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const toolRegistry = new Map([
|
||||
[readTool.name, readTool],
|
||||
[docsSearchTool.name, docsSearchTool],
|
||||
@@ -595,7 +599,7 @@ describe("AgentSession MCP discovery", () => {
|
||||
expect(session.systemPrompt).toBe("tools:read,mcp_docs_search");
|
||||
});
|
||||
|
||||
it("does not leak MCP defaults across session switches without persisted selections", async () => {
|
||||
it("restores session defaults in memory across session switches without rewriting sessions missing persisted metadata", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-agent-session-mcp-switch-"));
|
||||
tempDirs.push(tempDir);
|
||||
const readTool = createBasicTool("read", "Read");
|
||||
@@ -606,18 +610,31 @@ describe("AgentSession MCP discovery", () => {
|
||||
]);
|
||||
|
||||
const olderSessionManager = SessionManager.create(tempDir, tempDir);
|
||||
olderSessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: "older session",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const olderSessionFile = olderSessionManager.getSessionFile();
|
||||
expect(olderSessionFile).toBeString();
|
||||
await olderSessionManager.flush();
|
||||
await olderSessionManager.rewriteEntries();
|
||||
const olderSessionBeforeSwitch = fs.readFileSync(olderSessionFile!, "utf8");
|
||||
const olderSessionMtimeBeforeSwitch = fs.statSync(olderSessionFile!).mtimeMs;
|
||||
|
||||
const sessionManager = SessionManager.create(tempDir, tempDir);
|
||||
const originalSessionFile = sessionManager.getSessionFile();
|
||||
expect(originalSessionFile).toBeString();
|
||||
await sessionManager.flush();
|
||||
|
||||
const reasoningModel: Model<"openai-responses"> = {
|
||||
...createModel(),
|
||||
reasoning: true,
|
||||
thinking: { mode: "effort", minLevel: Effort.Medium, maxLevel: Effort.Medium },
|
||||
};
|
||||
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
model: createModel(),
|
||||
model: reasoningModel,
|
||||
systemPrompt: "initial",
|
||||
tools: [readTool, docsSearchTool],
|
||||
messages: sessionManager.buildSessionContext().messages,
|
||||
@@ -626,7 +643,11 @@ describe("AgentSession MCP discovery", () => {
|
||||
const session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settings: Settings.isolated({ "mcp.discoveryMode": true }),
|
||||
settings: Settings.isolated({
|
||||
"mcp.discoveryMode": true,
|
||||
defaultThinkingLevel: "high",
|
||||
serviceTier: "priority",
|
||||
}),
|
||||
modelRegistry: {} as never,
|
||||
toolRegistry,
|
||||
mcpDiscoveryEnabled: true,
|
||||
@@ -637,17 +658,37 @@ describe("AgentSession MCP discovery", () => {
|
||||
sessions.push(session);
|
||||
|
||||
expect(session.getSelectedMCPToolNames()).toEqual(["mcp_docs_search"]);
|
||||
sessionManager.appendThinkingLevelChange(ThinkingLevel.High);
|
||||
sessionManager.appendServiceTierChange("flex");
|
||||
sessionManager.appendMCPToolSelection(["mcp_docs_search"]);
|
||||
expect(sessionManager.buildSessionContext().thinkingLevel).toBe(ThinkingLevel.High);
|
||||
expect(sessionManager.buildSessionContext().serviceTier).toBe("flex");
|
||||
expect(sessionManager.buildSessionContext().selectedMCPToolNames).toEqual(["mcp_docs_search"]);
|
||||
expect(sessionManager.buildSessionContext().hasPersistedMCPToolSelection).toBe(true);
|
||||
await sessionManager.rewriteEntries();
|
||||
const originalSessionBeforeSwitch = fs.readFileSync(originalSessionFile!, "utf8");
|
||||
const originalSessionMtimeBeforeSwitch = fs.statSync(originalSessionFile!).mtimeMs;
|
||||
await Bun.sleep(20);
|
||||
|
||||
await session.switchSession(olderSessionFile!);
|
||||
expect(session.sessionFile).toBe(olderSessionFile);
|
||||
expect(session.thinkingLevel).toBe(ThinkingLevel.Medium);
|
||||
expect(session.serviceTier).toBe("priority");
|
||||
expect(session.getSelectedMCPToolNames()).toEqual([]);
|
||||
expect(session.getActiveToolNames()).toEqual(["read"]);
|
||||
expect(session.systemPrompt).toBe("tools:read");
|
||||
expect(fs.readFileSync(olderSessionFile!, "utf8")).toBe(olderSessionBeforeSwitch);
|
||||
expect(fs.statSync(olderSessionFile!).mtimeMs).toBe(olderSessionMtimeBeforeSwitch);
|
||||
|
||||
await session.switchSession(originalSessionFile!);
|
||||
expect(session.sessionFile).toBe(originalSessionFile);
|
||||
expect(session.thinkingLevel).toBe(ThinkingLevel.Medium);
|
||||
expect(session.serviceTier).toBe("flex");
|
||||
expect(session.getSelectedMCPToolNames()).toEqual(["mcp_docs_search"]);
|
||||
expect(session.getActiveToolNames()).toEqual(["read", "mcp_docs_search"]);
|
||||
expect(session.systemPrompt).toBe("tools:read,mcp_docs_search");
|
||||
expect(fs.readFileSync(originalSessionFile!, "utf8")).toBe(originalSessionBeforeSwitch);
|
||||
expect(fs.statSync(originalSessionFile!).mtimeMs).toBe(originalSessionMtimeBeforeSwitch);
|
||||
});
|
||||
|
||||
it("restores explicit MCP defaults after startup outage once tools recover in a new session", async () => {
|
||||
|
||||
@@ -86,6 +86,13 @@ describe("parseArgs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("--fork flag", () => {
|
||||
test("parses --fork with session ID", () => {
|
||||
const result = parseArgs(["--fork", "abc123"]);
|
||||
expect(result.fork).toBe("abc123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("flags with values", () => {
|
||||
test("parses --provider", () => {
|
||||
const result = parseArgs(["--provider", "openai"]);
|
||||
@@ -112,6 +119,11 @@ describe("parseArgs", () => {
|
||||
expect(result.appendSystemPrompt).toBe("Additional context");
|
||||
});
|
||||
|
||||
test("parses --provider-session-id", () => {
|
||||
const result = parseArgs(["--provider-session-id", "reb_cache_key"]);
|
||||
expect(result.providerSessionId).toBe("reb_cache_key");
|
||||
});
|
||||
|
||||
test("parses --mode", () => {
|
||||
const result = parseArgs(["--mode", "json"]);
|
||||
expect(result.mode).toBe("json");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -246,7 +246,7 @@ describe("validateLineRef", () => {
|
||||
describe("applyHashlineEdits — replace", () => {
|
||||
it("replaces single line", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] }];
|
||||
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "bbb"), lines: ["BBB"] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\nBBB\nccc");
|
||||
@@ -255,7 +255,9 @@ describe("applyHashlineEdits — replace", () => {
|
||||
|
||||
it("range replace (shrink)", () => {
|
||||
const content = "aaa\nbbb\nccc\nddd";
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: ["ONE"] }];
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "replace_range", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: ["ONE"] },
|
||||
];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\nONE\nddd");
|
||||
@@ -264,7 +266,7 @@ describe("applyHashlineEdits — replace", () => {
|
||||
it("range replace (same count)", () => {
|
||||
const content = "aaa\nbbb\nccc\nddd";
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "replace", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: ["XXX", "YYY"] },
|
||||
{ op: "replace_range", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: ["XXX", "YYY"] },
|
||||
];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
@@ -274,7 +276,7 @@ describe("applyHashlineEdits — replace", () => {
|
||||
|
||||
it("replaces first line", () => {
|
||||
const content = "first\nsecond\nthird";
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(1, "first"), lines: ["FIRST"] }];
|
||||
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(1, "first"), lines: ["FIRST"] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("FIRST\nsecond\nthird");
|
||||
@@ -283,7 +285,7 @@ describe("applyHashlineEdits — replace", () => {
|
||||
|
||||
it("replaces last line", () => {
|
||||
const content = "first\nsecond\nthird";
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "third"), lines: ["THIRD"] }];
|
||||
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(3, "third"), lines: ["THIRD"] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("first\nsecond\nTHIRD");
|
||||
@@ -298,7 +300,7 @@ describe("applyHashlineEdits — replace", () => {
|
||||
describe("applyHashlineEdits — delete", () => {
|
||||
it("deletes single line", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: [] }];
|
||||
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "bbb"), lines: [] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\nccc");
|
||||
@@ -307,7 +309,9 @@ describe("applyHashlineEdits — delete", () => {
|
||||
|
||||
it("deletes range of lines", () => {
|
||||
const content = "aaa\nbbb\nccc\nddd";
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: [] }];
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "replace_range", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: [] },
|
||||
];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\nddd");
|
||||
@@ -315,7 +319,7 @@ describe("applyHashlineEdits — delete", () => {
|
||||
|
||||
it("deletes first line", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(1, "aaa"), lines: [] }];
|
||||
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(1, "aaa"), lines: [] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("bbb\nccc");
|
||||
@@ -323,7 +327,7 @@ describe("applyHashlineEdits — delete", () => {
|
||||
|
||||
it("deletes last line", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "ccc"), lines: [] }];
|
||||
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(3, "ccc"), lines: [] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\nbbb");
|
||||
@@ -331,7 +335,7 @@ describe("applyHashlineEdits — delete", () => {
|
||||
|
||||
it("replaces line with blank line when lines is ['']", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: [""] }];
|
||||
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "bbb"), lines: [""] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\n\nccc");
|
||||
@@ -346,7 +350,7 @@ describe("applyHashlineEdits — delete", () => {
|
||||
describe("applyHashlineEdits — append", () => {
|
||||
it("inserts after a line", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [{ op: "append", pos: makeTag(1, "aaa"), lines: ["NEW"] }];
|
||||
const edits: HashlineEdit[] = [{ op: "append_at", pos: makeTag(1, "aaa"), lines: ["NEW"] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\nNEW\nbbb\nccc");
|
||||
@@ -355,7 +359,7 @@ describe("applyHashlineEdits — append", () => {
|
||||
|
||||
it("inserts multiple lines", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ op: "append", pos: makeTag(1, "aaa"), lines: ["x", "y", "z"] }];
|
||||
const edits: HashlineEdit[] = [{ op: "append_at", pos: makeTag(1, "aaa"), lines: ["x", "y", "z"] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\nx\ny\nz\nbbb");
|
||||
@@ -363,7 +367,7 @@ describe("applyHashlineEdits — append", () => {
|
||||
|
||||
it("inserts after last line", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ op: "append", pos: makeTag(2, "bbb"), lines: ["NEW"] }];
|
||||
const edits: HashlineEdit[] = [{ op: "append_at", pos: makeTag(2, "bbb"), lines: ["NEW"] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\nbbb\nNEW");
|
||||
@@ -371,7 +375,7 @@ describe("applyHashlineEdits — append", () => {
|
||||
|
||||
it("insert with empty dst inserts an empty line", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ op: "append", pos: makeTag(1, "aaa"), lines: [] }];
|
||||
const edits: HashlineEdit[] = [{ op: "append_at", pos: makeTag(1, "aaa"), lines: [] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\n\nbbb");
|
||||
@@ -380,7 +384,7 @@ describe("applyHashlineEdits — append", () => {
|
||||
|
||||
it("inserts at EOF without anchors", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits = [{ op: "append", lines: ["NEW"] }] as unknown as HashlineEdit[];
|
||||
const edits: HashlineEdit[] = [{ op: "append_file", lines: ["NEW"] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\nbbb\nNEW");
|
||||
@@ -389,7 +393,7 @@ describe("applyHashlineEdits — append", () => {
|
||||
|
||||
it("inserts at EOF into empty file without anchors", () => {
|
||||
const content = "";
|
||||
const edits = [{ op: "append", lines: ["NEW"] }] as unknown as HashlineEdit[];
|
||||
const edits: HashlineEdit[] = [{ op: "append_file", lines: ["NEW"] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("NEW");
|
||||
@@ -398,7 +402,7 @@ describe("applyHashlineEdits — append", () => {
|
||||
|
||||
it("insert at EOF with empty dst inserts a trailing empty line", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits = [{ op: "append", lines: [] }] as unknown as HashlineEdit[];
|
||||
const edits: HashlineEdit[] = [{ op: "append_file", lines: [] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\nbbb\n");
|
||||
@@ -413,7 +417,7 @@ describe("applyHashlineEdits — append", () => {
|
||||
describe("applyHashlineEdits — prepend", () => {
|
||||
it("inserts before a line", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [{ op: "prepend", pos: makeTag(2, "bbb"), lines: ["NEW"] }];
|
||||
const edits: HashlineEdit[] = [{ op: "prepend_at", pos: makeTag(2, "bbb"), lines: ["NEW"] }];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\nNEW\nbbb\nccc");
|
||||
expect(result.firstChangedLine).toBe(2);
|
||||
@@ -421,21 +425,21 @@ describe("applyHashlineEdits — prepend", () => {
|
||||
|
||||
it("inserts multiple lines before", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ op: "prepend", pos: makeTag(2, "bbb"), lines: ["x", "y", "z"] }];
|
||||
const edits: HashlineEdit[] = [{ op: "prepend_at", pos: makeTag(2, "bbb"), lines: ["x", "y", "z"] }];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\nx\ny\nz\nbbb");
|
||||
});
|
||||
|
||||
it("inserts before first line", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ op: "prepend", pos: makeTag(1, "aaa"), lines: ["NEW"] }];
|
||||
const edits: HashlineEdit[] = [{ op: "prepend_at", pos: makeTag(1, "aaa"), lines: ["NEW"] }];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("NEW\naaa\nbbb");
|
||||
});
|
||||
|
||||
it("prepends at BOF without anchor", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits = [{ op: "prepend", lines: ["NEW"] }] as unknown as HashlineEdit[];
|
||||
const edits: HashlineEdit[] = [{ op: "prepend_file", lines: ["NEW"] }];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("NEW\naaa\nbbb");
|
||||
expect(result.firstChangedLine).toBe(1);
|
||||
@@ -443,7 +447,7 @@ describe("applyHashlineEdits — prepend", () => {
|
||||
|
||||
it("insert with before and empty text inserts an empty line", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ op: "prepend", pos: makeTag(1, "aaa"), lines: [] }];
|
||||
const edits: HashlineEdit[] = [{ op: "prepend_at", pos: makeTag(1, "aaa"), lines: [] }];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("\naaa\nbbb");
|
||||
expect(result.firstChangedLine).toBe(1);
|
||||
@@ -452,8 +456,8 @@ describe("applyHashlineEdits — prepend", () => {
|
||||
it("insert before and insert after at same line produce correct order", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "prepend", pos: makeTag(2, "bbb"), lines: ["BEFORE"] },
|
||||
{ op: "append", pos: makeTag(2, "bbb"), lines: ["AFTER"] },
|
||||
{ op: "prepend_at", pos: makeTag(2, "bbb"), lines: ["BEFORE"] },
|
||||
{ op: "append_at", pos: makeTag(2, "bbb"), lines: ["AFTER"] },
|
||||
];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\nBEFORE\nbbb\nAFTER\nccc");
|
||||
@@ -462,8 +466,8 @@ describe("applyHashlineEdits — prepend", () => {
|
||||
it("insert before with set at same line", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "prepend", pos: makeTag(2, "bbb"), lines: ["BEFORE"] },
|
||||
{ op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] },
|
||||
{ op: "prepend_at", pos: makeTag(2, "bbb"), lines: ["BEFORE"] },
|
||||
{ op: "replace_line", pos: makeTag(2, "bbb"), lines: ["BBB"] },
|
||||
];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\nBEFORE\nBBB\nccc");
|
||||
@@ -482,7 +486,7 @@ describe("applyHashlineEdits — heuristics", () => {
|
||||
const srcHash = computeLineHash(2, "bbb");
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
op: "replace",
|
||||
op: "replace_line",
|
||||
pos: parseTag(`2#${srcHash}export function foo(a, b) {}`), // comma in trailing content
|
||||
lines: ["BBB"],
|
||||
},
|
||||
@@ -496,7 +500,7 @@ describe("applyHashlineEdits — heuristics", () => {
|
||||
const content = ["import { foo } from 'x';", "import { bar } from 'y';", "const x = 1;"].join("\n");
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
op: "replace",
|
||||
op: "replace_range",
|
||||
pos: makeTag(1, "import { foo } from 'x';"),
|
||||
end: makeTag(2, "import { bar } from 'y';"),
|
||||
lines: ["import {foo} from 'x';", "import { bar } from 'y';", "// added"],
|
||||
@@ -514,81 +518,68 @@ describe("applyHashlineEdits — heuristics", () => {
|
||||
it("treats same-line ranges as single-line replacements", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const good = makeTag(2, "bbb");
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: good, end: good, lines: ["BBB"] }];
|
||||
const edits: HashlineEdit[] = [{ op: "replace_range", pos: good, end: good, lines: ["BBB"] }];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\nBBB\nccc");
|
||||
});
|
||||
|
||||
it("auto-corrects off-by-one range end that duplicates a closing brace", () => {
|
||||
it("preserves duplicated trailing closer lines exactly as provided", () => {
|
||||
const content = "if (ok) {\n run();\n}\nafter();";
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
op: "replace",
|
||||
op: "replace_range",
|
||||
pos: makeTag(1, "if (ok) {"),
|
||||
end: makeTag(2, " run();"),
|
||||
lines: ["if (ok) {", " runSafe();", "}"],
|
||||
},
|
||||
];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("if (ok) {\n runSafe();\n}\nafter();");
|
||||
expect(result.lines).toBe("if (ok) {\n runSafe();\n}\n}\nafter();");
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings?.[0]).toContain("Auto-corrected range replace");
|
||||
expect(result.warnings?.[0]).toContain('"}"');
|
||||
expect(result.warnings?.[0]).toContain("Possible boundary duplication");
|
||||
expect(result.warnings?.[0]).toContain("set `end` to 3#RZ");
|
||||
});
|
||||
|
||||
it('auto-corrects off-by-one range end that duplicates a ");" closer', () => {
|
||||
const content = "doThing(\n value,\n);\nnext();";
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
op: "replace",
|
||||
pos: makeTag(1, "doThing("),
|
||||
end: makeTag(2, " value,"),
|
||||
lines: ["doThing(", " normalize(value),", ");"],
|
||||
},
|
||||
];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("doThing(\n normalize(value),\n);\nnext();");
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings?.[0]).toContain('");"');
|
||||
});
|
||||
|
||||
it("auto-corrects duplicated trailing lines when they match the next surviving line", () => {
|
||||
it("preserves duplicated trailing content when replacement re-emits the next line", () => {
|
||||
const content = "start\n oldCall();\nnextCall();\nafter();";
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
op: "replace",
|
||||
op: "replace_range",
|
||||
pos: makeTag(1, "start"),
|
||||
end: makeTag(2, " oldCall();"),
|
||||
lines: ["start", " newCall();", "nextCall();"],
|
||||
},
|
||||
];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("start\n newCall();\nnextCall();\nafter();");
|
||||
expect(result.lines).toBe("start\n newCall();\nnextCall();\nnextCall();\nafter();");
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings?.[0]).toContain("removed trailing replacement line");
|
||||
expect(result.warnings?.[0]).toContain("Possible boundary duplication");
|
||||
expect(result.warnings?.[0]).toContain("set `end` to 3#HR");
|
||||
});
|
||||
|
||||
it("auto-corrects off-by-one range start that duplicates a preceding line", () => {
|
||||
it("preserves duplicated leading content when replacement re-emits the previous line", () => {
|
||||
const content = "if (x) {\n oldBody();\n}\nafter();";
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
op: "replace",
|
||||
op: "replace_range",
|
||||
pos: makeTag(2, " oldBody();"),
|
||||
end: makeTag(3, "}"),
|
||||
lines: ["if (x) {", " newBody();", "}"],
|
||||
},
|
||||
];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("if (x) {\n newBody();\n}\nafter();");
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings?.[0]).toContain("removed leading replacement line");
|
||||
expect(result.lines).toBe("if (x) {\nif (x) {\n newBody();\n}\nafter();");
|
||||
expect(result.warnings).toBeUndefined();
|
||||
});
|
||||
|
||||
it("auto-corrects leading escaped tab indentation by default", () => {
|
||||
const previous = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
|
||||
delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
|
||||
try {
|
||||
const content = "root\n\tchild\n\t\tvalue\nend";
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] }];
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "replace_line", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] },
|
||||
];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("root\n\tchild\n\t\treplaced\nend");
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
@@ -604,7 +595,9 @@ describe("applyHashlineEdits — heuristics", () => {
|
||||
Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS = "0";
|
||||
try {
|
||||
const content = "root\n\tchild\n\t\tvalue\nend";
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] }];
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "replace_line", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] },
|
||||
];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("root\n\tchild\n\\t\\treplaced\nend");
|
||||
expect(result.warnings).toBeUndefined();
|
||||
@@ -614,14 +607,14 @@ describe("applyHashlineEdits — heuristics", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not auto-correct when edit already includes real tab characters", () => {
|
||||
it("preserves mixed real-tab and escaped-tab content verbatim", () => {
|
||||
const previous = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
|
||||
delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
|
||||
try {
|
||||
const content = "root\n\tchild\n\t\tvalue\nend";
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
op: "replace",
|
||||
op: "replace_line",
|
||||
pos: makeTag(3, "\t\tvalue"),
|
||||
lines: ["\t\talready-tab", "\\t\\tescaped-still-literal"],
|
||||
},
|
||||
@@ -634,9 +627,10 @@ describe("applyHashlineEdits — heuristics", () => {
|
||||
else Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("warns on literal \\uDDDD without changing content", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: ["\\uDDDD"] }];
|
||||
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "bbb"), lines: ["\\uDDDD"] }];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\n\\uDDDD\nccc");
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
@@ -652,8 +646,8 @@ describe("applyHashlineEdits — multiple edits", () => {
|
||||
it("applies two non-overlapping replaces (bottom-up safe)", () => {
|
||||
const content = "aaa\nbbb\nccc\nddd\neee";
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] },
|
||||
{ op: "replace", pos: makeTag(4, "ddd"), lines: ["DDD"] },
|
||||
{ op: "replace_line", pos: makeTag(2, "bbb"), lines: ["BBB"] },
|
||||
{ op: "replace_line", pos: makeTag(4, "ddd"), lines: ["DDD"] },
|
||||
];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
@@ -664,8 +658,8 @@ describe("applyHashlineEdits — multiple edits", () => {
|
||||
it("applies replace + delete in one call", () => {
|
||||
const content = "aaa\nbbb\nccc\nddd";
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] },
|
||||
{ op: "replace", pos: makeTag(4, "ddd"), lines: [] },
|
||||
{ op: "replace_line", pos: makeTag(2, "bbb"), lines: ["BBB"] },
|
||||
{ op: "replace_line", pos: makeTag(4, "ddd"), lines: [] },
|
||||
];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
@@ -675,8 +669,8 @@ describe("applyHashlineEdits — multiple edits", () => {
|
||||
it("applies replace + append in one call", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "replace", pos: makeTag(3, "ccc"), lines: ["CCC"] },
|
||||
{ op: "append", pos: makeTag(1, "aaa"), lines: ["INSERTED"] },
|
||||
{ op: "replace_line", pos: makeTag(3, "ccc"), lines: ["CCC"] },
|
||||
{ op: "append_at", pos: makeTag(1, "aaa"), lines: ["INSERTED"] },
|
||||
];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
@@ -687,12 +681,12 @@ describe("applyHashlineEdits — multiple edits", () => {
|
||||
const content = "one\ntwo\nthree\nfour\nfive\nsix";
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
op: "replace",
|
||||
op: "replace_range",
|
||||
pos: makeTag(2, "two"),
|
||||
end: makeTag(3, "three"),
|
||||
lines: ["TWO_THREE"],
|
||||
},
|
||||
{ op: "replace", pos: makeTag(6, "six"), lines: ["SIX"] },
|
||||
{ op: "replace_line", pos: makeTag(6, "six"), lines: ["SIX"] },
|
||||
];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
@@ -702,7 +696,9 @@ describe("applyHashlineEdits — multiple edits", () => {
|
||||
it("single-line replace expanding to multiple lines is not a noop", () => {
|
||||
const content = "aaa\n\nccc";
|
||||
const blankHash = computeLineHash(2, "");
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: { line: 2, hash: blankHash }, lines: ["", "inserted", ""] }];
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "replace_line", pos: { line: 2, hash: blankHash }, lines: ["", "inserted", ""] },
|
||||
];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\n\ninserted\n\nccc");
|
||||
expect(result.firstChangedLine).toBe(2);
|
||||
@@ -724,13 +720,13 @@ describe("applyHashlineEdits — errors", () => {
|
||||
it("rejects stale hash", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
// Use a hash that doesn't match any line (avoid 00 — ccc hashes to 00)
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: parseTag("2#QQ"), lines: ["BBB"] }];
|
||||
const edits: HashlineEdit[] = [{ op: "replace_line", pos: parseTag("2#QQ"), lines: ["BBB"] }];
|
||||
expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError);
|
||||
});
|
||||
|
||||
it("stale hash error shows >>> markers with correct hashes", () => {
|
||||
const content = "aaa\nbbb\nccc\nddd\neee";
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: parseTag("2#QQ"), lines: ["BBB"] }];
|
||||
const edits: HashlineEdit[] = [{ op: "replace_line", pos: parseTag("2#QQ"), lines: ["BBB"] }];
|
||||
|
||||
try {
|
||||
applyHashlineEdits(content, edits);
|
||||
@@ -754,8 +750,8 @@ describe("applyHashlineEdits — errors", () => {
|
||||
const content = "aaa\nbbb\nccc\nddd\neee";
|
||||
// Use hashes that don't match any line (avoid 00 — ccc hashes to 00)
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "replace", pos: parseTag("2#ZZ"), lines: ["BBB"] },
|
||||
{ op: "replace", pos: parseTag("4#ZZ"), lines: ["DDD"] },
|
||||
{ op: "replace_line", pos: parseTag("2#ZZ"), lines: ["BBB"] },
|
||||
{ op: "replace_line", pos: parseTag("4#ZZ"), lines: ["DDD"] },
|
||||
];
|
||||
|
||||
try {
|
||||
@@ -776,7 +772,7 @@ describe("applyHashlineEdits — errors", () => {
|
||||
it("does not relocate stale line refs even when hash uniquely matches another line", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const staleButUnique = parseTag(`2#${computeLineHash(1, "ccc")}`);
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: staleButUnique, lines: ["CCC"] }];
|
||||
const edits: HashlineEdit[] = [{ op: "replace_line", pos: staleButUnique, lines: ["CCC"] }];
|
||||
try {
|
||||
applyHashlineEdits(content, edits);
|
||||
expect.unreachable("should have thrown");
|
||||
@@ -790,31 +786,33 @@ describe("applyHashlineEdits — errors", () => {
|
||||
it("does not relocate when expected hash is non-unique", () => {
|
||||
const content = "dup\nmid\ndup";
|
||||
const staleDuplicate = parseTag(`2#${computeLineHash(1, "dup")}`);
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: staleDuplicate, lines: ["DUP"] }];
|
||||
const edits: HashlineEdit[] = [{ op: "replace_line", pos: staleDuplicate, lines: ["DUP"] }];
|
||||
|
||||
expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError);
|
||||
});
|
||||
|
||||
it("rejects out-of-range line", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: parseTag("10#ZZ"), lines: ["X"] }];
|
||||
const edits: HashlineEdit[] = [{ op: "replace_line", pos: parseTag("10#ZZ"), lines: ["X"] }];
|
||||
|
||||
expect(() => applyHashlineEdits(content, edits)).toThrow(/does not exist/);
|
||||
});
|
||||
|
||||
it("rejects range with start > end", () => {
|
||||
const content = "aaa\nbbb\nccc\nddd\neee";
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(5, "eee"), end: makeTag(2, "bbb"), lines: ["X"] }];
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "replace_range", pos: makeTag(5, "eee"), end: makeTag(2, "bbb"), lines: ["X"] },
|
||||
];
|
||||
|
||||
expect(() => applyHashlineEdits(content, edits)).toThrow();
|
||||
});
|
||||
|
||||
it("accepts append/prepend with empty text by inserting empty lines", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const appendEdits: HashlineEdit[] = [{ op: "append", pos: makeTag(1, "aaa"), lines: [] }];
|
||||
const appendEdits: HashlineEdit[] = [{ op: "append_at", pos: makeTag(1, "aaa"), lines: [] }];
|
||||
expect(applyHashlineEdits(content, appendEdits).lines).toBe("aaa\n\nbbb");
|
||||
|
||||
const prependEdits: HashlineEdit[] = [{ op: "prepend", pos: makeTag(1, "aaa"), lines: [] }];
|
||||
const prependEdits: HashlineEdit[] = [{ op: "prepend_at", pos: makeTag(1, "aaa"), lines: [] }];
|
||||
expect(applyHashlineEdits(content, prependEdits).lines).toBe("\naaa\nbbb");
|
||||
});
|
||||
});
|
||||
@@ -995,7 +993,7 @@ describe("hashlineParseContent", () => {
|
||||
const fileContent = "# Title\n- old item\n- old item 2\nfooter";
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
op: "replace",
|
||||
op: "replace_line",
|
||||
pos: makeTag(2, "- old item"),
|
||||
lines: hashlineParseText("- [x] new item"),
|
||||
},
|
||||
@@ -1008,7 +1006,7 @@ describe("hashlineParseContent", () => {
|
||||
// All replacement lines start with '- ', triggering the 50% heuristic when '-' matched.
|
||||
const fileContent = "- [x] done\n- [ ] pending\n- [ ] also pending";
|
||||
const newContent = hashlineParseText("- [x] done");
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "- [ ] pending"), lines: newContent }];
|
||||
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "- [ ] pending"), lines: newContent }];
|
||||
const result = applyHashlineEdits(fileContent, edits);
|
||||
expect(result.lines).toBe("- [x] done\n- [x] done\n- [ ] also pending");
|
||||
});
|
||||
@@ -1032,7 +1030,7 @@ describe("hashlineParseContent", () => {
|
||||
const fileContent = [" # cuDNN section", " # Note: Using version 1.23.0", ' $Version = "1.23.0"'].join("\n");
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
op: "replace",
|
||||
op: "replace_line",
|
||||
pos: makeTag(2, " # Note: Using version 1.23.0"),
|
||||
lines: hashlineParseText([" # Note: Using version 1.24.x"]),
|
||||
},
|
||||
@@ -1047,7 +1045,7 @@ describe("hashlineParseContent", () => {
|
||||
const fileContent = "const x = 1;\n// TODO: old\n# TODO: remove this\nconst y = 2;";
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
op: "replace",
|
||||
op: "replace_line",
|
||||
pos: makeTag(3, "# TODO: remove this"),
|
||||
lines: hashlineParseText(["# TODO: remove this -- done"]),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
||||
import type { Args } from "../src/cli/args";
|
||||
import { buildInitialMessage } from "../src/cli/initial-message";
|
||||
|
||||
function createArgs(messages: string[]): Args {
|
||||
return {
|
||||
messages,
|
||||
fileArgs: [],
|
||||
unknownFlags: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildInitialMessage", () => {
|
||||
it("combines stdin, file text, and the first CLI message", () => {
|
||||
const parsed = createArgs(["first", "second"]);
|
||||
const images: ImageContent[] = [{ type: "image", data: "abc123", mimeType: "image/png" }];
|
||||
|
||||
const result = buildInitialMessage({
|
||||
parsed,
|
||||
stdinContent: "stdin",
|
||||
fileText: "file-",
|
||||
fileImages: images,
|
||||
});
|
||||
|
||||
expect(result.initialMessage).toBe("stdin\nfile-first");
|
||||
expect(result.initialImages).toEqual(images);
|
||||
expect(parsed.messages).toEqual(["second"]);
|
||||
});
|
||||
|
||||
it("leaves plain CLI messages untouched when there is no initial file or stdin input", () => {
|
||||
const parsed = createArgs(["first", "second"]);
|
||||
|
||||
const result = buildInitialMessage({ parsed });
|
||||
|
||||
expect(result.initialMessage).toBeUndefined();
|
||||
expect(result.initialImages).toBeUndefined();
|
||||
expect(parsed.messages).toEqual(["first", "second"]);
|
||||
});
|
||||
});
|
||||
@@ -4,25 +4,25 @@ import { KeybindingsManager } from "../src/config/keybindings";
|
||||
describe("KeybindingsManager.getDisplayString", () => {
|
||||
it("formats a single binding as a human-readable key hint", () => {
|
||||
const keybindings = KeybindingsManager.inMemory({
|
||||
dequeue: "alt+up",
|
||||
"app.message.dequeue": "alt+up",
|
||||
});
|
||||
|
||||
expect(keybindings.getDisplayString("dequeue")).toBe("Alt+Up");
|
||||
expect(keybindings.getDisplayString("app.message.dequeue")).toBe("Alt+Up");
|
||||
});
|
||||
|
||||
it("formats multiple bindings with the existing separator", () => {
|
||||
const keybindings = KeybindingsManager.inMemory({
|
||||
copyPrompt: ["alt+shift+c", "ctrl+shift+c"],
|
||||
"app.clipboard.copyPrompt": ["alt+shift+c", "ctrl+shift+c"],
|
||||
});
|
||||
|
||||
expect(keybindings.getDisplayString("copyPrompt")).toBe("Alt+Shift+C/Ctrl+Shift+C");
|
||||
expect(keybindings.getDisplayString("app.clipboard.copyPrompt")).toBe("Alt+Shift+C/Ctrl+Shift+C");
|
||||
});
|
||||
|
||||
it("returns an empty string when the action has no binding", () => {
|
||||
const keybindings = KeybindingsManager.inMemory({
|
||||
copyPrompt: [],
|
||||
"app.clipboard.copyPrompt": [],
|
||||
});
|
||||
|
||||
expect(keybindings.getDisplayString("copyPrompt")).toBe("");
|
||||
expect(keybindings.getDisplayString("app.clipboard.copyPrompt")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-ai";
|
||||
import { KeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings";
|
||||
import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { ModelSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/model-selector";
|
||||
import { SessionSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/session-selector";
|
||||
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import type { SessionInfo } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { setKeybindings, type TUI } from "@oh-my-pi/pi-tui";
|
||||
|
||||
beforeAll(() => {
|
||||
initTheme();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setKeybindings(KeybindingsManager.inMemory());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createSession(id: string, title: string): SessionInfo {
|
||||
return {
|
||||
path: `/tmp/${id}.jsonl`,
|
||||
id,
|
||||
cwd: "/tmp",
|
||||
title,
|
||||
created: new Date("2024-01-01T00:00:00Z"),
|
||||
modified: new Date("2024-01-02T00:00:00Z"),
|
||||
messageCount: 1,
|
||||
firstMessage: `${title} first message`,
|
||||
allMessagesText: `${title} first message`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("component escape bindings", () => {
|
||||
it("uses app.interrupt for session selector cancel without changing Ctrl+C exit", () => {
|
||||
const keybindings = KeybindingsManager.inMemory({
|
||||
"app.interrupt": "alt+x",
|
||||
});
|
||||
setKeybindings(keybindings);
|
||||
|
||||
const onCancel = vi.fn();
|
||||
const onExit = vi.fn();
|
||||
const selector = new SessionSelectorComponent(
|
||||
[createSession("session-a", "Alpha"), createSession("session-b", "Beta")],
|
||||
() => {},
|
||||
onCancel,
|
||||
onExit,
|
||||
);
|
||||
|
||||
selector.handleInput("\x1b");
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
|
||||
selector.handleInput("\x1bx");
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
|
||||
selector.handleInput("\x03");
|
||||
expect(onExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses tui.select.cancel for model selector cancellation", async () => {
|
||||
const keybindings = KeybindingsManager.inMemory({
|
||||
"tui.select.cancel": "ctrl+g",
|
||||
});
|
||||
setKeybindings(keybindings);
|
||||
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
|
||||
if (!model) {
|
||||
throw new Error("Expected bundled model anthropic/claude-sonnet-4-5");
|
||||
}
|
||||
|
||||
const settings = Settings.isolated({
|
||||
modelRoles: {
|
||||
default: `${model.provider}/${model.id}`,
|
||||
},
|
||||
});
|
||||
const modelRegistry = {
|
||||
getAll: () => [model],
|
||||
getDiscoverableProviders: () => [],
|
||||
} as unknown as ModelRegistry;
|
||||
const ui = {
|
||||
requestRender: vi.fn(),
|
||||
} as unknown as TUI;
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const selector = new ModelSelectorComponent(
|
||||
ui,
|
||||
model,
|
||||
settings,
|
||||
modelRegistry,
|
||||
[{ model, thinkingLevel: "off" }],
|
||||
() => {},
|
||||
onCancel,
|
||||
);
|
||||
|
||||
await Bun.sleep(0);
|
||||
|
||||
selector.handleInput("\x1b");
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
|
||||
selector.handleInput("\x07");
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user