refactor: reorganized exports and removed obsolete helper branches

- Removed export leakage by demoting many helper and const symbols to module-local scope.
- Renamed underscore-prefixed internals and cache fields, then updated related references and `satisfies never` checks.
- Deleted obsolete logic branches and helpers, including harmony-stream interruption flow and unused benchmark runtime helpers.
- Updated Biome config and manifests by broadening lint coverage and removing an unused `@napi-rs/cli` dev dependency.
- Adjusted tests and utilities to use renamed test helpers and remove redundant private test-only helpers/locals.
This commit is contained in:
can1357
2026-05-14 04:02:09 +02:00
parent 6b6cc417f2
commit f1f6516056
141 changed files with 4219 additions and 5465 deletions
+29
View File
@@ -0,0 +1,29 @@
{
// Fallow configuration — see `bunx fallow config-schema` for the full schema.
//
// `entry` is critical: fallow's dead-code analyzer treats unlisted test files
// as unreachable, which means any helper imported only from other tests looks
// orphaned. Without this, `fallow fix` strips real `export` keywords from
// shared test utilities (e.g. `e2eApiKey`, `withEnv`, `waitForDelayOrAbort`),
// breaking the test build with `noUnusedVariables` lint errors.
"entry": [
"packages/*/test/**/*.{ts,tsx}",
"packages/*/test/**/*.test.{ts,tsx}",
"packages/*/bench/**/*.{ts,tsx}",
"packages/*/scripts/**/*.ts"
],
"duplicates": {
"ignore": [
// Generated from `packages/natives/scripts/native-index.template.js` via gen-enums.ts.
"packages/natives/native/index.js",
// Generated by `packages/coding-agent/scripts/generate-docs-index.ts`.
"packages/coding-agent/src/internal-urls/docs-index.generated.ts",
// Embedded HTML asset shipped as a static template, not a code module.
"packages/coding-agent/src/export/html/template.js",
// Generated/owned upstream — see packages/ai/scripts/generate-models.ts.
"packages/ai/src/models.json"
],
"ignoreImports": true
},
"rules": {}
}
+6
View File
@@ -7,10 +7,16 @@
}, },
"linter": { "linter": {
"enabled": true, "enabled": true,
"includes": ["**"],
"rules": { "rules": {
"recommended": true, "recommended": true,
"a11y": "off",
"correctness": { "correctness": {
"noUnusedImports": "error", "noUnusedImports": "error",
"noUnusedVariables": {
"level": "warn",
"fix": "none"
},
"noVoidTypeReturn": "off" "noVoidTypeReturn": "off"
}, },
"style": { "style": {
+141 -142
View File
@@ -1,144 +1,143 @@
{ {
"name": "omp-monorepo", "name": "omp-monorepo",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "bun@1.3.14", "packageManager": "bun@1.3.14",
"workspaces": { "workspaces": {
"packages": [ "packages": [
"packages/*" "packages/*"
], ],
"catalog": { "catalog": {
"@agentclientprotocol/sdk": "0.21.0", "@agentclientprotocol/sdk": "0.21.0",
"@anthropic-ai/sdk": "^0.94.0", "@anthropic-ai/sdk": "^0.94.0",
"@aws-sdk/client-bedrock-runtime": "^3.1043.0", "@aws-sdk/client-bedrock-runtime": "^3.1043.0",
"@aws-sdk/credential-provider-node": "^3.972.39", "@aws-sdk/credential-provider-node": "^3.972.39",
"@babel/generator": "^7.29.1", "@babel/generator": "^7.29.1",
"@babel/parser": "^7.29.3", "@babel/parser": "^7.29.3",
"@babel/traverse": "^7.29.0", "@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0", "@babel/types": "^7.29.0",
"@biomejs/biome": "^2.4.14", "@biomejs/biome": "^2.4.14",
"@bufbuild/protobuf": "^2.12.0", "@bufbuild/protobuf": "^2.12.0",
"@bufbuild/protoc-gen-es": "^2.12.0", "@bufbuild/protoc-gen-es": "^2.12.0",
"@google/genai": "^1.52.0", "@google/genai": "^1.52.0",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"@napi-rs/cli": "3.6.2", "@napi-rs/cli": "3.6.2",
"@oh-my-pi/omp-stats": "15.0.0", "@oh-my-pi/omp-stats": "15.0.0",
"@oh-my-pi/pi-agent-core": "15.0.0", "@oh-my-pi/pi-agent-core": "15.0.0",
"@oh-my-pi/pi-ai": "15.0.0", "@oh-my-pi/pi-ai": "15.0.0",
"@oh-my-pi/pi-coding-agent": "15.0.0", "@oh-my-pi/pi-coding-agent": "15.0.0",
"@oh-my-pi/pi-natives": "15.0.0", "@oh-my-pi/pi-natives": "15.0.0",
"@oh-my-pi/pi-tui": "15.0.0", "@oh-my-pi/pi-tui": "15.0.0",
"@oh-my-pi/pi-utils": "15.0.0", "@oh-my-pi/pi-utils": "15.0.0",
"@puppeteer/browsers": "^2.13.0", "@puppeteer/browsers": "^2.13.0",
"@sinclair/typebox": "^0.34.49", "@sinclair/typebox": "^0.34.49",
"@smithy/node-http-handler": "^4.6.1", "@smithy/node-http-handler": "^4.6.1",
"@tailwindcss/node": "^4.2.4", "@tailwindcss/node": "^4.2.4",
"@types/babel__generator": "^7.27.0", "@types/babel__generator": "^7.27.0",
"@types/babel__traverse": "^7.28.0", "@types/babel__traverse": "^7.28.0",
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/turndown": "5.0.6", "@types/turndown": "5.0.6",
"@typescript/native-preview": "7.0.0-dev.20260505.1", "@typescript/native-preview": "7.0.0-dev.20260505.1",
"@xterm/headless": "^6.0.0", "@xterm/headless": "^6.0.0",
"ajv": "^8.20.0", "ajv": "^8.20.0",
"ajv-formats": "^3.0.1", "ajv-formats": "^3.0.1",
"beautiful-mermaid": "^1.1.3", "beautiful-mermaid": "^1.1.3",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"diff": "^9.0.0", "diff": "^9.0.0",
"fflate": "0.8.2", "fflate": "0.8.2",
"handlebars": "^4.7.9", "handlebars": "^4.7.9",
"linkedom": "^0.18.12", "linkedom": "^0.18.12",
"lint-staged": "^16.4.0", "lint-staged": "^16.4.0",
"lru-cache": "11.3.6", "lru-cache": "11.3.6",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"marked": "^18.0.3", "marked": "^18.0.3",
"markit-ai": "0.5.3", "markit-ai": "0.5.3",
"openai": "^6.36.0", "openai": "^6.36.0",
"partial-json": "^0.1.7", "partial-json": "^0.1.7",
"postcss": "^8.5.14", "postcss": "^8.5.14",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"proxy-agent": "^8.0.1", "proxy-agent": "^8.0.1",
"puppeteer-core": "^24.42.0", "puppeteer-core": "^24.42.0",
"react": "19.2.5", "react": "19.2.5",
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",
"react-dom": "19.2.5", "react-dom": "19.2.5",
"regexp-tree": "^0.1.27", "regexp-tree": "^0.1.27",
"tailwindcss": "^4.2.4", "tailwindcss": "^4.2.4",
"turndown": "7.2.4", "turndown": "7.2.4",
"turndown-plugin-gfm": "1.0.2", "turndown-plugin-gfm": "1.0.2",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"winston": "^3.19.0", "winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0", "winston-daily-rotate-file": "^5.0.0",
"zod": "4.4.3" "zod": "4.4.3"
} }
}, },
"overrides": { "overrides": {
"ajv": "8.20.0" "ajv": "8.20.0"
}, },
"scripts": { "scripts": {
"install:dev": "bun install && bun --cwd=packages/coding-agent link && bun --cwd=packages/ai link", "install:dev": "bun install && bun --cwd=packages/coding-agent link && bun --cwd=packages/ai link",
"dev": "bun --cwd=packages/coding-agent src/cli.ts", "dev": "bun --cwd=packages/coding-agent src/cli.ts",
"stats": "bun --cwd=packages/coding-agent src/cli.ts stats", "stats": "bun --cwd=packages/coding-agent src/cli.ts stats",
"build": "bun run --workspaces --if-present build", "build": "bun run --workspaces --if-present build",
"build:native": "bun --cwd=packages/natives run build", "build:native": "bun --cwd=packages/natives run build",
"test": "bun run --parallel test:ts test:rs", "test": "bun run --parallel test:ts test:rs",
"test:ts": "bun run --workspaces --if-present test -- --only-failures", "test:ts": "bun run --workspaces --if-present test -- --only-failures",
"test:rs": "bun scripts/run-rs-task.ts test:rs", "test:rs": "bun scripts/run-rs-task.ts test:rs",
"check": "bun run --parallel check:ts check:rs", "check": "bun run --parallel check:ts check:rs",
"check:ts": "bun run check:tools && bun run --workspaces --if-present check", "check:ts": "bun run check:tools && bun run --workspaces --if-present check",
"check:tools": "biome check . --no-errors-on-unmatched", "check:tools": "biome check . --no-errors-on-unmatched",
"check:rs": "bun scripts/run-rs-task.ts check:rs", "check:rs": "bun scripts/run-rs-task.ts check:rs",
"lint": "bun run --parallel lint:ts lint:rs", "lint": "bun run --parallel lint:ts lint:rs",
"lint:ts": "bun run --parallel lint:tools && bun run --workspaces --if-present lint", "lint:ts": "bun run --parallel lint:tools && bun run --workspaces --if-present lint",
"lint:tools": "biome lint . --no-errors-on-unmatched", "lint:tools": "biome lint . --no-errors-on-unmatched",
"lint:rs": "bun scripts/run-rs-task.ts lint:rs", "lint:rs": "bun scripts/run-rs-task.ts lint:rs",
"fmt": "bun run --parallel fmt:ts fmt:rs", "fmt": "bun run --parallel fmt:ts fmt:rs",
"fmt:ts": "bun run fmt:tools && bun run --workspaces --if-present fmt", "fmt:ts": "bun run fmt:tools && bun run --workspaces --if-present fmt",
"fmt:tools": "biome format --write . --no-errors-on-unmatched", "fmt:tools": "biome format --write . --no-errors-on-unmatched",
"fmt:rs": "bun scripts/run-rs-task.ts fmt:rs", "fmt:rs": "bun scripts/run-rs-task.ts fmt:rs",
"fix": "bun run --parallel fix:ts fix:rs", "fix": "bun run --parallel fix:ts fix:rs",
"fix:all": "bun run --parallel fix:ts:all fix:rs", "fix:all": "bun run --parallel fix:ts:all fix:rs",
"fix:ts": "bun run fix:tools && bun run --workspaces --if-present fix", "fix:ts": "bun run fix:tools && bun run --workspaces --if-present fix",
"fix:ts:all": "bun run fix:tools:all && bun run --workspaces --if-present fix", "fix:ts:all": "bun run fix:tools:all && bun run --workspaces --if-present fix",
"fix:tools": "biome check --write --unsafe --changed --no-errors-on-unmatched .", "fix:tools": "biome check --write --unsafe --changed --no-errors-on-unmatched .",
"fix:tools:all": "biome check --write --unsafe --no-errors-on-unmatched .", "fix:tools:all": "biome check --write --unsafe --no-errors-on-unmatched .",
"fix:rs": "bun scripts/run-rs-task.ts fix:rs", "fix:rs": "bun scripts/run-rs-task.ts fix:rs",
"ci:check:full": "bun run check:ts", "ci:check:full": "bun run check:ts",
"ci:build:native": "bun scripts/ci-build-native.ts", "ci:build:native": "bun scripts/ci-build-native.ts",
"ci:test:full": "bun run test", "ci:test:full": "bun run test",
"ci:test:smoke": "bun packages/coding-agent/src/cli.ts --version && bun packages/coding-agent/src/cli.ts --help && bun packages/coding-agent/src/cli.ts stats --help && bun packages/coding-agent/src/cli.ts --smoke-test", "ci:test:smoke": "bun packages/coding-agent/src/cli.ts --version && bun packages/coding-agent/src/cli.ts --help && bun packages/coding-agent/src/cli.ts stats --help && bun packages/coding-agent/src/cli.ts --smoke-test",
"ci:test:install-methods": "bash scripts/install-tests/run-ci.sh", "ci:test:install-methods": "bash scripts/install-tests/run-ci.sh",
"ci:release:build-binaries": "bun scripts/ci-release-build-binaries.ts", "ci:release:build-binaries": "bun scripts/ci-release-build-binaries.ts",
"ci:release:publish": "bun scripts/ci-release-publish.ts", "ci:release:publish": "bun scripts/ci-release-publish.ts",
"bench:gen-fixtures": "bun --cwd=packages/typescript-edit-benchmark run src/generate.ts --typescript-dir /tmp/typescript-source --count-per-type 8", "bench:gen-fixtures": "bun --cwd=packages/typescript-edit-benchmark run src/generate.ts --typescript-dir /tmp/typescript-source --count-per-type 8",
"bench:edit": "bun --cwd=packages/typescript-edit-benchmark run start", "bench:edit": "bun --cwd=packages/typescript-edit-benchmark run start",
"stats:sync": "python3 scripts/session-stats/sync.py", "stats:sync": "python3 scripts/session-stats/sync.py",
"stats:tools": "python3 scripts/session-stats/analyze.py tools", "stats:tools": "python3 scripts/session-stats/analyze.py tools",
"stats:edits": "python3 scripts/session-stats/analyze.py edits", "stats:edits": "python3 scripts/session-stats/analyze.py edits",
"stats:followups": "python3 scripts/session-stats/analyze.py followups", "stats:followups": "python3 scripts/session-stats/analyze.py followups",
"prepublishOnly": "bun run check", "prepublishOnly": "bun run check",
"prepare": "bun --cwd=packages/coding-agent run generate-docs-index", "prepare": "bun --cwd=packages/coding-agent run generate-docs-index",
"publish": "bun run prepublishOnly && npm publish -ws --access public", "publish": "bun run prepublishOnly && npm publish -ws --access public",
"publish:dry": "bun run prepublishOnly && npm publish -ws --access public --dry-run", "publish:dry": "bun run prepublishOnly && npm publish -ws --access public --dry-run",
"release": "bun scripts/release.ts", "release": "bun scripts/release.ts",
"generate-models": "bun --cwd=packages/ai run generate-models", "generate-models": "bun --cwd=packages/ai run generate-models",
"generate-docs-index": "bun --cwd=packages/coding-agent run generate-docs-index", "generate-docs-index": "bun --cwd=packages/coding-agent run generate-docs-index",
"generate-template": "bun --cwd=packages/coding-agent run generate-template", "generate-template": "bun --cwd=packages/coding-agent run generate-template",
"check-spoofed-versions": "bun scripts/check-spoofed-versions.ts" "check-spoofed-versions": "bun scripts/check-spoofed-versions.ts"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "catalog:", "@biomejs/biome": "catalog:",
"@bufbuild/protoc-gen-es": "catalog:", "prettier": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
"lint-staged": "catalog:", "lint-staged": "catalog:"
"prettier": "catalog:" },
}, "lint-staged": {
"lint-staged": { "*.{js,ts,jsx,tsx,json,jsonc,css}": "biome check --write --no-errors-on-unmatched"
"*.{js,ts,jsx,tsx,json,jsonc,css}": "biome check --write --no-errors-on-unmatched" }
}
} }
-22
View File
@@ -14,11 +14,9 @@ import {
import { sanitizeText } from "@oh-my-pi/pi-natives"; import { sanitizeText } from "@oh-my-pi/pi-natives";
import { import {
createHarmonyAuditEvent, createHarmonyAuditEvent,
extractHarmonyRemoved,
type HarmonyDetection, type HarmonyDetection,
type HarmonyRecoveredToolCall, type HarmonyRecoveredToolCall,
isHarmonyLeakMitigationTarget, isHarmonyLeakMitigationTarget,
recoverHarmonyToolCall,
signalListLabel, signalListLabel,
} from "./harmony-leak"; } from "./harmony-leak";
import type { import type {
@@ -502,26 +500,6 @@ async function streamAssistantResponse(
const responseIterator = response[Symbol.asyncIterator](); const responseIterator = response[Symbol.asyncIterator]();
const _interruptForHarmonyLeak = (message: AssistantMessage, detection: HarmonyDetection): never => {
const recovered = recoverHarmonyToolCall(message, detection);
const removed = recovered?.removed ?? extractHarmonyRemoved(message, detection);
harmonyAbortController?.abort();
responseIterator.return?.()?.catch(() => {});
if (recovered) {
if (addedPartial) {
context.messages[context.messages.length - 1] = recovered.message;
} else {
context.messages.push(recovered.message);
stream.push({ type: "message_start", message: { ...recovered.message } });
}
stream.push({ type: "message_end", message: recovered.message });
throw new HarmonyLeakInterruption(detection, removed, recovered);
}
if (addedPartial) {
context.messages.pop();
}
throw new HarmonyLeakInterruption(detection, removed);
};
// Set up a single abort race: register the abort listener once for the whole // Set up a single abort race: register the abort listener once for the whole
// stream and reuse the same race promise for every iterator.next() instead of // stream and reuse the same race promise for every iterator.next() instead of
// allocating Promise.withResolvers and add/removeEventListener per event. // allocating Promise.withResolvers and add/removeEventListener per event.
-1
View File
@@ -36,7 +36,6 @@ const FENCE_RE = /^\s*(?:```+|~~~+)/;
const SCRIPT_CLASS = const SCRIPT_CLASS =
"\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u0400-\u04FF\u0E00-\u0E7F\u10A0-\u10FF\u0530-\u058F\u0C80-\u0CFF\u0C00-\u0C7F\u0900-\u097F\u0600-\u06FF\u0D00-\u0D7F"; "\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u0400-\u04FF\u0E00-\u0E7F\u10A0-\u10FF\u0530-\u058F\u0C80-\u0CFF\u0C00-\u0C7F\u0900-\u097F\u0600-\u06FF\u0D00-\u0D7F";
const SCRIPT_RUN_RE = new RegExp(`[${SCRIPT_CLASS}]{2,}`, "u"); const SCRIPT_RUN_RE = new RegExp(`[${SCRIPT_CLASS}]{2,}`, "u");
const _SCRIPT_CHAR_RE = new RegExp(`[${SCRIPT_CLASS}]`, "u");
// Recovery registry. Each entry's parser must recognize the configured // Recovery registry. Each entry's parser must recognize the configured
// sentinel (per-tool, see eval/parse.ts and hashline/parser.ts) and surface // sentinel (per-tool, see eval/parse.ts and hashline/parser.ts) and surface
+1 -1
View File
@@ -779,7 +779,7 @@ it("refreshes tools and system prompt between same-turn model calls", async () =
}; };
const stream = agentLoop([userPrompt], context, config, undefined, streamFn); const stream = agentLoop([userPrompt], context, config, undefined, streamFn);
for await (const _event of stream) { for await (const _ of stream) {
// consume // consume
} }
+1 -1
View File
@@ -27,7 +27,7 @@ export function pushAlphaThenDoneEvent(
} }
} }
export function createUsage(): Usage { function createUsage(): Usage {
return { return {
input: 0, input: 0,
output: 0, output: 0,
@@ -6,17 +6,15 @@ import type {
ResponseInput, ResponseInput,
} from "openai/resources/responses/responses"; } from "openai/resources/responses/responses";
import { getEnvApiKey } from "../stream"; import { getEnvApiKey } from "../stream";
import { import type {
type Api, AssistantMessage,
type AssistantMessage, Context,
type Context, Model,
type Model, ServiceTier,
type ServiceTier, StreamFunction,
type StreamFunction, StreamOptions,
type StreamOptions, Tool,
shouldSendServiceTier, ToolChoice,
type Tool,
type ToolChoice,
} from "../types"; } from "../types";
import { normalizeSystemPrompts } from "../utils"; import { normalizeSystemPrompts } from "../utils";
import { createAbortSourceTracker } from "../utils/abort"; import { createAbortSourceTracker } from "../utils/abort";
@@ -33,8 +31,11 @@ import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice";
import { normalizeOpenAIResponsesPromptCacheKey, supportsDeveloperRole } from "./openai-responses"; import { normalizeOpenAIResponsesPromptCacheKey, supportsDeveloperRole } from "./openai-responses";
import { import {
appendResponsesToolResultMessages, appendResponsesToolResultMessages,
applyCommonResponsesSamplingParams,
applyResponsesReasoningParams,
convertResponsesAssistantMessage, convertResponsesAssistantMessage,
convertResponsesInputContent, convertResponsesInputContent,
createInitialResponsesAssistantMessage,
normalizeResponsesToolCallIdForTransform, normalizeResponsesToolCallIdForTransform,
processResponsesStream, processResponsesStream,
} from "./openai-responses-shared"; } from "./openai-responses-shared";
@@ -101,23 +102,11 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
let firstTokenTime: number | undefined; let firstTokenTime: number | undefined;
const deploymentName = resolveDeploymentName(model, options); const deploymentName = resolveDeploymentName(model, options);
const output: AssistantMessage = { const output: AssistantMessage = createInitialResponsesAssistantMessage(
role: "assistant", "azure-openai-responses",
content: [], model.provider,
api: "azure-openai-responses" as Api, model.id,
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: "stop",
timestamp: Date.now(),
};
let rawRequestDump: RawHttpRequestDump | undefined; let rawRequestDump: RawHttpRequestDump | undefined;
const abortTracker = createAbortSourceTracker(options?.signal); const abortTracker = createAbortSourceTracker(options?.signal);
const firstEventTimeoutAbortError = new Error(AZURE_OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE); const firstEventTimeoutAbortError = new Error(AZURE_OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE);
@@ -279,31 +268,7 @@ function buildParams(
prompt_cache_key: normalizeOpenAIResponsesPromptCacheKey(options?.sessionId), prompt_cache_key: normalizeOpenAIResponsesPromptCacheKey(options?.sessionId),
}; };
if (options?.maxTokens) { applyCommonResponsesSamplingParams(params, options, model.provider);
params.max_output_tokens = options?.maxTokens;
}
if (options?.temperature !== undefined) {
params.temperature = options?.temperature;
}
if (options?.topP !== undefined) {
params.top_p = options.topP;
}
if (options?.topK !== undefined) {
params.top_k = options.topK;
}
if (options?.minP !== undefined) {
params.min_p = options.minP;
}
if (options?.presencePenalty !== undefined) {
params.presence_penalty = options.presencePenalty;
}
if (options?.repetitionPenalty !== undefined) {
params.repetition_penalty = options.repetitionPenalty;
}
if (shouldSendServiceTier(options?.serviceTier, model.provider)) {
params.service_tier = options.serviceTier;
}
if (context.tools) { if (context.tools) {
params.tools = convertTools(context.tools); params.tools = convertTools(context.tools);
@@ -312,36 +277,7 @@ function buildParams(
} }
} }
if (model.reasoning) { applyResponsesReasoningParams(params, model, options, messages);
// Always request encrypted reasoning content so reasoning items can be
// replayed in multi-turn conversations when store is false (items aren't
// persisted server-side, so we must include the full content).
// See: https://github.com/can1357/oh-my-pi/issues/41
params.include = ["reasoning.encrypted_content"];
if (options?.reasoning || options?.reasoningSummary !== undefined) {
const reasoningParams: NonNullable<typeof params.reasoning> = {
effort: options?.reasoning || "medium",
};
if (options?.reasoningSummary !== null) {
reasoningParams.summary = options?.reasoningSummary || "auto";
}
params.reasoning = reasoningParams;
} else {
if (model.name.toLowerCase().startsWith("gpt-5")) {
// Jesus Christ, see https://community.openai.com/t/need-reasoning-false-option-for-gpt-5/1351588/7
messages.push({
role: "developer",
content: [
{
type: "input_text",
text: "# Juice: 0 !important",
},
],
});
}
}
}
return params; return params;
} }
+14 -82
View File
@@ -29,17 +29,22 @@ import { ANTIGRAVITY_SYSTEM_INSTRUCTION, getAntigravityUserAgent, getGeminiCliHe
import { import {
convertMessages, convertMessages,
convertTools, convertTools,
type GoogleThinkingLevel,
isThinkingPart, isThinkingPart,
mapStopReasonString, mapStopReasonString,
mapToolChoice, mapToolChoice,
nextToolCallId,
pushBlockEndEvent,
pushToolCallEvents,
retainThoughtSignature, retainThoughtSignature,
startTextOrThinkingBlock,
} from "./google-shared"; } from "./google-shared";
/** /**
* Thinking level for Gemini 3 models. * Thinking level for Gemini 3 models. Re-exported from `google-shared` so existing
* Mirrors Google's ThinkingLevel enum values. * `import { GoogleThinkingLevel } from "./google-gemini-cli"` callers keep working.
*/ */
export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"; export type { GoogleThinkingLevel };
export interface GoogleGeminiCliOptions extends StreamOptions { export interface GoogleGeminiCliOptions extends StreamOptions {
toolChoice?: "auto" | "none" | "any"; toolChoice?: "auto" | "none" | "any";
@@ -72,9 +77,6 @@ export {
getGeminiCliUserAgent, getGeminiCliUserAgent,
} from "./google-gemini-headers"; } from "./google-gemini-headers";
// Counter for generating unique tool call IDs
let toolCallCounter = 0;
// Retry configuration // Retry configuration
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
const BASE_DELAY_MS = 1000; const BASE_DELAY_MS = 1000;
@@ -426,37 +428,9 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
(!isThinking && currentBlock.type !== "text") (!isThinking && currentBlock.type !== "text")
) { ) {
if (currentBlock) { if (currentBlock) {
if (currentBlock.type === "text") { pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
stream.push({
type: "text_end",
contentIndex: blocks.length - 1,
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (isThinking) {
currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined };
output.content.push(currentBlock);
ensureStarted();
stream.push({
type: "thinking_start",
contentIndex: blockIndex(),
partial: output,
});
} else {
currentBlock = { type: "text", text: "" };
output.content.push(currentBlock);
ensureStarted();
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
} }
currentBlock = startTextOrThinkingBlock(isThinking, output, stream, ensureStarted);
} }
if (currentBlock.type === "thinking") { if (currentBlock.type === "thinking") {
currentBlock.thinking += part.text; currentBlock.thinking += part.text;
@@ -488,30 +462,14 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
if (part.functionCall) { if (part.functionCall) {
hasContent = true; hasContent = true;
if (currentBlock) { if (currentBlock) {
if (currentBlock.type === "text") { pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
currentBlock = null; currentBlock = null;
} }
const providedId = part.functionCall.id; const providedId = part.functionCall.id;
const needsNewId = const needsNewId =
!providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId); !providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId);
const toolCallId = needsNewId const toolCallId = needsNewId ? nextToolCallId(part.functionCall.name || "tool") : providedId;
? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`
: providedId;
const toolCall: ToolCall = { const toolCall: ToolCall = {
type: "toolCall", type: "toolCall",
@@ -523,19 +481,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
output.content.push(toolCall); output.content.push(toolCall);
ensureStarted(); ensureStarted();
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output }); pushToolCallEvents(toolCall, blockIndex(), output, stream);
stream.push({
type: "toolcall_delta",
contentIndex: blockIndex(),
delta: JSON.stringify(toolCall.arguments),
partial: output,
});
stream.push({
type: "toolcall_end",
contentIndex: blockIndex(),
toolCall,
partial: output,
});
} }
} }
} }
@@ -572,21 +518,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
} }
if (currentBlock) { if (currentBlock) {
if (currentBlock.type === "text") { pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
} }
return hasContent; return hasContent;
+446 -4
View File
@@ -1,8 +1,35 @@
/** /**
* Shared utilities for Google Generative AI and Google Cloud Code Assist providers. * Shared utilities for Google Generative AI and Google Cloud Code Assist providers.
*/ */
import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai"; import {
import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types"; type Content,
FinishReason,
FunctionCallingConfigMode,
type GenerateContentConfig,
type GenerateContentParameters,
type GenerateContentResponse,
type GoogleGenAI,
type Part,
type ThinkingConfig,
type ThinkingLevel,
} from "@google/genai";
import { calculateCost } from "../models";
import type {
Api,
AssistantMessage,
Context,
ImageContent,
Model,
StopReason,
StreamOptions,
TextContent,
ThinkingContent,
Tool,
ToolCall,
} from "../types";
import { normalizeSystemPrompts } from "../utils";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
import { prepareSchemaForCCA, sanitizeSchemaForGoogle } from "../utils/schema"; import { prepareSchemaForCCA, sanitizeSchemaForGoogle } from "../utils/schema";
import { transformMessages } from "./transform-messages"; import { transformMessages } from "./transform-messages";
import { NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard"; import { NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
@@ -11,6 +38,26 @@ export { sanitizeSchemaForGoogle };
type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex"; type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex";
/**
* Thinking level for Gemini 3 models. Mirrors Google's `ThinkingLevel` enum values.
* Defined here (not in any specific provider) so all Google providers can reference it
* without inducing a circular dependency.
*/
export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH";
/**
* Sampling/thinking options shared by `streamGoogle` and `streamGoogleVertex`.
* `google-gemini-cli` uses a different transport and request shape — do not extend this for it.
*/
export interface GoogleSharedStreamOptions extends StreamOptions {
toolChoice?: "auto" | "none" | "any";
thinking?: {
enabled: boolean;
budgetTokens?: number;
level?: GoogleThinkingLevel;
};
}
/** /**
* Determines whether a streamed Gemini `Part` should be treated as "thinking". * Determines whether a streamed Gemini `Part` should be treated as "thinking".
* *
@@ -342,8 +389,7 @@ export function mapStopReason(reason: FinishReason): StopReason {
case FinishReason.NO_IMAGE: case FinishReason.NO_IMAGE:
return "error"; return "error";
default: { default: {
const _exhaustive: never = reason; throw new Error(`Unhandled stop reason: ${reason satisfies never}`);
throw new Error(`Unhandled stop reason: ${_exhaustive}`);
} }
} }
} }
@@ -361,3 +407,399 @@ export function mapStopReasonString(reason: string): StopReason {
return "error"; return "error";
} }
} }
/**
* Module-local counter for generating unique tool call IDs across Google providers.
* Shared so that a single monotonically-increasing sequence is used regardless of which
* Google API surface produced the stream — purely for uniqueness, not ordering semantics.
*/
let toolCallCounter = 0;
export function nextToolCallId(name: string): string {
return `${name}_${Date.now()}_${++toolCallCounter}`;
}
/**
* Push the appropriate `text_end` / `thinking_end` event for the given block.
* Shared between the SDK-backed stream consumer and the gemini-cli SSE consumer so
* the end-of-block event shape stays in lockstep.
*/
export function pushBlockEndEvent(
block: TextContent | ThinkingContent,
contentIndex: number,
output: AssistantMessage,
stream: AssistantMessageEventStream,
): void {
if (block.type === "text") {
stream.push({ type: "text_end", contentIndex, content: block.text, partial: output });
} else {
stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: output });
}
}
/**
* Push the three lifecycle events (`toolcall_start` / `toolcall_delta` / `toolcall_end`) for a
* fully-assembled `ToolCall`. Caller is responsible for appending the toolCall to `output.content`
* before invoking — this helper does not mutate `output.content`.
*/
export function pushToolCallEvents(
toolCall: ToolCall,
contentIndex: number,
output: AssistantMessage,
stream: AssistantMessageEventStream,
): void {
stream.push({ type: "toolcall_start", contentIndex, partial: output });
stream.push({
type: "toolcall_delta",
contentIndex,
delta: JSON.stringify(toolCall.arguments),
partial: output,
});
stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
}
/**
* Append a new text- or thinking-block to `output.content` and push the matching
* `text_start` / `thinking_start` event. `onBeforeStartEvent` lets the SSE consumer
* inject its `ensureStarted()` first-token side effect into the canonical event order.
*/
export function startTextOrThinkingBlock(
isThinking: boolean,
output: AssistantMessage,
stream: AssistantMessageEventStream,
onBeforeStartEvent?: () => void,
): TextContent | ThinkingContent {
const block: TextContent | ThinkingContent = isThinking
? { type: "thinking", thinking: "", thinkingSignature: undefined }
: { type: "text", text: "" };
output.content.push(block);
onBeforeStartEvent?.();
const contentIndex = output.content.length - 1;
if (isThinking) {
stream.push({ type: "thinking_start", contentIndex, partial: output });
} else {
stream.push({ type: "text_start", contentIndex, partial: output });
}
return block;
}
/**
* Drives the chunked `generateContentStream` iterator into an `AssistantMessage` and
* the corresponding `AssistantMessageEventStream`. Shared between `streamGoogle` and
* `streamGoogleVertex` — every observable event order and stop-reason rule is preserved.
*
* The caller still owns: `output` construction, timing fields (`duration`/`ttft`),
* `rawRequestDump`, the `client.models.generateContentStream(params)` call itself,
* pushing `start`/`done`/`error` events, and the surrounding try/catch that translates
* thrown errors into `output.stopReason`/`errorMessage`.
*
* This helper handles: the chunk loop, currentBlock flush transitions, usage metadata
* decoding (`calculateCost` included), tool-call id collision avoidance, finish-reason
* mapping, and the abort/stop-reason post-checks that re-throw to bubble into the
* caller's catch.
*/
export async function consumeGoogleStream<T extends GoogleApiType>(args: {
googleStream: AsyncIterable<GenerateContentResponse>;
output: AssistantMessage;
stream: AssistantMessageEventStream;
model: Model<T>;
options: { signal?: AbortSignal } | undefined;
/** Vertex preserves `textSignature` on streamed text deltas; google-generative-ai does not. */
retainTextSignature?: boolean;
onFirstToken?: () => void;
}): Promise<void> {
const { googleStream, output, stream, model, options, retainTextSignature, onFirstToken } = args;
const blocks = output.content;
const blockIndex = () => blocks.length - 1;
let currentBlock: TextContent | ThinkingContent | null = null;
let firstTokenSeen = false;
const flushCurrent = () => {
if (!currentBlock) return;
pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
};
for await (const chunk of googleStream) {
const candidate = chunk.candidates?.[0];
if (candidate?.content?.parts) {
for (const part of candidate.content.parts) {
if (part.text !== undefined) {
if (!firstTokenSeen) {
firstTokenSeen = true;
onFirstToken?.();
}
const isThinking = isThinkingPart(part);
if (
!currentBlock ||
(isThinking && currentBlock.type !== "thinking") ||
(!isThinking && currentBlock.type !== "text")
) {
flushCurrent();
currentBlock = startTextOrThinkingBlock(isThinking, output, stream);
}
if (currentBlock.type === "thinking") {
currentBlock.thinking += part.text;
currentBlock.thinkingSignature = retainThoughtSignature(
currentBlock.thinkingSignature,
part.thoughtSignature,
);
stream.push({
type: "thinking_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
} else {
currentBlock.text += part.text;
if (retainTextSignature) {
currentBlock.textSignature = retainThoughtSignature(
currentBlock.textSignature,
part.thoughtSignature,
);
}
stream.push({
type: "text_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
}
}
if (part.functionCall) {
if (currentBlock) {
flushCurrent();
currentBlock = null;
}
// Generate unique ID if not provided or if it's a duplicate
const providedId = part.functionCall.id;
const needsNewId = !providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId);
const toolCallId = needsNewId ? nextToolCallId(part.functionCall.name || "tool") : providedId;
const toolCall: ToolCall = {
type: "toolCall",
id: toolCallId,
name: part.functionCall.name || "",
arguments: (part.functionCall.args ?? {}) as Record<string, any>,
...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
};
output.content.push(toolCall);
pushToolCallEvents(toolCall, blockIndex(), output, stream);
}
}
}
if (candidate?.finishReason) {
output.stopReason = mapStopReason(candidate.finishReason);
if (output.content.some(b => b.type === "toolCall")) {
output.stopReason = "toolUse";
}
}
if (chunk.usageMetadata) {
// promptTokenCount includes cachedContentTokenCount when cached content is used.
// Subtract to get non-cached input, matching the OpenAI convention where
// input = uncached prompt tokens and cacheRead = cached tokens so that
// input + cacheRead = total prompt tokens (no double-counting).
// Ref: https://ai.google.dev/api/generate-content#v1beta.GenerateContentResponse.UsageMetadata
const cachedTokens = chunk.usageMetadata.cachedContentTokenCount || 0;
const thinkingTokens = chunk.usageMetadata.thoughtsTokenCount || 0;
output.usage = {
input: (chunk.usageMetadata.promptTokenCount || 0) - cachedTokens,
output: (chunk.usageMetadata.candidatesTokenCount || 0) + thinkingTokens,
cacheRead: cachedTokens,
cacheWrite: 0,
totalTokens: chunk.usageMetadata.totalTokenCount || 0,
...(thinkingTokens > 0 ? { reasoningTokens: thinkingTokens } : {}),
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
calculateCost(model, output.usage);
}
}
flushCurrent();
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error(output.errorMessage ?? "An unknown error occurred");
}
}
/**
* Generation/sampling fields that map directly onto Gemini's `GenerateContentConfig`.
* Excludes any provider-specific extensions (`topP`/`topK`/etc are all forwarded as-is).
*/
interface GoogleGenerationConfig extends GenerateContentConfig {
topP?: number;
topK?: number;
minP?: number;
presencePenalty?: number;
repetitionPenalty?: number;
}
/**
* Build the `GenerateContentParameters` payload for the public Gemini API and Vertex AI.
* Both surfaces accept the same `GenerateContentConfig` shape — every numeric/string knob,
* tool-config, thinking-config, and system-instruction conversion is identical.
*
* `google-gemini-cli` is NOT routed through here: its `CloudCodeAssistRequest` body has a
* distinct top-level shape (project/request/requestType) and a different thinking-config
* placement on `generationConfig`.
*/
export function buildGoogleGenerateContentParams<T extends "google-generative-ai" | "google-vertex">(
model: Model<T>,
context: Context,
options: GoogleSharedStreamOptions,
): GenerateContentParameters {
const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
const contents = convertMessages(model, context);
const generationConfig: GoogleGenerationConfig = {};
if (options.temperature !== undefined) generationConfig.temperature = options.temperature;
if (options.maxTokens !== undefined) generationConfig.maxOutputTokens = options.maxTokens;
if (options.topP !== undefined) generationConfig.topP = options.topP;
if (options.topK !== undefined) generationConfig.topK = options.topK;
if (options.minP !== undefined) generationConfig.minP = options.minP;
if (options.presencePenalty !== undefined) generationConfig.presencePenalty = options.presencePenalty;
if (options.repetitionPenalty !== undefined) generationConfig.repetitionPenalty = options.repetitionPenalty;
const config: GenerateContentConfig = {
...(Object.keys(generationConfig).length > 0 && generationConfig),
...(systemPrompts.length > 0 && { systemInstruction: { parts: systemPrompts.map(text => ({ text })) } }),
...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools, model) }),
};
if (context.tools && context.tools.length > 0 && options.toolChoice) {
config.toolConfig = {
functionCallingConfig: {
mode: mapToolChoice(options.toolChoice),
},
};
} else {
config.toolConfig = undefined;
}
if (options.thinking?.enabled && model.reasoning) {
const cfg: ThinkingConfig = { includeThoughts: true };
if (options.thinking.level !== undefined) {
// GoogleThinkingLevel mirrors the SDK's `ThinkingLevel` string enum values 1:1.
cfg.thinkingLevel = options.thinking.level as ThinkingLevel;
} else if (options.thinking.budgetTokens !== undefined) {
cfg.thinkingBudget = options.thinking.budgetTokens;
}
config.thinkingConfig = cfg;
}
if (options.signal) {
if (options.signal.aborted) {
throw new Error("Request aborted");
}
config.abortSignal = options.signal;
}
return {
model: model.id,
contents,
config,
};
}
/**
* Drive the `streamGoogle` / `streamGoogleVertex` event flow: build the assistant message,
* push start/done/error events, run `consumeGoogleStream`, and translate thrown errors into
* the canonical `error` event shape.
*
* Caller-supplied `prepare()` runs inside the try-block so any failure (missing project,
* bad auth, etc.) is funneled through the same error path as a streaming failure.
*/
export function streamGoogleGenAI<T extends "google-generative-ai" | "google-vertex">(args: {
model: Model<T>;
options: GoogleSharedStreamOptions | undefined;
api: T;
retainTextSignature?: boolean;
prepare: () => { client: GoogleGenAI; params: GenerateContentParameters; url: string | undefined };
}): AssistantMessageEventStream {
const { model, options, api, retainTextSignature, prepare } = args;
const stream = new AssistantMessageEventStream();
(async () => {
const startTime = Date.now();
let firstTokenTime: number | undefined;
const output: AssistantMessage = {
role: "assistant",
content: [],
api: api as 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: "stop",
timestamp: Date.now(),
};
let rawRequestDump: RawHttpRequestDump | undefined;
try {
const { client, params, url } = prepare();
options?.onPayload?.(params);
rawRequestDump = {
provider: model.provider,
api: output.api,
model: model.id,
method: "POST",
url,
body: params,
};
const googleStream = await client.models.generateContentStream(params);
stream.push({ type: "start", partial: output });
await consumeGoogleStream({
googleStream,
output,
stream,
model,
options,
retainTextSignature,
onFirstToken: () => {
firstTokenTime = Date.now();
},
});
output.duration = Date.now() - startTime;
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
stream.push({ type: "done", reason: output.stopReason as "length" | "stop" | "toolUse", message: output });
stream.end();
} catch (error) {
for (const block of output.content) {
if ("index" in block) {
delete (block as { index?: number }).index;
}
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = await finalizeErrorMessage(error, rawRequestDump);
output.duration = Date.now() - startTime;
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
}
+19 -371
View File
@@ -1,317 +1,38 @@
import { import { GoogleGenAI } from "@google/genai";
type GenerateContentConfig,
type GenerateContentParameters,
GoogleGenAI,
type ThinkingConfig,
ThinkingLevel,
} from "@google/genai";
import { $env } from "@oh-my-pi/pi-utils"; import { $env } from "@oh-my-pi/pi-utils";
import { calculateCost } from "../models"; import type { Context, Model, StreamFunction } from "../types";
import type { import type { AssistantMessageEventStream } from "../utils/event-stream";
Api, import { buildGoogleGenerateContentParams, type GoogleSharedStreamOptions, streamGoogleGenAI } from "./google-shared";
AssistantMessage,
Context,
Model,
StreamFunction,
StreamOptions,
TextContent,
ThinkingContent,
ToolCall,
} from "../types";
import { normalizeSystemPrompts } from "../utils";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
import type { GoogleThinkingLevel } from "./google-gemini-cli";
import {
convertMessages,
convertTools,
isThinkingPart,
mapStopReason,
mapToolChoice,
retainThoughtSignature,
} from "./google-shared";
export interface GoogleVertexOptions extends StreamOptions { export interface GoogleVertexOptions extends GoogleSharedStreamOptions {
toolChoice?: "auto" | "none" | "any";
thinking?: {
enabled: boolean;
budgetTokens?: number; // -1 for dynamic, 0 to disable
level?: GoogleThinkingLevel;
};
project?: string; project?: string;
location?: string; location?: string;
} }
interface GoogleVertexSamplingConfig extends GenerateContentConfig {
topP?: number;
topK?: number;
minP?: number;
presencePenalty?: number;
repetitionPenalty?: number;
}
const API_VERSION = "v1"; const API_VERSION = "v1";
const THINKING_LEVEL_MAP: Record<GoogleThinkingLevel, ThinkingLevel> = {
THINKING_LEVEL_UNSPECIFIED: ThinkingLevel.THINKING_LEVEL_UNSPECIFIED,
MINIMAL: ThinkingLevel.MINIMAL,
LOW: ThinkingLevel.LOW,
MEDIUM: ThinkingLevel.MEDIUM,
HIGH: ThinkingLevel.HIGH,
};
// Counter for generating unique tool call IDs
let toolCallCounter = 0;
export const streamGoogleVertex: StreamFunction<"google-vertex"> = ( export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
model: Model<"google-vertex">, model: Model<"google-vertex">,
context: Context, context: Context,
options?: GoogleVertexOptions, options?: GoogleVertexOptions,
): AssistantMessageEventStream => { ): AssistantMessageEventStream =>
const stream = new AssistantMessageEventStream(); streamGoogleGenAI({
model,
(async () => { options,
const startTime = Date.now(); api: "google-vertex",
let firstTokenTime: number | undefined; retainTextSignature: true,
prepare: () => {
const output: AssistantMessage = {
role: "assistant",
content: [],
api: "google-vertex" as 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: "stop",
timestamp: Date.now(),
};
let rawRequestDump: RawHttpRequestDump | undefined;
try {
const apiKey = resolveApiKey(options); const apiKey = resolveApiKey(options);
const project = apiKey ? undefined : resolveProject(options); const project = apiKey ? undefined : resolveProject(options);
const location = apiKey ? undefined : resolveLocation(options); const location = apiKey ? undefined : resolveLocation(options);
const client = apiKey ? createClientWithApiKey(model, apiKey) : createClient(model, project!, location!); const client = apiKey ? createClientWithApiKey(model, apiKey) : createClient(model, project!, location!);
const params = buildParams(model, context, options); const params = buildGoogleGenerateContentParams(model, context, options ?? {});
options?.onPayload?.(params); const url = apiKey
rawRequestDump = { ? `https://aiplatform.googleapis.com/${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent`
provider: model.provider, : `https://${location}-aiplatform.googleapis.com/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent`;
api: output.api, return { client, params, url };
model: model.id, },
method: "POST", });
url: apiKey
? `https://aiplatform.googleapis.com/${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent`
: `https://${location}-aiplatform.googleapis.com/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent`,
body: params,
};
const googleStream = await client.models.generateContentStream(params);
stream.push({ type: "start", partial: output });
let currentBlock: TextContent | ThinkingContent | null = null;
const blocks = output.content;
const blockIndex = () => blocks.length - 1;
for await (const chunk of googleStream) {
const candidate = chunk.candidates?.[0];
if (candidate?.content?.parts) {
for (const part of candidate.content.parts) {
if (part.text !== undefined) {
if (!firstTokenTime) firstTokenTime = Date.now();
const isThinking = isThinkingPart(part);
if (
!currentBlock ||
(isThinking && currentBlock.type !== "thinking") ||
(!isThinking && currentBlock.type !== "text")
) {
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blocks.length - 1,
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (isThinking) {
currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined };
output.content.push(currentBlock);
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
} else {
currentBlock = { type: "text", text: "" };
output.content.push(currentBlock);
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
}
}
if (currentBlock.type === "thinking") {
currentBlock.thinking += part.text;
currentBlock.thinkingSignature = retainThoughtSignature(
currentBlock.thinkingSignature,
part.thoughtSignature,
);
stream.push({
type: "thinking_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
} else {
currentBlock.text += part.text;
currentBlock.textSignature = retainThoughtSignature(
currentBlock.textSignature,
part.thoughtSignature,
);
stream.push({
type: "text_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
}
}
if (part.functionCall) {
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
currentBlock = null;
}
const providedId = part.functionCall.id;
const needsNewId =
!providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId);
const toolCallId = needsNewId
? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`
: providedId;
const toolCall: ToolCall = {
type: "toolCall",
id: toolCallId,
name: part.functionCall.name || "",
arguments: part.functionCall.args as Record<string, any>,
...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
};
output.content.push(toolCall);
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
stream.push({
type: "toolcall_delta",
contentIndex: blockIndex(),
delta: JSON.stringify(toolCall.arguments),
partial: output,
});
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
}
}
}
if (candidate?.finishReason) {
output.stopReason = mapStopReason(candidate.finishReason);
if (output.content.some(b => b.type === "toolCall")) {
output.stopReason = "toolUse";
}
}
if (chunk.usageMetadata) {
// promptTokenCount includes cachedContentTokenCount when cached content is used.
// Subtract to get non-cached input, matching the OpenAI convention where
// input = uncached prompt tokens and cacheRead = cached tokens so that
// input + cacheRead = total prompt tokens (no double-counting).
// Ref: https://ai.google.dev/api/generate-content#v1beta.GenerateContentResponse.UsageMetadata
const cachedTokens = chunk.usageMetadata.cachedContentTokenCount || 0;
const thinkingTokens = chunk.usageMetadata.thoughtsTokenCount || 0;
output.usage = {
input: (chunk.usageMetadata.promptTokenCount || 0) - cachedTokens,
output: (chunk.usageMetadata.candidatesTokenCount || 0) + thinkingTokens,
cacheRead: cachedTokens,
cacheWrite: 0,
totalTokens: chunk.usageMetadata.totalTokenCount || 0,
...(thinkingTokens > 0 ? { reasoningTokens: thinkingTokens } : {}),
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
calculateCost(model, output.usage);
}
}
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error(output.errorMessage ?? "An unknown error occurred");
}
output.duration = Date.now() - startTime;
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
// Remove internal index property used during streaming
for (const block of output.content) {
if ("index" in block) {
delete (block as { index?: number }).index;
}
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = await finalizeErrorMessage(error, rawRequestDump);
output.duration = Date.now() - startTime;
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
};
function buildHttpOptions(model: Model<"google-vertex">): { headers?: Record<string, string> } | undefined { function buildHttpOptions(model: Model<"google-vertex">): { headers?: Record<string, string> } | undefined {
if (!model.headers) { if (!model.headers) {
@@ -364,76 +85,3 @@ function resolveLocation(options?: GoogleVertexOptions): string {
} }
return location; return location;
} }
function buildParams(
model: Model<"google-vertex">,
context: Context,
options: GoogleVertexOptions = {},
): GenerateContentParameters {
const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
const contents = convertMessages(model, context);
const generationConfig: GoogleVertexSamplingConfig = {};
if (options.temperature !== undefined) {
generationConfig.temperature = options.temperature;
}
if (options.maxTokens !== undefined) {
generationConfig.maxOutputTokens = options.maxTokens;
}
if (options.topP !== undefined) {
generationConfig.topP = options.topP;
}
if (options.topK !== undefined) {
generationConfig.topK = options.topK;
}
if (options.minP !== undefined) {
generationConfig.minP = options.minP;
}
if (options.presencePenalty !== undefined) {
generationConfig.presencePenalty = options.presencePenalty;
}
if (options.repetitionPenalty !== undefined) {
generationConfig.repetitionPenalty = options.repetitionPenalty;
}
const config: GenerateContentConfig = {
...(Object.keys(generationConfig).length > 0 && generationConfig),
...(systemPrompts.length > 0 && { systemInstruction: { parts: systemPrompts.map(text => ({ text })) } }),
...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools, model) }),
};
if (context.tools && context.tools.length > 0 && options.toolChoice) {
config.toolConfig = {
functionCallingConfig: {
mode: mapToolChoice(options.toolChoice),
},
};
} else {
config.toolConfig = undefined;
}
if (options.thinking?.enabled && model.reasoning) {
const cfg: ThinkingConfig = { includeThoughts: true };
if (options.thinking.level !== undefined) {
cfg.thinkingLevel = THINKING_LEVEL_MAP[options.thinking.level];
} else if (options.thinking.budgetTokens !== undefined) {
cfg.thinkingBudget = options.thinking.budgetTokens;
}
config.thinkingConfig = cfg;
}
if (options.signal) {
if (options.signal.aborted) {
throw new Error("Request aborted");
}
config.abortSignal = options.signal;
}
const params: GenerateContentParameters = {
model: model.id,
contents,
config,
};
return params;
}
+16 -359
View File
@@ -1,297 +1,28 @@
import { import { GoogleGenAI } from "@google/genai";
type GenerateContentConfig,
type GenerateContentParameters,
GoogleGenAI,
type ThinkingConfig,
} from "@google/genai";
import { calculateCost } from "../models";
import { getEnvApiKey } from "../stream"; import { getEnvApiKey } from "../stream";
import type { import type { Context, Model, StreamFunction } from "../types";
Api, import type { AssistantMessageEventStream } from "../utils/event-stream";
AssistantMessage, import { buildGoogleGenerateContentParams, type GoogleSharedStreamOptions, streamGoogleGenAI } from "./google-shared";
Context,
Model,
StreamFunction,
StreamOptions,
TextContent,
ThinkingContent,
ToolCall,
} from "../types";
import { normalizeSystemPrompts } from "../utils";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
import type { GoogleThinkingLevel } from "./google-gemini-cli";
import {
convertMessages,
convertTools,
isThinkingPart,
mapStopReason,
mapToolChoice,
retainThoughtSignature,
} from "./google-shared";
export interface GoogleOptions extends StreamOptions { export type GoogleOptions = GoogleSharedStreamOptions;
toolChoice?: "auto" | "none" | "any";
thinking?: {
enabled: boolean;
budgetTokens?: number; // -1 for dynamic, 0 to disable
level?: GoogleThinkingLevel;
};
}
interface GoogleSamplingConfig extends GenerateContentConfig {
topP?: number;
topK?: number;
minP?: number;
presencePenalty?: number;
repetitionPenalty?: number;
}
// Counter for generating unique tool call IDs
let toolCallCounter = 0;
export const streamGoogle: StreamFunction<"google-generative-ai"> = ( export const streamGoogle: StreamFunction<"google-generative-ai"> = (
model: Model<"google-generative-ai">, model: Model<"google-generative-ai">,
context: Context, context: Context,
options?: GoogleOptions, options?: GoogleOptions,
): AssistantMessageEventStream => { ): AssistantMessageEventStream =>
const stream = new AssistantMessageEventStream(); streamGoogleGenAI({
model,
(async () => { options,
const startTime = Date.now(); api: "google-generative-ai",
let firstTokenTime: number | undefined; prepare: () => {
const output: AssistantMessage = {
role: "assistant",
content: [],
api: "google-generative-ai" as 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: "stop",
timestamp: Date.now(),
};
let rawRequestDump: RawHttpRequestDump | undefined;
try {
const apiKey = options?.apiKey || getEnvApiKey(model.provider); const apiKey = options?.apiKey || getEnvApiKey(model.provider);
const client = createClient(model, apiKey); const client = createClient(model, apiKey);
const params = buildParams(model, context, options); const params = buildGoogleGenerateContentParams(model, context, options ?? {});
options?.onPayload?.(params); const url = model.baseUrl ? `${model.baseUrl}/models/${model.id}:streamGenerateContent` : undefined;
rawRequestDump = { return { client, params, url };
provider: model.provider, },
api: output.api, });
model: model.id,
method: "POST",
url: model.baseUrl ? `${model.baseUrl}/models/${model.id}:streamGenerateContent` : undefined,
body: params,
};
const googleStream = await client.models.generateContentStream(params);
stream.push({ type: "start", partial: output });
let currentBlock: TextContent | ThinkingContent | null = null;
const blocks = output.content;
const blockIndex = () => blocks.length - 1;
for await (const chunk of googleStream) {
const candidate = chunk.candidates?.[0];
if (candidate?.content?.parts) {
for (const part of candidate.content.parts) {
if (part.text !== undefined) {
const isThinking = isThinkingPart(part);
if (!firstTokenTime) firstTokenTime = Date.now();
if (
!currentBlock ||
(isThinking && currentBlock.type !== "thinking") ||
(!isThinking && currentBlock.type !== "text")
) {
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blocks.length - 1,
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (isThinking) {
currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined };
output.content.push(currentBlock);
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
} else {
currentBlock = { type: "text", text: "" };
output.content.push(currentBlock);
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
}
}
if (currentBlock.type === "thinking") {
currentBlock.thinking += part.text;
currentBlock.thinkingSignature = retainThoughtSignature(
currentBlock.thinkingSignature,
part.thoughtSignature,
);
stream.push({
type: "thinking_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
} else {
currentBlock.text += part.text;
stream.push({
type: "text_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
}
}
if (part.functionCall) {
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
currentBlock = null;
}
// Generate unique ID if not provided or if it's a duplicate
const providedId = part.functionCall.id;
const needsNewId =
!providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId);
const toolCallId = needsNewId
? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`
: providedId;
const toolCall: ToolCall = {
type: "toolCall",
id: toolCallId,
name: part.functionCall.name || "",
arguments: (part.functionCall.args ?? {}) as Record<string, any>,
...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
};
output.content.push(toolCall);
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
stream.push({
type: "toolcall_delta",
contentIndex: blockIndex(),
delta: JSON.stringify(toolCall.arguments),
partial: output,
});
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
}
}
}
if (candidate?.finishReason) {
output.stopReason = mapStopReason(candidate.finishReason);
if (output.content.some(b => b.type === "toolCall")) {
output.stopReason = "toolUse";
}
}
if (chunk.usageMetadata) {
// promptTokenCount includes cachedContentTokenCount when cached content is used.
// Subtract to get non-cached input, matching the OpenAI convention where
// input = uncached prompt tokens and cacheRead = cached tokens so that
// input + cacheRead = total prompt tokens (no double-counting).
// Ref: https://ai.google.dev/api/generate-content#v1beta.GenerateContentResponse.UsageMetadata
const cachedTokens = chunk.usageMetadata.cachedContentTokenCount || 0;
const thinkingTokens = chunk.usageMetadata.thoughtsTokenCount || 0;
output.usage = {
input: (chunk.usageMetadata.promptTokenCount || 0) - cachedTokens,
output: (chunk.usageMetadata.candidatesTokenCount || 0) + thinkingTokens,
cacheRead: cachedTokens,
cacheWrite: 0,
totalTokens: chunk.usageMetadata.totalTokenCount || 0,
...(thinkingTokens > 0 ? { reasoningTokens: thinkingTokens } : {}),
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
calculateCost(model, output.usage);
}
}
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error(output.errorMessage ?? "An unknown error occurred");
}
output.duration = Date.now() - startTime;
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
// Remove internal index property used during streaming
for (const block of output.content) {
if ("index" in block) {
delete (block as { index?: number }).index;
}
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = await finalizeErrorMessage(error, rawRequestDump);
output.duration = Date.now() - startTime;
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
};
function createClient(model: Model<"google-generative-ai">, apiKey?: string): GoogleGenAI { function createClient(model: Model<"google-generative-ai">, apiKey?: string): GoogleGenAI {
const httpOptions: { baseUrl?: string; apiVersion?: string; headers?: Record<string, string> } = {}; const httpOptions: { baseUrl?: string; apiVersion?: string; headers?: Record<string, string> } = {};
@@ -308,77 +39,3 @@ function createClient(model: Model<"google-generative-ai">, apiKey?: string): Go
httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined, httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined,
}); });
} }
function buildParams(
model: Model<"google-generative-ai">,
context: Context,
options: GoogleOptions = {},
): GenerateContentParameters {
const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
const contents = convertMessages(model, context);
const generationConfig: GoogleSamplingConfig = {};
if (options.temperature !== undefined) {
generationConfig.temperature = options.temperature;
}
if (options.maxTokens !== undefined) {
generationConfig.maxOutputTokens = options.maxTokens;
}
if (options.topP !== undefined) {
generationConfig.topP = options.topP;
}
if (options.topK !== undefined) {
generationConfig.topK = options.topK;
}
if (options.minP !== undefined) {
generationConfig.minP = options.minP;
}
if (options.presencePenalty !== undefined) {
generationConfig.presencePenalty = options.presencePenalty;
}
if (options.repetitionPenalty !== undefined) {
generationConfig.repetitionPenalty = options.repetitionPenalty;
}
const config: GenerateContentConfig = {
...(Object.keys(generationConfig).length > 0 && generationConfig),
...(systemPrompts.length > 0 && { systemInstruction: { parts: systemPrompts.map(text => ({ text })) } }),
...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools, model) }),
};
if (context.tools && context.tools.length > 0 && options.toolChoice) {
config.toolConfig = {
functionCallingConfig: {
mode: mapToolChoice(options.toolChoice),
},
};
} else {
config.toolConfig = undefined;
}
if (options.thinking?.enabled && model.reasoning) {
const cfg: ThinkingConfig = { includeThoughts: true };
if (options.thinking.level !== undefined) {
// Cast to any since our GoogleThinkingLevel mirrors Google's ThinkingLevel enum values
cfg.thinkingLevel = options.thinking.level as any;
} else if (options.thinking.budgetTokens !== undefined) {
cfg.thinkingBudget = options.thinking.budgetTokens;
}
config.thinkingConfig = cfg;
}
if (options.signal) {
if (options.signal.aborted) {
throw new Error("Request aborted");
}
config.abortSignal = options.signal;
}
const params: GenerateContentParameters = {
model: model.id,
contents,
config,
};
return params;
}
+15 -96
View File
@@ -9,19 +9,21 @@
* Note: Kimi calculates TPM rate limits based on max_tokens, not actual output. * Note: Kimi calculates TPM rate limits based on max_tokens, not actual output.
*/ */
import { ANTHROPIC_THINKING } from "../stream"; import type { Api, Context, Model } from "../types";
import type { Api, Context, Model, SimpleStreamOptions } from "../types"; import type { AssistantMessageEventStream } from "../utils/event-stream";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { getKimiCommonHeaders } from "../utils/oauth/kimi"; import { getKimiCommonHeaders } from "../utils/oauth/kimi";
import { streamAnthropic, streamOpenAICompletions } from "./register-builtins"; import {
import { createProviderErrorMessage } from "./shared/error-message"; type OpenAIAnthropicApiFormat,
type OpenAIAnthropicShimOptions,
streamOpenAIAnthropicShim,
} from "./openai-anthropic-shim";
export type KimiApiFormat = "openai" | "anthropic"; export type KimiApiFormat = OpenAIAnthropicApiFormat;
// Note: Anthropic SDK appends /v1/messages, so base URL should not include /v1 // Note: Anthropic SDK appends /v1/messages, so base URL should not include /v1
const KIMI_ANTHROPIC_BASE_URL = "https://api.kimi.com/coding"; const KIMI_ANTHROPIC_BASE_URL = "https://api.kimi.com/coding";
export interface KimiOptions extends SimpleStreamOptions { export interface KimiOptions extends OpenAIAnthropicShimOptions {
/** API format: "openai" or "anthropic". Default: "anthropic" */ /** API format: "openai" or "anthropic". Default: "anthropic" */
format?: KimiApiFormat; format?: KimiApiFormat;
} }
@@ -35,96 +37,13 @@ export function streamKimi(
context: Context, context: Context,
options?: KimiOptions, options?: KimiOptions,
): AssistantMessageEventStream { ): AssistantMessageEventStream {
const stream = new AssistantMessageEventStream(); return streamOpenAIAnthropicShim(model, context, options, {
const format = options?.format ?? "anthropic"; anthropicBaseUrl: KIMI_ANTHROPIC_BASE_URL,
defaultFormat: "anthropic",
// Async IIFE to handle header fetching and stream piping extraHeaders: getKimiCommonHeaders,
(async () => { });
try {
const mergedHeaders = { ...getKimiCommonHeaders(), ...options?.headers };
if (format === "anthropic") {
// Create a synthetic Anthropic model pointing to Kimi's endpoint
const anthropicModel: Model<"anthropic-messages"> = {
id: model.id,
name: model.name,
api: "anthropic-messages",
provider: model.provider,
baseUrl: KIMI_ANTHROPIC_BASE_URL,
headers: mergedHeaders,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
reasoning: model.reasoning,
input: model.input,
cost: model.cost,
};
// Calculate thinking budget from reasoning level
const reasoning = options?.reasoning;
const reasoningEffort = reasoning;
const thinkingEnabled = !!reasoningEffort && model.reasoning;
const thinkingBudget = reasoningEffort
? (options?.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort])
: undefined;
const innerStream = streamAnthropic(anthropicModel, context, {
apiKey: options?.apiKey,
temperature: options?.temperature,
topP: options?.topP,
topK: options?.topK,
minP: options?.minP,
presencePenalty: options?.presencePenalty,
repetitionPenalty: options?.repetitionPenalty,
maxTokens: options?.maxTokens ?? Math.min(model.maxTokens, 32000),
signal: options?.signal,
headers: mergedHeaders,
sessionId: options?.sessionId,
onPayload: options?.onPayload,
onResponse: options?.onResponse,
onSseEvent: options?.onSseEvent,
thinkingEnabled,
thinkingBudgetTokens: thinkingBudget,
});
for await (const event of innerStream) {
stream.push(event);
}
} else {
// OpenAI format - use original model with Kimi headers
const reasoningEffort = options?.reasoning;
const innerStream = streamOpenAICompletions(model, context, {
apiKey: options?.apiKey,
temperature: options?.temperature,
topP: options?.topP,
topK: options?.topK,
minP: options?.minP,
presencePenalty: options?.presencePenalty,
repetitionPenalty: options?.repetitionPenalty,
maxTokens: options?.maxTokens ?? model.maxTokens,
signal: options?.signal,
headers: mergedHeaders,
sessionId: options?.sessionId,
onPayload: options?.onPayload,
onResponse: options?.onResponse,
onSseEvent: options?.onSseEvent,
reasoning: reasoningEffort,
});
for await (const event of innerStream) {
stream.push(event);
}
}
} catch (err) {
stream.push({
type: "error",
reason: "error",
error: createProviderErrorMessage(model, err),
});
}
})();
return stream;
} }
/** /**
* Check if a model is a Kimi Code model. * Check if a model is a Kimi Code model.
*/ */
@@ -0,0 +1,136 @@
/**
* Shared implementation for providers that expose BOTH an OpenAI-compatible
* and an Anthropic-compatible API surface against the same model catalog
* (currently Kimi Code and Synthetic).
*
* Each call site supplies the provider-specific bits (base URLs, default
* format, optional extra headers); the streaming/forwarding plumbing lives
* here once.
*/
import { ANTHROPIC_THINKING } from "../stream";
import type { Context, Model, SimpleStreamOptions } from "../types";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { streamAnthropic, streamOpenAICompletions } from "./register-builtins";
import { createProviderErrorMessage } from "./shared/error-message";
export type OpenAIAnthropicApiFormat = "openai" | "anthropic";
export interface OpenAIAnthropicShimOptions extends SimpleStreamOptions {
/** API format: "openai" or "anthropic". */
format?: OpenAIAnthropicApiFormat;
}
export interface OpenAIAnthropicShimConfig {
/** Base URL for the Anthropic-compatible endpoint (without trailing /v1/messages). */
anthropicBaseUrl: string;
/** Optional override for the OpenAI-compatible base URL. If omitted, `model.baseUrl` is used as-is. */
openaiBaseUrl?: string;
/** Default API format when caller does not specify one. */
defaultFormat: OpenAIAnthropicApiFormat;
/** Provider-specific headers (e.g. auth/session) merged ahead of user-supplied headers. */
extraHeaders?: () => Record<string, string>;
}
/**
* Stream from an OpenAI-or-Anthropic compatible provider. Returns synchronously;
* async header fetching and stream piping happen internally.
*/
export function streamOpenAIAnthropicShim(
model: Model<"openai-completions">,
context: Context,
options: OpenAIAnthropicShimOptions | undefined,
config: OpenAIAnthropicShimConfig,
): AssistantMessageEventStream {
const stream = new AssistantMessageEventStream();
const format = options?.format ?? config.defaultFormat;
(async () => {
try {
const mergedHeaders = {
...(config.extraHeaders?.() ?? {}),
...options?.headers,
};
if (format === "anthropic") {
const anthropicModel: Model<"anthropic-messages"> = {
id: model.id,
name: model.name,
api: "anthropic-messages",
provider: model.provider,
baseUrl: config.anthropicBaseUrl,
headers: mergedHeaders,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
reasoning: model.reasoning,
input: model.input,
cost: model.cost,
};
const reasoningEffort = options?.reasoning;
const thinkingEnabled = !!reasoningEffort && model.reasoning;
const thinkingBudget = reasoningEffort
? (options?.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort])
: undefined;
const innerStream = streamAnthropic(anthropicModel, context, {
apiKey: options?.apiKey,
temperature: options?.temperature,
topP: options?.topP,
topK: options?.topK,
minP: options?.minP,
presencePenalty: options?.presencePenalty,
repetitionPenalty: options?.repetitionPenalty,
maxTokens: options?.maxTokens ?? Math.min(model.maxTokens, 32000),
signal: options?.signal,
headers: mergedHeaders,
sessionId: options?.sessionId,
onPayload: options?.onPayload,
onResponse: options?.onResponse,
onSseEvent: options?.onSseEvent,
thinkingEnabled,
thinkingBudgetTokens: thinkingBudget,
});
for await (const event of innerStream) {
stream.push(event);
}
} else {
const openaiModel: Model<"openai-completions"> = config.openaiBaseUrl
? { ...model, baseUrl: config.openaiBaseUrl, headers: mergedHeaders }
: model;
const reasoningEffort = options?.reasoning;
const innerStream = streamOpenAICompletions(openaiModel, context, {
apiKey: options?.apiKey,
temperature: options?.temperature,
topP: options?.topP,
topK: options?.topK,
minP: options?.minP,
presencePenalty: options?.presencePenalty,
repetitionPenalty: options?.repetitionPenalty,
maxTokens: options?.maxTokens ?? model.maxTokens,
signal: options?.signal,
headers: mergedHeaders,
sessionId: options?.sessionId,
onPayload: options?.onPayload,
onResponse: options?.onResponse,
onSseEvent: options?.onSseEvent,
reasoning: reasoningEffort,
});
for await (const event of innerStream) {
stream.push(event);
}
}
} catch (err) {
stream.push({
type: "error",
reason: "error",
error: createProviderErrorMessage(model, err),
});
}
})();
return stream;
}
@@ -7,8 +7,6 @@ import type {
ResponseFunctionToolCall, ResponseFunctionToolCall,
ResponseInput, ResponseInput,
ResponseInputContent, ResponseInputContent,
ResponseInputImage,
ResponseInputText,
ResponseOutputMessage, ResponseOutputMessage,
ResponseReasoningItem, ResponseReasoningItem,
} from "openai/resources/responses/responses"; } from "openai/resources/responses/responses";
@@ -35,7 +33,6 @@ import {
createOpenAIResponsesHistoryPayload, createOpenAIResponsesHistoryPayload,
getOpenAIResponsesHistoryItems, getOpenAIResponsesHistoryItems,
getOpenAIResponsesHistoryPayload, getOpenAIResponsesHistoryPayload,
normalizeResponsesToolCallId,
normalizeSystemPrompts, normalizeSystemPrompts,
} from "../utils"; } from "../utils";
import { AssistantMessageEventStream } from "../utils/event-stream"; import { AssistantMessageEventStream } from "../utils/event-stream";
@@ -54,14 +51,15 @@ import {
import { parseCodexError } from "./openai-codex/response-handler"; import { parseCodexError } from "./openai-codex/response-handler";
import { normalizeOpenAIResponsesPromptCacheKey } from "./openai-responses"; import { normalizeOpenAIResponsesPromptCacheKey } from "./openai-responses";
import { import {
appendResponsesToolResultMessages,
convertResponsesAssistantMessage,
convertResponsesInputContent, convertResponsesInputContent,
encodeResponsesToolCallId, encodeResponsesToolCallId,
encodeTextSignatureV1, encodeTextSignatureV1,
mapOpenAIResponsesStopReason, mapOpenAIResponsesStopReason,
parseTextSignature, populateResponsesUsageFromResponse,
} from "./openai-responses-shared"; } from "./openai-responses-shared";
import { transformMessages } from "./transform-messages"; import { transformMessages } from "./transform-messages";
import { joinTextWithImagePlaceholder } from "./vision-guard";
export interface OpenAICodexResponsesOptions extends StreamOptions { export interface OpenAICodexResponsesOptions extends StreamOptions {
reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
@@ -1233,19 +1231,7 @@ function handleResponseCompleted(
} }
).response; ).response;
if (response?.usage) { populateResponsesUsageFromResponse(output, response?.usage);
const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0;
const reasoningTokens = response.usage.output_tokens_details?.reasoning_tokens || 0;
output.usage = {
input: (response.usage.input_tokens || 0) - cachedTokens,
output: response.usage.output_tokens || 0,
cacheRead: cachedTokens,
cacheWrite: 0,
totalTokens: response.usage.total_tokens || 0,
...(reasoningTokens > 0 ? { reasoningTokens } : {}),
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
if (typeof response?.id === "string" && response.id.length > 0) { if (typeof response?.id === "string" && response.id.length > 0) {
output.responseId = response.id; output.responseId = response.id;
} }
@@ -2356,6 +2342,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
// messages can be replayed as `custom_tool_call_output` rather than // messages can be replayed as `custom_tool_call_output` rather than
// `function_call_output` (OpenAI rejects mismatched pairs). // `function_call_output` (OpenAI rejects mismatched pairs).
const customCallIds = new Set<string>(); const customCallIds = new Set<string>();
const knownCallIds = new Set<string>();
for (const msg of transformedMessages) { for (const msg of transformedMessages) {
if (msg.role === "user" || msg.role === "developer") { if (msg.role === "user" || msg.role === "developer") {
@@ -2407,57 +2394,14 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
continue; continue;
} }
const outputItems: ResponseInput = []; const outputItems = convertResponsesAssistantMessage(
for (const block of msg.content) { msg as AssistantMessage,
if (block.type === "thinking" && msg.stopReason !== "error") { model,
if (block.thinkingSignature) { msgIndex,
outputItems.push(JSON.parse(block.thinkingSignature) as ResponseReasoningItem); knownCallIds,
} true,
continue; customCallIds,
} );
if (block.type === "text") {
const textBlock = block as TextContent;
const parsedSignature = parseTextSignature(textBlock.textSignature);
let msgId = parsedSignature?.id;
if (!msgId) {
msgId = `msg_${msgIndex}`;
} else if (msgId.length > 64) {
msgId = `msg_${Bun.hash(msgId).toString(36)}`;
}
outputItems.push({
type: "message",
role: "assistant",
content: [{ type: "output_text", text: textBlock.text.toWellFormed(), annotations: [] }],
status: "completed",
id: msgId,
phase: parsedSignature?.phase,
} satisfies ResponseOutputMessage);
continue;
}
if (block.type === "toolCall") {
const toolCall = block as ToolCall;
const normalized = normalizeResponsesToolCallId(toolCall.id, toolCall.customWireName ? "ctc" : "fc");
if (toolCall.customWireName) {
const rawInput = typeof toolCall.arguments?.input === "string" ? toolCall.arguments.input : "";
customCallIds.add(normalized.callId);
outputItems.push({
type: "custom_tool_call",
id: normalized.itemId,
call_id: normalized.callId,
name: toolCall.customWireName,
input: rawInput,
} as ResponseInput[number]);
continue;
}
outputItems.push({
type: "function_call",
id: normalized.itemId,
call_id: normalized.callId,
name: toolCall.name,
arguments: JSON.stringify(toolCall.arguments),
});
}
}
if (outputItems.length > 0) { if (outputItems.length > 0) {
messages.push(...outputItems); messages.push(...outputItems);
} }
@@ -2466,49 +2410,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
} }
if (msg.role === "toolResult") { if (msg.role === "toolResult") {
const supportsImages = model.input.includes("image"); appendResponsesToolResultMessages(messages, msg, model, false, knownCallIds, customCallIds);
const textResult = msg.content
.filter(content => content.type === "text")
.map(content => content.text)
.join("\n");
const hasImages = msg.content.some(content => content.type === "image");
const omittedImages = hasImages && !supportsImages;
const normalized = normalizeResponsesToolCallId(msg.toolCallId);
const output = (
omittedImages
? joinTextWithImagePlaceholder(textResult, true)
: textResult.length > 0
? textResult
: "(see attached image)"
).toWellFormed();
if (customCallIds.has(normalized.callId)) {
messages.push({
type: "custom_tool_call_output",
call_id: normalized.callId,
output,
} as ResponseInput[number]);
} else {
messages.push({
type: "function_call_output",
call_id: normalized.callId,
output,
});
}
if (hasImages && supportsImages) {
const contentParts: ResponseInputContent[] = [
{ type: "input_text", text: "Attached image(s) from tool result:" } satisfies ResponseInputText,
];
for (const block of msg.content) {
if (block.type === "image") {
contentParts.push({
type: "input_image",
detail: "auto",
image_url: `data:${block.mimeType};base64,${block.data}`,
} satisfies ResponseInputImage);
}
}
messages.push({ role: "user", content: contentParts });
}
} }
msgIndex += 1; msgIndex += 1;
@@ -64,6 +64,7 @@ import {
resolveGitHubCopilotBaseUrl, resolveGitHubCopilotBaseUrl,
} from "./github-copilot-headers"; } from "./github-copilot-headers";
import { detectOpenAICompat, type ResolvedOpenAICompat, resolveOpenAICompat } from "./openai-completions-compat"; import { detectOpenAICompat, type ResolvedOpenAICompat, resolveOpenAICompat } from "./openai-completions-compat";
import { createInitialResponsesAssistantMessage } from "./openai-responses-shared";
import { transformMessages } from "./transform-messages"; import { transformMessages } from "./transform-messages";
import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard"; import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
@@ -338,23 +339,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
let firstTokenTime: number | undefined; let firstTokenTime: number | undefined;
let getCapturedErrorResponse: (() => CapturedHttpErrorResponse | undefined) | undefined; let getCapturedErrorResponse: (() => CapturedHttpErrorResponse | undefined) | undefined;
const output: AssistantMessage = { const output: AssistantMessage = createInitialResponsesAssistantMessage(model.api, model.provider, model.id);
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: "stop",
timestamp: Date.now(),
};
let rawRequestDump: RawHttpRequestDump | undefined; let rawRequestDump: RawHttpRequestDump | undefined;
const abortTracker = createAbortSourceTracker(options?.signal); const abortTracker = createAbortSourceTracker(options?.signal);
const firstEventTimeoutAbortError = new Error(OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE); const firstEventTimeoutAbortError = new Error(OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE);
@@ -12,17 +12,20 @@ import type {
ResponseReasoningItem, ResponseReasoningItem,
} from "openai/resources/responses/responses"; } from "openai/resources/responses/responses";
import { calculateCost } from "../models"; import { calculateCost } from "../models";
import type { import {
Api, type Api,
AssistantMessage, type AssistantMessage,
ImageContent, type ImageContent,
Model, type Model,
StopReason, type ServiceTier,
TextContent, type StopReason,
TextSignatureV1, type StreamOptions,
ThinkingContent, shouldSendServiceTier,
ToolCall, type TextContent,
ToolResultMessage, type TextSignatureV1,
type ThinkingContent,
type ToolCall,
type ToolResultMessage,
} from "../types"; } from "../types";
import { normalizeResponsesToolCallId } from "../utils"; import { normalizeResponsesToolCallId } from "../utils";
import type { AssistantMessageEventStream } from "../utils/event-stream"; import type { AssistantMessageEventStream } from "../utils/event-stream";
@@ -540,19 +543,7 @@ export async function processResponsesStream<TApi extends Api>(
if (response?.id) { if (response?.id) {
output.responseId = response.id; output.responseId = response.id;
} }
if (response?.usage) { populateResponsesUsageFromResponse(output, response?.usage);
const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0;
const reasoningTokens = response.usage.output_tokens_details?.reasoning_tokens || 0;
output.usage = {
input: (response.usage.input_tokens || 0) - cachedTokens,
output: response.usage.output_tokens || 0,
cacheRead: cachedTokens,
cacheWrite: 0,
totalTokens: response.usage.total_tokens || 0,
...(reasoningTokens > 0 ? { reasoningTokens } : {}),
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
calculateCost(model, output.usage); calculateCost(model, output.usage);
output.stopReason = mapOpenAIResponsesStopReason(response?.status); output.stopReason = mapOpenAIResponsesStopReason(response?.status);
if (response?.status === "failed" || response?.status === "cancelled") { if (response?.status === "failed" || response?.status === "cancelled") {
@@ -605,3 +596,131 @@ export function mapOpenAIResponsesStopReason(status: OpenAI.Responses.ResponseSt
} }
} }
} }
/** Initial empty `AssistantMessage` that streaming providers accumulate into. */
export function createInitialResponsesAssistantMessage(api: Api, provider: string, modelId: string): AssistantMessage {
return {
role: "assistant",
content: [],
api,
provider,
model: modelId,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
}
/** Extension fields we add on top of `ResponseCreateParamsStreaming` across the Responses-family providers. */
export type ResponsesSamplingParamsExtras = {
top_p?: number;
top_k?: number;
min_p?: number;
presence_penalty?: number;
repetition_penalty?: number;
};
type CommonResponsesParams = OpenAI.Responses.ResponseCreateParamsStreaming & ResponsesSamplingParamsExtras;
type CommonSamplingOptions = Pick<
StreamOptions,
"temperature" | "topP" | "topK" | "minP" | "presencePenalty" | "repetitionPenalty" | "maxTokens"
> & { serviceTier?: ServiceTier };
/**
* Apply the common `StreamOptions` → Responses sampling-parameter mapping (max output tokens,
* temperature, top-p/k, min-p, presence/repetition penalties, service tier). Mutates `params`.
*/
export function applyCommonResponsesSamplingParams<P extends CommonResponsesParams>(
params: P,
options: CommonSamplingOptions | undefined,
provider: string,
): void {
if (options?.maxTokens) params.max_output_tokens = options.maxTokens;
if (options?.temperature !== undefined) params.temperature = options.temperature;
if (options?.topP !== undefined) params.top_p = options.topP;
if (options?.topK !== undefined) params.top_k = options.topK;
if (options?.minP !== undefined) params.min_p = options.minP;
if (options?.presencePenalty !== undefined) params.presence_penalty = options.presencePenalty;
if (options?.repetitionPenalty !== undefined) params.repetition_penalty = options.repetitionPenalty;
if (shouldSendServiceTier(options?.serviceTier, provider)) {
params.service_tier = options.serviceTier;
}
}
type ReasoningOptions = {
reasoning?: string;
reasoningSummary?: "auto" | "detailed" | "concise" | null;
};
/**
* Apply reasoning-related Responses parameters: enable encrypted reasoning content for replay,
* set effort/summary when requested, and otherwise inject the GPT-5 "Juice: 0" no-reasoning hack.
* Mutates `params` and may push a developer message into `messages`.
*/
export function applyResponsesReasoningParams<P extends OpenAI.Responses.ResponseCreateParamsStreaming>(
params: P,
model: Model<Api>,
options: ReasoningOptions | undefined,
messages: ResponseInput,
mapEffort?: (effort: string) => string,
): void {
if (!model.reasoning) return;
// Always request encrypted reasoning content so reasoning items can be replayed in
// multi-turn conversations when store is false (items aren't persisted server-side, so
// we must include the full content). See: https://github.com/can1357/oh-my-pi/issues/41
params.include = ["reasoning.encrypted_content"];
if (options?.reasoning || options?.reasoningSummary !== undefined) {
const requested = options?.reasoning || "medium";
type ReasoningParam = NonNullable<OpenAI.Responses.ResponseCreateParamsStreaming["reasoning"]>;
const reasoningParams: ReasoningParam = {
effort: (mapEffort ? mapEffort(requested) : requested) as ReasoningParam["effort"],
};
if (options?.reasoningSummary !== null) {
reasoningParams.summary = options?.reasoningSummary || "auto";
}
params.reasoning = reasoningParams as P["reasoning"];
} else if (model.name.toLowerCase().startsWith("gpt-5")) {
// Jesus Christ, see https://community.openai.com/t/need-reasoning-false-option-for-gpt-5/1351588/7
messages.push({
role: "developer",
content: [{ type: "input_text", text: "# Juice: 0 !important" }],
});
}
}
/** Populate `output.usage` from a Responses-API `response.usage` payload. Does not invoke `calculateCost`. */
export function populateResponsesUsageFromResponse(
output: AssistantMessage,
usage:
| {
input_tokens?: number | null;
output_tokens?: number | null;
total_tokens?: number | null;
input_tokens_details?: { cached_tokens?: number | null } | null;
output_tokens_details?: { reasoning_tokens?: number | null } | null;
}
| null
| undefined,
): void {
if (!usage) return;
const cachedTokens = usage.input_tokens_details?.cached_tokens || 0;
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens || 0;
output.usage = {
input: (usage.input_tokens || 0) - cachedTokens,
output: usage.output_tokens || 0,
cacheRead: cachedTokens,
cacheWrite: 0,
totalTokens: usage.total_tokens || 0,
...(reasoningTokens > 0 ? { reasoningTokens } : {}),
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
+12 -74
View File
@@ -7,7 +7,6 @@ import type {
} from "openai/resources/responses/responses"; } from "openai/resources/responses/responses";
import { getEnvApiKey } from "../stream"; import { getEnvApiKey } from "../stream";
import { import {
type Api,
type AssistantMessage, type AssistantMessage,
type CacheRetention, type CacheRetention,
type Context, type Context,
@@ -19,7 +18,6 @@ import {
type ServiceTier, type ServiceTier,
type StreamFunction, type StreamFunction,
type StreamOptions, type StreamOptions,
shouldSendServiceTier,
type Tool, type Tool,
type ToolChoice, type ToolChoice,
} from "../types"; } from "../types";
@@ -54,10 +52,13 @@ import {
import { compactGrammarDefinition } from "./grammar"; import { compactGrammarDefinition } from "./grammar";
import { import {
appendResponsesToolResultMessages, appendResponsesToolResultMessages,
applyCommonResponsesSamplingParams,
applyResponsesReasoningParams,
collectCustomCallIds, collectCustomCallIds,
collectKnownCallIds, collectKnownCallIds,
convertResponsesAssistantMessage, convertResponsesAssistantMessage,
convertResponsesInputContent, convertResponsesInputContent,
createInitialResponsesAssistantMessage,
normalizeResponsesToolCallIdForTransform, normalizeResponsesToolCallIdForTransform,
processResponsesStream, processResponsesStream,
} from "./openai-responses-shared"; } from "./openai-responses-shared";
@@ -187,23 +188,11 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
const startTime = Date.now(); const startTime = Date.now();
let firstTokenTime: number | undefined; let firstTokenTime: number | undefined;
const output: AssistantMessage = { const output: AssistantMessage = createInitialResponsesAssistantMessage(
role: "assistant", "openai-responses",
content: [], model.provider,
api: "openai-responses" as Api, model.id,
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: "stop",
timestamp: Date.now(),
};
let rawRequestDump: RawHttpRequestDump | undefined; let rawRequestDump: RawHttpRequestDump | undefined;
const abortTracker = createAbortSourceTracker(options?.signal); const abortTracker = createAbortSourceTracker(options?.signal);
const firstEventTimeoutAbortError = new Error(OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE); const firstEventTimeoutAbortError = new Error(OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE);
@@ -418,31 +407,7 @@ function buildParams(
store: false, store: false,
}; };
if (options?.maxTokens) { applyCommonResponsesSamplingParams(params, options, model.provider);
params.max_output_tokens = options?.maxTokens;
}
if (options?.temperature !== undefined) {
params.temperature = options?.temperature;
}
if (options?.topP !== undefined) {
params.top_p = options.topP;
}
if (options?.topK !== undefined) {
params.top_k = options.topK;
}
if (options?.minP !== undefined) {
params.min_p = options.minP;
}
if (options?.presencePenalty !== undefined) {
params.presence_penalty = options.presencePenalty;
}
if (options?.repetitionPenalty !== undefined) {
params.repetition_penalty = options.repetitionPenalty;
}
if (shouldSendServiceTier(options?.serviceTier, model.provider)) {
params.service_tier = options.serviceTier;
}
if (context.tools) { if (context.tools) {
params.tools = convertTools(context.tools, supportsStrictMode(model), model); params.tools = convertTools(context.tools, supportsStrictMode(model), model);
@@ -460,36 +425,9 @@ function buildParams(
} }
} }
if (model.reasoning) { applyResponsesReasoningParams(params, model, options, messages, effort =>
// Always request encrypted reasoning content so reasoning items can be mapReasoningEffort(effort as NonNullable<OpenAIResponsesOptions["reasoning"]>, model.compat?.reasoningEffortMap),
// replayed in multi-turn conversations when store is false (items aren't );
// persisted server-side, so we must include the full content).
// See: https://github.com/can1357/oh-my-pi/issues/41
params.include = ["reasoning.encrypted_content"];
if (options?.reasoning || options?.reasoningSummary !== undefined) {
const reasoningParams: NonNullable<typeof params.reasoning> = {
effort: mapReasoningEffort(options?.reasoning || "medium", model.compat?.reasoningEffortMap) as NonNullable<
OpenAIResponsesSamplingParams["reasoning"]
>["effort"],
};
if (options?.reasoningSummary !== null) {
reasoningParams.summary = options?.reasoningSummary || "auto";
}
params.reasoning = reasoningParams;
} else if (model.name.startsWith("gpt-5")) {
// Jesus Christ, see https://community.openai.com/t/need-reasoning-false-option-for-gpt-5/1351588/7
messages.push({
role: "developer",
content: [
{
type: "input_text",
text: "# Juice: 0 !important",
},
],
});
}
}
return { conversationMessages, params }; return { conversationMessages, params };
} }
+15 -102
View File
@@ -8,18 +8,20 @@
* @see https://dev.synthetic.new/docs/api/overview * @see https://dev.synthetic.new/docs/api/overview
*/ */
import { ANTHROPIC_THINKING } from "../stream"; import type { Api, Context, Model } from "../types";
import type { Api, Context, Model, SimpleStreamOptions } from "../types"; import type { AssistantMessageEventStream } from "../utils/event-stream";
import { AssistantMessageEventStream } from "../utils/event-stream"; import {
import { streamAnthropic, streamOpenAICompletions } from "./register-builtins"; type OpenAIAnthropicApiFormat,
import { createProviderErrorMessage } from "./shared/error-message"; type OpenAIAnthropicShimOptions,
streamOpenAIAnthropicShim,
} from "./openai-anthropic-shim";
export type SyntheticApiFormat = "openai" | "anthropic"; export type SyntheticApiFormat = OpenAIAnthropicApiFormat;
const SYNTHETIC_NEW_BASE_URL = "https://api.synthetic.new/openai/v1"; const SYNTHETIC_NEW_BASE_URL = "https://api.synthetic.new/openai/v1";
const SYNTHETIC_NEW_ANTHROPIC_BASE_URL = "https://api.synthetic.new/anthropic"; const SYNTHETIC_NEW_ANTHROPIC_BASE_URL = "https://api.synthetic.new/anthropic";
export interface SyntheticOptions extends SimpleStreamOptions { export interface SyntheticOptions extends OpenAIAnthropicShimOptions {
/** API format: "openai" or "anthropic". Default: "openai" */ /** API format: "openai" or "anthropic". Default: "openai" */
format?: SyntheticApiFormat; format?: SyntheticApiFormat;
} }
@@ -33,102 +35,13 @@ export function streamSynthetic(
context: Context, context: Context,
options?: SyntheticOptions, options?: SyntheticOptions,
): AssistantMessageEventStream { ): AssistantMessageEventStream {
const stream = new AssistantMessageEventStream(); return streamOpenAIAnthropicShim(model, context, options, {
const format = options?.format ?? "openai"; anthropicBaseUrl: SYNTHETIC_NEW_ANTHROPIC_BASE_URL,
openaiBaseUrl: SYNTHETIC_NEW_BASE_URL,
// Async IIFE to handle stream piping defaultFormat: "openai",
(async () => { });
try {
const mergedHeaders = options?.headers ?? {};
if (format === "anthropic") {
// Create a synthetic Anthropic model pointing to Synthetic's endpoint
const anthropicModel: Model<"anthropic-messages"> = {
id: model.id,
name: model.name,
api: "anthropic-messages",
provider: model.provider,
baseUrl: SYNTHETIC_NEW_ANTHROPIC_BASE_URL,
headers: mergedHeaders,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
reasoning: model.reasoning,
input: model.input,
cost: model.cost,
};
// Calculate thinking budget from reasoning level
const reasoning = options?.reasoning;
const reasoningEffort = reasoning;
const thinkingEnabled = !!reasoningEffort && model.reasoning;
const thinkingBudget = reasoningEffort
? (options?.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort])
: undefined;
const innerStream = streamAnthropic(anthropicModel, context, {
apiKey: options?.apiKey,
temperature: options?.temperature,
topP: options?.topP,
topK: options?.topK,
minP: options?.minP,
presencePenalty: options?.presencePenalty,
repetitionPenalty: options?.repetitionPenalty,
maxTokens: options?.maxTokens ?? Math.min(model.maxTokens, 32000),
signal: options?.signal,
headers: mergedHeaders,
sessionId: options?.sessionId,
onPayload: options?.onPayload,
onResponse: options?.onResponse,
onSseEvent: options?.onSseEvent,
thinkingEnabled,
thinkingBudgetTokens: thinkingBudget,
});
for await (const event of innerStream) {
stream.push(event);
}
} else {
// OpenAI format - use original model with Synthetic headers
const syntheticModel: Model<"openai-completions"> = {
...model,
baseUrl: SYNTHETIC_NEW_BASE_URL,
headers: mergedHeaders,
};
const reasoningEffort = options?.reasoning;
const innerStream = streamOpenAICompletions(syntheticModel, context, {
apiKey: options?.apiKey,
temperature: options?.temperature,
topP: options?.topP,
topK: options?.topK,
minP: options?.minP,
presencePenalty: options?.presencePenalty,
repetitionPenalty: options?.repetitionPenalty,
maxTokens: options?.maxTokens ?? model.maxTokens,
signal: options?.signal,
headers: mergedHeaders,
sessionId: options?.sessionId,
onPayload: options?.onPayload,
onResponse: options?.onResponse,
onSseEvent: options?.onSseEvent,
reasoning: reasoningEffort,
});
for await (const event of innerStream) {
stream.push(event);
}
}
} catch (err) {
stream.push({
type: "error",
reason: "error",
error: createProviderErrorMessage(model, err),
});
}
})();
return stream;
} }
/** /**
* Check if a model is a Synthetic model. * Check if a model is a Synthetic model.
*/ */
+1 -1
View File
@@ -64,7 +64,7 @@ type _CheckExhaustive =
? true ? true
: ["ApiOptionsMap is missing some KnownApi values", Exclude<KnownApi, keyof ApiOptionsMap>] : ["ApiOptionsMap is missing some KnownApi values", Exclude<KnownApi, keyof ApiOptionsMap>]
: ["ApiOptionsMap doesn't extend Record<KnownApi, StreamOptions>"]; : ["ApiOptionsMap doesn't extend Record<KnownApi, StreamOptions>"];
const _exhaustive: _CheckExhaustive = true; true satisfies _CheckExhaustive;
export type OptionsForApi<TApi extends Api> = export type OptionsForApi<TApi extends Api> =
| StreamOptions | StreamOptions
| (TApi extends keyof ApiOptionsMap ? ApiOptionsMap[TApi] : never); | (TApi extends keyof ApiOptionsMap ? ApiOptionsMap[TApi] : never);
-2
View File
@@ -10,8 +10,6 @@
import type { OAuthController, OAuthProvider } from "./types"; import type { OAuthController, OAuthProvider } from "./types";
const PROVIDER_ID: OAuthProvider = "lm-studio"; const PROVIDER_ID: OAuthProvider = "lm-studio";
const _AUTH_URL = "https://lmstudio.ai/docs/api";
const _DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:1234/v1";
export const DEFAULT_LOCAL_TOKEN = "lm-studio-local"; export const DEFAULT_LOCAL_TOKEN = "lm-studio-local";
/** /**
@@ -470,7 +470,7 @@ describe("anthropic stream envelope handling", () => {
vi.spyOn(Messages.prototype, "create").mockImplementation(() => createRawSseRequest(frames) as never); vi.spyOn(Messages.prototype, "create").mockImplementation(() => createRawSseRequest(frames) as never);
const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" });
for await (const _event of stream) { for await (const _ of stream) {
// drain stream // drain stream
} }
const result = await stream.result(); const result = await stream.result();
@@ -497,7 +497,7 @@ describe("anthropic stream envelope handling", () => {
}); });
const eagerStream = streamAnthropic(model, toolContext, { apiKey: "sk-ant-test" }); const eagerStream = streamAnthropic(model, toolContext, { apiKey: "sk-ant-test" });
for await (const _event of eagerStream) { for await (const _ of eagerStream) {
// drain stream // drain stream
} }
await eagerStream.result(); await eagerStream.result();
@@ -507,7 +507,7 @@ describe("anthropic stream envelope handling", () => {
toolContext, toolContext,
{ apiKey: "sk-ant-test" }, { apiKey: "sk-ant-test" },
); );
for await (const _event of disabledStream) { for await (const _ of disabledStream) {
// drain stream // drain stream
} }
await disabledStream.result(); await disabledStream.result();
@@ -534,7 +534,7 @@ describe("anthropic stream envelope handling", () => {
apiKey: "sk-ant-test", apiKey: "sk-ant-test",
cacheRetention: "long", cacheRetention: "long",
}); });
for await (const _event of stream) { for await (const _ of stream) {
// drain stream // drain stream
} }
await stream.result(); await stream.result();
@@ -227,7 +227,6 @@ describe("loginGitHubCopilot", () => {
}); });
it("device flow error", async () => { it("device flow error", async () => {
let _pollCount = 0;
const fetchMock = vi.fn(async (input: string | URL) => { const fetchMock = vi.fn(async (input: string | URL) => {
const url = typeof input === "string" ? input : input.toString(); const url = typeof input === "string" ? input : input.toString();
if (url === "https://github.com/login/device/code") { if (url === "https://github.com/login/device/code") {
@@ -237,7 +236,6 @@ describe("loginGitHubCopilot", () => {
}); });
} }
if (url === "https://github.com/login/oauth/access_token") { if (url === "https://github.com/login/oauth/access_token") {
_pollCount++;
return new Response(JSON.stringify({ error: "access_denied", error_description: "User denied" }), { return new Response(JSON.stringify({ error: "access_denied", error_description: "User denied" }), {
status: 200, status: 200,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
+1 -1
View File
@@ -16,7 +16,7 @@ import { $flag, getAgentDir, isEnoent } from "@oh-my-pi/pi-utils";
* E2E tests require explicit opt-in via E2E=1 environment variable. * E2E tests require explicit opt-in via E2E=1 environment variable.
* This prevents accidental API calls when keys happen to be in the environment. * This prevents accidental API calls when keys happen to be in the environment.
*/ */
export const E2E_ENABLED = $flag("E2E"); const E2E_ENABLED = $flag("E2E");
/** /**
* Get an API key from environment, but only if E2E tests are enabled. * Get an API key from environment, but only if E2E tests are enabled.
+1 -1
View File
@@ -501,7 +501,7 @@ describe("Generate E2E Tests", () => {
); );
const drain = (async () => { const drain = (async () => {
for await (const _event of events) { for await (const _ of events) {
} }
})(); })();
@@ -334,7 +334,6 @@ export default function planModeExtension(pi: ExtensionAPI) {
} }
// Remove any previous plan-mode-context messages // Remove any previous plan-mode-context messages
const _beforeCount = event.messages.length;
const filtered = event.messages.filter(m => { const filtered = event.messages.filter(m => {
if (m.role === "user" && Array.isArray(m.content)) { if (m.role === "user" && Array.isArray(m.content)) {
const hasOldContext = m.content.some( const hasOldContext = m.content.some(
@@ -1,3 +1,4 @@
import * as git from "../utils/git";
import type { ASIData, ASIValue, MetricDirection, NumericMetricMap } from "./types"; import type { ASIData, ASIValue, MetricDirection, NumericMetricMap } from "./types";
export const METRIC_LINE_PREFIX = "METRIC"; export const METRIC_LINE_PREFIX = "METRIC";
@@ -199,3 +200,19 @@ function sanitizeAsiValue(value: unknown): ASIValue | undefined {
} }
return undefined; return undefined;
} }
export async function tryGitStatus(cwd: string): Promise<string> {
try {
return await git.status(cwd, { porcelainV1: true, untrackedFiles: "all", z: true });
} catch {
return "";
}
}
export async function tryGitPrefix(cwd: string): Promise<string> {
try {
return await git.show.prefix(cwd);
} catch {
return "";
}
}
@@ -8,7 +8,15 @@ import type { Theme } from "../../modes/theme/theme";
import { replaceTabs, truncateToWidth } from "../../tools/render-utils"; import { replaceTabs, truncateToWidth } from "../../tools/render-utils";
import * as git from "../../utils/git"; import * as git from "../../utils/git";
import { computeRunModifiedPaths, getCurrentAutoresearchBranch, parseWorkDirDirtyPaths } from "../git"; import { computeRunModifiedPaths, getCurrentAutoresearchBranch, parseWorkDirDirtyPaths } from "../git";
import { ensureNumericMetricMap, formatNum, mergeAsi, pathMatchesSpec, sanitizeAsi } from "../helpers"; import {
ensureNumericMetricMap,
formatNum,
mergeAsi,
pathMatchesSpec,
sanitizeAsi,
tryGitPrefix,
tryGitStatus,
} from "../helpers";
import { import {
buildExperimentState, buildExperimentState,
computeConfidence, computeConfidence,
@@ -445,22 +453,6 @@ async function tryReadHeadSha(cwd: string): Promise<string | null> {
} }
} }
async function tryGitStatus(cwd: string): Promise<string> {
try {
return await git.status(cwd, { porcelainV1: true, untrackedFiles: "all", z: true });
} catch {
return "";
}
}
async function tryGitPrefix(cwd: string): Promise<string> {
try {
return await git.show.prefix(cwd);
} catch {
return "";
}
}
function buildLogText( function buildLogText(
state: ExperimentState, state: ExperimentState,
experiment: ExperimentResult, experiment: ExperimentResult,
@@ -18,6 +18,8 @@ import {
killTree, killTree,
parseAsiLines, parseAsiLines,
parseMetricLines, parseMetricLines,
tryGitPrefix,
tryGitStatus,
} from "../helpers"; } from "../helpers";
import { buildExperimentState } from "../state"; import { buildExperimentState } from "../state";
import { openAutoresearchStorageIfExists } from "../storage"; import { openAutoresearchStorageIfExists } from "../storage";
@@ -265,23 +267,6 @@ export function createRunExperimentTool(
}, },
}; };
} }
async function tryGitStatus(cwd: string): Promise<string> {
try {
return await git.status(cwd, { porcelainV1: true, untrackedFiles: "all", z: true });
} catch {
return "";
}
}
async function tryGitPrefix(cwd: string): Promise<string> {
try {
return await git.show.prefix(cwd);
} catch {
return "";
}
}
async function executeProcess(opts: { async function executeProcess(opts: {
command: string[]; command: string[];
cwd: string; cwd: string;
+1 -1
View File
@@ -2,11 +2,11 @@
* List available models with optional fuzzy search * List available models with optional fuzzy search
*/ */
import { type Api, getSupportedEfforts, type Model } from "@oh-my-pi/pi-ai"; import { type Api, getSupportedEfforts, type Model } from "@oh-my-pi/pi-ai";
import { fuzzyFilter } from "@oh-my-pi/pi-tui";
import { formatNumber } from "@oh-my-pi/pi-utils"; import { formatNumber } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../config/model-registry"; import type { ModelRegistry } from "../config/model-registry";
import { discoverAndLoadExtensions, loadExtensions } from "../extensibility/extensions"; import { discoverAndLoadExtensions, loadExtensions } from "../extensibility/extensions";
import { EventBus } from "../utils/event-bus"; import { EventBus } from "../utils/event-bus";
import { fuzzyFilter } from "../utils/fuzzy";
interface ProviderRow { interface ProviderRow {
provider: string; provider: string;
+3 -13
View File
@@ -5,10 +5,11 @@
*/ */
import * as path from "node:path"; import * as path from "node:path";
import { createInterface } from "node:readline/promises"; import { createInterface } from "node:readline/promises";
import { type MinimizerOptions, Shell } from "@oh-my-pi/pi-natives"; import { Shell } from "@oh-my-pi/pi-natives";
import { APP_NAME, getProjectDir } from "@oh-my-pi/pi-utils"; import { APP_NAME, getProjectDir } from "@oh-my-pi/pi-utils";
import chalk from "chalk"; import chalk from "chalk";
import { Settings, type ShellMinimizerSettings } from "../config/settings"; import { Settings } from "../config/settings";
import { buildMinimizerOptions } from "../exec/bash-executor";
import { getOrCreateSnapshot } from "../utils/shell-snapshot"; import { getOrCreateSnapshot } from "../utils/shell-snapshot";
export interface ShellCommandArgs { export interface ShellCommandArgs {
@@ -41,17 +42,6 @@ export function parseShellArgs(args: string[]): ShellCommandArgs | undefined {
return result; return result;
} }
function buildMinimizerOptions(group: ShellMinimizerSettings): MinimizerOptions | undefined {
if (!group.enabled) return undefined;
return {
enabled: true,
settingsPath: group.settingsPath || undefined,
only: group.only.length > 0 ? group.only : undefined,
except: group.except.length > 0 ? group.except : undefined,
maxCaptureBytes: group.maxCaptureBytes,
};
}
export async function runShellCommand(cmd: ShellCommandArgs): Promise<void> { export async function runShellCommand(cmd: ShellCommandArgs): Promise<void> {
if (!process.stdin.isTTY) { if (!process.stdin.isTTY) {
process.stderr.write("Error: shell console requires an interactive TTY.\n"); process.stderr.write("Error: shell console requires an interactive TTY.\n");
+1 -1
View File
@@ -91,7 +91,7 @@ function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): "b
return isPathInDirectory(ompPath, bunBinDir) ? "bun" : "binary"; return isPathInDirectory(ompPath, bunBinDir) ? "bun" : "binary";
} }
export function _resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" { export function resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" {
return resolveUpdateMethod(ompPath, bunBinDir); return resolveUpdateMethod(ompPath, bunBinDir);
} }
async function resolveUpdateTarget(): Promise<UpdateTarget> { async function resolveUpdateTarget(): Promise<UpdateTarget> {
@@ -1,52 +1,16 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; import type { Api, Model } from "@oh-my-pi/pi-ai";
import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; import { completeSimple } from "@oh-my-pi/pi-ai";
import { prompt } from "@oh-my-pi/pi-utils"; import { prompt } from "@oh-my-pi/pi-utils";
import { Type } from "@sinclair/typebox";
import analysisSystemPrompt from "../../commit/prompts/analysis-system.md" with { type: "text" }; import analysisSystemPrompt from "../../commit/prompts/analysis-system.md" with { type: "text" };
import analysisUserPrompt from "../../commit/prompts/analysis-user.md" with { type: "text" }; import analysisUserPrompt from "../../commit/prompts/analysis-user.md" with { type: "text" };
import type { ChangelogCategory, ConventionalAnalysis } from "../../commit/types"; import type { ConventionalAnalysis } from "../../commit/types";
import { toReasoningEffort } from "../../thinking"; import { toReasoningEffort } from "../../thinking";
import { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "../utils"; import { createConventionalAnalysisTool, parseConventionalAnalysisResponse } from "../shared-llm";
const ConventionalAnalysisTool = { const ConventionalAnalysisTool = createConventionalAnalysisTool(
name: "create_conventional_analysis", "Analyze a diff and return conventional commit classification.",
description: "Analyze a diff and return conventional commit classification.", );
parameters: Type.Object({
type: Type.Union([
Type.Literal("feat"),
Type.Literal("fix"),
Type.Literal("refactor"),
Type.Literal("docs"),
Type.Literal("test"),
Type.Literal("chore"),
Type.Literal("style"),
Type.Literal("perf"),
Type.Literal("build"),
Type.Literal("ci"),
Type.Literal("revert"),
]),
scope: Type.Union([Type.String(), Type.Null()]),
details: Type.Array(
Type.Object({
text: Type.String(),
changelog_category: Type.Optional(
Type.Union([
Type.Literal("Added"),
Type.Literal("Changed"),
Type.Literal("Fixed"),
Type.Literal("Deprecated"),
Type.Literal("Removed"),
Type.Literal("Security"),
Type.Literal("Breaking Changes"),
]),
),
user_visible: Type.Optional(Type.Boolean()),
}),
),
issue_refs: Type.Array(Type.String()),
}),
};
export interface ConventionalAnalysisInput { export interface ConventionalAnalysisInput {
model: Model<Api>; model: Model<Api>;
@@ -96,27 +60,5 @@ export async function generateConventionalAnalysis({
{ apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) }, { apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) },
); );
return parseAnalysisFromResponse(response); return parseConventionalAnalysisResponse(response, ConventionalAnalysisTool);
}
function parseAnalysisFromResponse(message: AssistantMessage): ConventionalAnalysis {
const toolCall = extractToolCall(message, "create_conventional_analysis");
if (toolCall) {
const parsed = validateToolCall([ConventionalAnalysisTool], toolCall) as {
type: ConventionalAnalysis["type"];
scope: string | null;
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
issue_refs: string[];
};
return normalizeAnalysis(parsed);
}
const text = extractTextContent(message);
const parsed = parseJsonPayload(text) as {
type: ConventionalAnalysis["type"];
scope: string | null;
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
issue_refs: string[];
};
return normalizeAnalysis(parsed);
} }
@@ -1,52 +1,14 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; import type { Api, Model } from "@oh-my-pi/pi-ai";
import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; import { completeSimple } from "@oh-my-pi/pi-ai";
import { prompt } from "@oh-my-pi/pi-utils"; import { prompt } from "@oh-my-pi/pi-utils";
import { Type } from "@sinclair/typebox";
import reduceSystemPrompt from "../../commit/prompts/reduce-system.md" with { type: "text" }; import reduceSystemPrompt from "../../commit/prompts/reduce-system.md" with { type: "text" };
import reduceUserPrompt from "../../commit/prompts/reduce-user.md" with { type: "text" }; import reduceUserPrompt from "../../commit/prompts/reduce-user.md" with { type: "text" };
import type { ChangelogCategory, ConventionalAnalysis, FileObservation } from "../../commit/types"; import type { ConventionalAnalysis, FileObservation } from "../../commit/types";
import { toReasoningEffort } from "../../thinking"; import { toReasoningEffort } from "../../thinking";
import { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "../utils"; import { createConventionalAnalysisTool, parseConventionalAnalysisResponse } from "../shared-llm";
const ReduceTool = { const ReduceTool = createConventionalAnalysisTool("Synthesize file observations into a conventional commit analysis.");
name: "create_conventional_analysis",
description: "Synthesize file observations into a conventional commit analysis.",
parameters: Type.Object({
type: Type.Union([
Type.Literal("feat"),
Type.Literal("fix"),
Type.Literal("refactor"),
Type.Literal("docs"),
Type.Literal("test"),
Type.Literal("chore"),
Type.Literal("style"),
Type.Literal("perf"),
Type.Literal("build"),
Type.Literal("ci"),
Type.Literal("revert"),
]),
scope: Type.Union([Type.String(), Type.Null()]),
details: Type.Array(
Type.Object({
text: Type.String(),
changelog_category: Type.Optional(
Type.Union([
Type.Literal("Added"),
Type.Literal("Changed"),
Type.Literal("Fixed"),
Type.Literal("Deprecated"),
Type.Literal("Removed"),
Type.Literal("Security"),
Type.Literal("Breaking Changes"),
]),
),
user_visible: Type.Optional(Type.Boolean()),
}),
),
issue_refs: Type.Array(Type.String()),
}),
};
export interface ReducePhaseInput { export interface ReducePhaseInput {
model: Model<Api>; model: Model<Api>;
@@ -83,26 +45,5 @@ export async function runReducePhase({
{ apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) }, { apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) },
); );
return parseAnalysisResponse(response); return parseConventionalAnalysisResponse(response, ReduceTool);
}
function parseAnalysisResponse(message: AssistantMessage): ConventionalAnalysis {
const toolCall = extractToolCall(message, "create_conventional_analysis");
if (toolCall) {
const parsed = validateToolCall([ReduceTool], toolCall) as {
type: ConventionalAnalysis["type"];
scope: string | null;
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
issue_refs: string[];
};
return normalizeAnalysis(parsed);
}
const text = extractTextContent(message);
const parsed = parseJsonPayload(text) as {
type: ConventionalAnalysis["type"];
scope: string | null;
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
issue_refs: string[];
};
return normalizeAnalysis(parsed);
} }
+2 -2
View File
@@ -25,8 +25,8 @@ import type { CommitCommandArgs, ConventionalAnalysis } from "./types";
const SUMMARY_MAX_CHARS = 72; const SUMMARY_MAX_CHARS = 72;
const RECENT_COMMITS_COUNT = 8; const RECENT_COMMITS_COUNT = 8;
let _typesDescription: string | undefined; let typesDescription: string | undefined;
const TYPES_DESCRIPTION = (): string => (_typesDescription ??= prompt.render(typesDescriptionPrompt)); const TYPES_DESCRIPTION = (): string => (typesDescription ??= prompt.render(typesDescriptionPrompt));
/** /**
* Execute the omp commit pipeline for staged changes. * Execute the omp commit pipeline for staged changes.
@@ -0,0 +1,89 @@
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { validateToolCall } from "@oh-my-pi/pi-ai";
import { Type } from "@sinclair/typebox";
import type { ChangelogCategory, ConventionalAnalysis } from "./types";
import { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "./utils";
/**
* Shared TypeBox schema for the `create_conventional_analysis` tool used by
* both the single-pass analysis call and the map-reduce reduce phase. Schemas
* are identical across phases — only the surrounding tool `description`
* differs to reflect the input the phase is summarizing.
*/
export const conventionalAnalysisParameters = Type.Object({
type: Type.Union([
Type.Literal("feat"),
Type.Literal("fix"),
Type.Literal("refactor"),
Type.Literal("docs"),
Type.Literal("test"),
Type.Literal("chore"),
Type.Literal("style"),
Type.Literal("perf"),
Type.Literal("build"),
Type.Literal("ci"),
Type.Literal("revert"),
]),
scope: Type.Union([Type.String(), Type.Null()]),
details: Type.Array(
Type.Object({
text: Type.String(),
changelog_category: Type.Optional(
Type.Union([
Type.Literal("Added"),
Type.Literal("Changed"),
Type.Literal("Fixed"),
Type.Literal("Deprecated"),
Type.Literal("Removed"),
Type.Literal("Security"),
Type.Literal("Breaking Changes"),
]),
),
user_visible: Type.Optional(Type.Boolean()),
}),
),
issue_refs: Type.Array(Type.String()),
});
export interface ConventionalAnalysisTool {
name: "create_conventional_analysis";
description: string;
parameters: typeof conventionalAnalysisParameters;
}
/**
* Build a `create_conventional_analysis` tool descriptor. Phase-specific
* `description` text is the only thing that varies between callers.
*/
export function createConventionalAnalysisTool(description: string): ConventionalAnalysisTool {
return {
name: "create_conventional_analysis",
description,
parameters: conventionalAnalysisParameters,
};
}
interface ParsedConventionalAnalysis {
type: ConventionalAnalysis["type"];
scope: string | null;
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
issue_refs: string[];
}
/**
* Extract a {@link ConventionalAnalysis} from an assistant response, preferring
* a structured tool call and falling back to JSON embedded in text content.
*/
export function parseConventionalAnalysisResponse(
message: AssistantMessage,
tool: ConventionalAnalysisTool,
): ConventionalAnalysis {
const toolCall = extractToolCall(message, tool.name);
if (toolCall) {
const parsed = validateToolCall([tool], toolCall) as ParsedConventionalAnalysis;
return normalizeAnalysis(parsed);
}
const text = extractTextContent(message);
const parsed = parseJsonPayload(text) as ParsedConventionalAnalysis;
return normalizeAnalysis(parsed);
}
@@ -72,15 +72,12 @@ const TRAILING_MARKER_SUFFIXES: readonly string[] = (() => {
})(); })();
const WRAPPER_PREFIXES = ["duo-chat-"] as const; const WRAPPER_PREFIXES = ["duo-chat-"] as const;
let __referenceDataCache: CanonicalReferenceData | undefined; let referenceDataCache: CanonicalReferenceData | undefined;
const EMPTY_COMPILED_EQUIVALENCE: CompiledEquivalenceConfig = { const EMPTY_COMPILED_EQUIVALENCE: CompiledEquivalenceConfig = {
overrides: new Map<string, string>(), overrides: new Map<string, string>(),
exclude: new Set<string>(), exclude: new Set<string>(),
}; };
const __resolutionCache: WeakMap< const resolutionCache: WeakMap<CompiledEquivalenceConfig, WeakMap<Model<Api>, ResolvedCanonicalModel>> = new WeakMap();
CompiledEquivalenceConfig,
WeakMap<Model<Api>, ResolvedCanonicalModel>
> = new WeakMap();
const FAMILY_EXTRACTION_PATTERNS = [ const FAMILY_EXTRACTION_PATTERNS = [
/(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+)(?::|$)/i, /(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+)(?::|$)/i,
/(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+(?:[-_/][a-z0-9.]+)*)(?::|$)/i, /(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+(?:[-_/][a-z0-9.]+)*)(?::|$)/i,
@@ -98,8 +95,8 @@ function shouldReplaceReference(existing: Model<Api> | undefined, candidate: Mod
} }
function createCanonicalReferenceData(): CanonicalReferenceData { function createCanonicalReferenceData(): CanonicalReferenceData {
if (__referenceDataCache) { if (referenceDataCache) {
return __referenceDataCache; return referenceDataCache;
} }
const references = new Map<string, Model<Api>>(); const references = new Map<string, Model<Api>>();
for (const provider of getBundledProviders()) { for (const provider of getBundledProviders()) {
@@ -112,11 +109,11 @@ function createCanonicalReferenceData(): CanonicalReferenceData {
} }
} }
const officialIds = new Set(references.keys()); const officialIds = new Set(references.keys());
__referenceDataCache = { referenceDataCache = {
references: Object.freeze(references) as Map<string, Model<Api>>, references: Object.freeze(references) as Map<string, Model<Api>>,
officialIds: Object.freeze(officialIds) as Set<string>, officialIds: Object.freeze(officialIds) as Set<string>,
}; };
return __referenceDataCache; return referenceDataCache;
} }
function normalizeSelectorKey(selector: string): string { function normalizeSelectorKey(selector: string): string {
@@ -668,10 +665,10 @@ export function buildCanonicalModelIndex(
const byId = new Map<string, CanonicalModelRecord>(); const byId = new Map<string, CanonicalModelRecord>();
const bySelector = new Map<string, string>(); const bySelector = new Map<string, string>();
let modelCache = __resolutionCache.get(compiledEquivalence); let modelCache = resolutionCache.get(compiledEquivalence);
if (!modelCache) { if (!modelCache) {
modelCache = new WeakMap<Model<Api>, ResolvedCanonicalModel>(); modelCache = new WeakMap<Model<Api>, ResolvedCanonicalModel>();
__resolutionCache.set(compiledEquivalence, modelCache); resolutionCache.set(compiledEquivalence, modelCache);
} }
for (const model of models) { for (const model of models) {
@@ -12,10 +12,10 @@ import {
type Model, type Model,
modelsAreEqual, modelsAreEqual,
} from "@oh-my-pi/pi-ai"; } from "@oh-my-pi/pi-ai";
import { fuzzyMatch } from "@oh-my-pi/pi-tui";
import chalk from "chalk"; import chalk from "chalk";
import MODEL_PRIO from "../priority.json" with { type: "json" }; import MODEL_PRIO from "../priority.json" with { type: "json" };
import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking"; import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking";
import { fuzzyMatch } from "../utils/fuzzy";
import { isAuthenticated, kNoAuth, MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry"; import { isAuthenticated, kNoAuth, MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry";
import type { Settings } from "./settings"; import type { Settings } from "./settings";
@@ -607,9 +607,6 @@ export function resolveModelRoleValue(
return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined }; return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
} }
const lastColonIndex = normalized.lastIndexOf(":");
const _thinkingSelector =
lastColonIndex > PREFIX_MODEL_ROLE.length ? parseThinkingLevel(normalized.slice(lastColonIndex + 1)) : undefined;
const effectivePatterns = resolveConfiguredRolePattern(normalized, options?.settings); const effectivePatterns = resolveConfiguredRolePattern(normalized, options?.settings);
if (!effectivePatterns || effectivePatterns.length === 0) { if (!effectivePatterns || effectivePatterns.length === 0) {
return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined }; return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
+1 -1
View File
@@ -850,7 +850,7 @@ export function isSettingsInitialized(): boolean {
* Reset the global singleton for testing. * Reset the global singleton for testing.
* @internal * @internal
*/ */
export function _resetSettingsForTest(): void { export function resetSettingsForTest(): void {
globalInstance = null; globalInstance = null;
globalInstancePromise = null; globalInstancePromise = null;
} }
@@ -180,7 +180,7 @@ export function rewriteImports(code: string): string {
* Nested declarations (inside functions, blocks, classes) are left alone \u2014 they're * Nested declarations (inside functions, blocks, classes) are left alone \u2014 they're
* scoped to their enclosing function/block regardless of `var` vs `let`/`const`. * scoped to their enclosing function/block regardless of `var` vs `let`/`const`.
*/ */
export function demoteTopLevelLexicals(code: string): string { function demoteTopLevelLexicals(code: string): string {
if (!/\b(?:const|let|class)\b/.test(code)) return code; if (!/\b(?:const|let|class)\b/.test(code)) return code;
const ast = parseProgram(code); const ast = parseProgram(code);
@@ -248,7 +248,7 @@ function returnFinalExpression(code: string): { source: string; returned: boolea
* common case avoids an extra transpile pass. We detect "looks like TS" with a cheap regex * common case avoids an extra transpile pass. We detect "looks like TS" with a cheap regex
* before invoking the transpiler. * before invoking the transpiler.
*/ */
export function stripTypeScript(code: string): string { function stripTypeScript(code: string): string {
if (!LOOKS_LIKE_TS.test(code)) return code; if (!LOOKS_LIKE_TS.test(code)) return code;
try { try {
return new Bun.Transpiler({ loader: "ts" }).transformSync(code); return new Bun.Transpiler({ loader: "ts" }).transformSync(code);
@@ -65,7 +65,8 @@ async function resolveShellCwd(cwd: string | undefined): Promise<string | undefi
} }
} }
function buildMinimizerOptions(group: ShellMinimizerSettings): MinimizerOptions | undefined { /** Translate `ShellMinimizerSettings` into native `MinimizerOptions`, or `undefined` when disabled. */
export function buildMinimizerOptions(group: ShellMinimizerSettings): MinimizerOptions | undefined {
if (!group.enabled) return undefined; if (!group.enabled) return undefined;
return { return {
enabled: true, enabled: true,
@@ -63,7 +63,7 @@ export type ExtensionErrorListener = (error: ExtensionError) => void;
export const EXTENSION_HANDLER_TIMEOUT_MS = 30_000; export const EXTENSION_HANDLER_TIMEOUT_MS = 30_000;
let extensionHandlerTimeoutMs = EXTENSION_HANDLER_TIMEOUT_MS; let extensionHandlerTimeoutMs = EXTENSION_HANDLER_TIMEOUT_MS;
export function __test_setExtensionHandlerTimeoutMs(timeoutMs: number): void { export function testSetExtensionHandlerTimeoutMs(timeoutMs: number): void {
extensionHandlerTimeoutMs = timeoutMs; extensionHandlerTimeoutMs = timeoutMs;
} }
@@ -18,13 +18,11 @@ import type {
ProviderResponseMetadata, ProviderResponseMetadata,
SimpleStreamOptions, SimpleStreamOptions,
TextContent, TextContent,
ToolResultMessage,
} from "@oh-my-pi/pi-ai"; } from "@oh-my-pi/pi-ai";
import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types"; import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types";
import type * as piCodingAgent from "@oh-my-pi/pi-coding-agent"; import type * as piCodingAgent from "@oh-my-pi/pi-coding-agent";
import type { AutocompleteItem, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui"; import type { AutocompleteItem, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
import type { Static, TSchema } from "@sinclair/typebox"; import type { Static, TSchema } from "@sinclair/typebox";
import type { Rule } from "../../capability/rule";
import type { KeybindingsManager } from "../../config/keybindings"; import type { KeybindingsManager } from "../../config/keybindings";
import type { ModelRegistry } from "../../config/model-registry"; import type { ModelRegistry } from "../../config/model-registry";
import type { EditToolDetails } from "../../edit"; import type { EditToolDetails } from "../../edit";
@@ -33,15 +31,9 @@ import type { BashResult } from "../../exec/bash-executor";
import type { ExecOptions, ExecResult } from "../../exec/exec"; import type { ExecOptions, ExecResult } from "../../exec/exec";
import type { CustomEditor } from "../../modes/components/custom-editor"; import type { CustomEditor } from "../../modes/components/custom-editor";
import type { Theme } from "../../modes/theme/theme"; import type { Theme } from "../../modes/theme/theme";
import type { CompactionPreparation, CompactionResult } from "../../session/compaction"; import type { CompactionResult } from "../../session/compaction";
import type { CustomMessage } from "../../session/messages"; import type { CustomMessage } from "../../session/messages";
import type { import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager";
BranchSummaryEntry,
CompactionEntry,
ReadonlySessionManager,
SessionEntry,
SessionManager,
} from "../../session/session-manager";
import type { import type {
BashToolDetails, BashToolDetails,
BashToolInput, BashToolInput,
@@ -53,8 +45,39 @@ import type {
SearchToolInput, SearchToolInput,
WriteToolInput, WriteToolInput,
} from "../../tools"; } from "../../tools";
import type { TodoItem } from "../../tools/todo-write";
import type { EventBus } from "../../utils/event-bus"; import type { EventBus } from "../../utils/event-bus";
import type {
AgentEndEvent,
AgentStartEvent,
AutoCompactionEndEvent,
AutoCompactionStartEvent,
AutoRetryEndEvent,
AutoRetryStartEvent,
ContextEvent,
SessionBeforeBranchEvent,
SessionBeforeBranchResult,
SessionBeforeCompactEvent,
SessionBeforeCompactResult,
SessionBeforeSwitchEvent,
SessionBeforeSwitchResult,
SessionBeforeTreeEvent,
SessionBeforeTreeResult,
SessionBranchEvent,
SessionCompactEvent,
SessionCompactingEvent,
SessionCompactingResult,
SessionEvent,
SessionShutdownEvent,
SessionStartEvent,
SessionSwitchEvent,
SessionTreeEvent,
TodoReminderEvent,
ToolCallEventResult,
ToolResultEventResult,
TtsrTriggeredEvent,
TurnEndEvent,
TurnStartEvent,
} from "../shared-events";
import type { SlashCommandInfo } from "../slash-commands"; import type { SlashCommandInfo } from "../slash-commands";
export type { AppKeybinding, KeybindingsManager } from "../../config/keybindings"; export type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
@@ -104,6 +127,11 @@ export type ExtensionWidgetContent = string[] | ExtensionUiComponentFactory | un
* UI context for extensions to request interactive UI. * UI context for extensions to request interactive UI.
* Each mode (interactive, RPC, print) provides its own implementation. * Each mode (interactive, RPC, print) provides its own implementation.
*/ */
// fallow-ignore-next-line code-duplication
// Parallel to HookUIContext: extensions expose a strictly larger UI surface
// (custom editor component, header/footer, widgets, theming, terminal input)
// and may be invoked from event handlers that have already taken the agent
// loop's lock — hooks intentionally cannot.
export interface ExtensionUIContext { export interface ExtensionUIContext {
/** Show a selector and return the user's choice. */ /** Show a selector and return the user's choice. */
select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined>; select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined>;
@@ -221,6 +249,11 @@ export interface CompactOptions {
/** /**
* Context passed to extension event handlers. * Context passed to extension event handlers.
*/ */
// fallow-ignore-next-line code-duplication
// Parallel to HookContext: extensions expose a strictly larger runtime
// surface (model registry, system prompt, shutdown, full session manager
// access). Field overlap is incidental; merging into a base would require
// hooks to widen their public contract.
export interface ExtensionContext { export interface ExtensionContext {
/** UI methods for user interaction */ /** UI methods for user interaction */
ui: ExtensionUIContext; ui: ExtensionUIContext;
@@ -256,6 +289,10 @@ export interface ExtensionContext {
* Extended context for command handlers. * Extended context for command handlers.
* Includes session control methods only safe in user-initiated commands. * Includes session control methods only safe in user-initiated commands.
*/ */
// fallow-ignore-next-line code-duplication
// Parallel to HookCommandContext: same method names, different invariants —
// extension commands additionally permit `switchSession` and `reload`,
// which hooks must not call to avoid deadlocking the agent loop.
export interface ExtensionCommandContext extends ExtensionContext { export interface ExtensionCommandContext extends ExtensionContext {
/** Get current context usage for the active model. */ /** Get current context usage for the active model. */
getContextUsage(): ContextUsage | undefined; getContextUsage(): ContextUsage | undefined;
@@ -373,115 +410,30 @@ export interface ResourcesDiscoverResult {
} }
// ============================================================================ // ============================================================================
// Session Events // Session Events (shared with hooks subsystem)
// ============================================================================ // ============================================================================
/** Fired on initial session load */ export type {
export interface SessionStartEvent { SessionBeforeBranchEvent,
type: "session_start"; SessionBeforeCompactEvent,
} SessionBeforeSwitchEvent,
SessionBeforeTreeEvent,
/** Fired before switching to another session (can be cancelled) */ SessionBranchEvent,
export interface SessionBeforeSwitchEvent { SessionCompactEvent,
type: "session_before_switch"; SessionCompactingEvent,
reason: "new" | "resume" | "fork"; SessionEvent,
targetSessionFile?: string; SessionShutdownEvent,
} SessionStartEvent,
SessionSwitchEvent,
/** Fired after switching to another session */ SessionTreeEvent,
export interface SessionSwitchEvent { TreePreparation,
type: "session_switch"; } from "../shared-events";
reason: "new" | "resume" | "fork";
previousSessionFile: string | undefined;
}
/** Fired before branching a session (can be cancelled) */
export interface SessionBeforeBranchEvent {
type: "session_before_branch";
entryId: string;
}
/** Fired after branching a session */
export interface SessionBranchEvent {
type: "session_branch";
previousSessionFile: string | undefined;
}
/** Fired before context compaction (can be cancelled or customized) */
export interface SessionBeforeCompactEvent {
type: "session_before_compact";
preparation: CompactionPreparation;
branchEntries: SessionEntry[];
customInstructions?: string;
signal: AbortSignal;
}
/** Fired before compaction summarization to customize prompts/context */
export interface SessionCompactingEvent {
type: "session.compacting";
sessionId: string;
messages: AgentMessage[];
}
/** Fired after context compaction */
export interface SessionCompactEvent {
type: "session_compact";
compactionEntry: CompactionEntry;
fromExtension: boolean;
}
/** Fired on process exit */
export interface SessionShutdownEvent {
type: "session_shutdown";
}
/** Preparation data for tree navigation */
export interface TreePreparation {
targetId: string;
oldLeafId: string | null;
commonAncestorId: string | null;
entriesToSummarize: SessionEntry[];
userWantsSummary: boolean;
}
/** Fired before navigating in the session tree (can be cancelled) */
export interface SessionBeforeTreeEvent {
type: "session_before_tree";
preparation: TreePreparation;
signal: AbortSignal;
}
/** Fired after navigating in the session tree */
export interface SessionTreeEvent {
type: "session_tree";
newLeafId: string | null;
oldLeafId: string | null;
summaryEntry?: BranchSummaryEntry;
fromExtension?: boolean;
}
export type SessionEvent =
| SessionStartEvent
| SessionBeforeSwitchEvent
| SessionSwitchEvent
| SessionBeforeBranchEvent
| SessionBranchEvent
| SessionBeforeCompactEvent
| SessionCompactingEvent
| SessionCompactEvent
| SessionShutdownEvent
| SessionBeforeTreeEvent
| SessionTreeEvent;
// ============================================================================ // ============================================================================
// Agent Events // Agent Events
// ============================================================================ // ============================================================================
/** Fired before each LLM call. Can modify messages. */ export type { ContextEvent } from "../shared-events";
export interface ContextEvent {
type: "context";
messages: AgentMessage[];
}
/** Fired before a provider request is sent. Can replace the payload. */ /** Fired before a provider request is sent. Can replace the payload. */
export interface BeforeProviderRequestEvent { export interface BeforeProviderRequestEvent {
@@ -502,31 +454,7 @@ export interface BeforeAgentStartEvent {
systemPrompt: string[]; systemPrompt: string[];
} }
/** Fired when an agent loop starts */ export type { AgentEndEvent, AgentStartEvent, TurnEndEvent, TurnStartEvent } from "../shared-events";
export interface AgentStartEvent {
type: "agent_start";
}
/** Fired when an agent loop ends */
export interface AgentEndEvent {
type: "agent_end";
messages: AgentMessage[];
}
/** Fired at the start of each turn */
export interface TurnStartEvent {
type: "turn_start";
turnIndex: number;
timestamp: number;
}
/** Fired at the end of each turn */
export interface TurnEndEvent {
type: "turn_end";
turnIndex: number;
message: AgentMessage;
toolResults: ToolResultMessage[];
}
/** Fired when a message starts (user, assistant, or toolResult) */ /** Fired when a message starts (user, assistant, or toolResult) */
export interface MessageStartEvent { export interface MessageStartEvent {
@@ -574,55 +502,14 @@ export interface ToolExecutionEndEvent {
isError: boolean; isError: boolean;
} }
/** Fired when auto-compaction starts */ export type {
export interface AutoCompactionStartEvent { AutoCompactionEndEvent,
type: "auto_compaction_start"; AutoCompactionStartEvent,
reason: "threshold" | "overflow" | "idle"; AutoRetryEndEvent,
action: "context-full" | "handoff"; AutoRetryStartEvent,
} TodoReminderEvent,
TtsrTriggeredEvent,
/** Fired when auto-compaction ends */ } from "../shared-events";
export interface AutoCompactionEndEvent {
type: "auto_compaction_end";
action: "context-full" | "handoff";
result: CompactionResult | undefined;
aborted: boolean;
willRetry: boolean;
errorMessage?: string;
/** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */
skipped?: boolean;
}
/** Fired when auto-retry starts */
export interface AutoRetryStartEvent {
type: "auto_retry_start";
attempt: number;
maxAttempts: number;
delayMs: number;
errorMessage: string;
}
/** Fired when auto-retry ends */
export interface AutoRetryEndEvent {
type: "auto_retry_end";
success: boolean;
attempt: number;
finalError?: string;
}
/** Fired when TTSR rule matching interrupts generation */
export interface TtsrTriggeredEvent {
type: "ttsr_triggered";
rules: Rule[];
}
/** Fired when todo reminder logic detects unfinished todos */
export interface TodoReminderEvent {
type: "todo_reminder";
todos: TodoItem[];
attempt: number;
maxAttempts: number;
}
/** Fired when AuthStorage automatically soft-disables a credential (e.g. OAuth `invalid_grant`). Not fired for user-initiated `remove()` or duplicate-credential dedup. */ /** Fired when AuthStorage automatically soft-disables a credential (e.g. OAuth `invalid_grant`). Not fired for user-initiated `remove()` or duplicate-credential dedup. */
export interface CredentialDisabledEvent { export interface CredentialDisabledEvent {
@@ -857,10 +744,7 @@ export interface ContextEventResult {
export type BeforeProviderRequestEventResult = unknown; export type BeforeProviderRequestEventResult = unknown;
export interface ToolCallEventResult { export type { ToolCallEventResult } from "../shared-events";
block?: boolean;
reason?: string;
}
/** Result from input event handler */ /** Result from input event handler */
export interface InputEventResult { export interface InputEventResult {
@@ -884,11 +768,7 @@ export interface UserPythonEventResult {
result?: PythonResult; result?: PythonResult;
} }
export interface ToolResultEventResult { export type { ToolResultEventResult } from "../shared-events";
content?: (TextContent | ImageContent)[];
details?: unknown;
isError?: boolean;
}
export interface BeforeAgentStartEventResult { export interface BeforeAgentStartEventResult {
message?: Pick<CustomMessage, "customType" | "content" | "display" | "details" | "attribution">; message?: Pick<CustomMessage, "customType" | "content" | "display" | "details" | "attribution">;
@@ -896,33 +776,13 @@ export interface BeforeAgentStartEventResult {
systemPrompt?: string[]; systemPrompt?: string[];
} }
export interface SessionBeforeSwitchResult { export type {
cancel?: boolean; SessionBeforeBranchResult,
} SessionBeforeCompactResult,
SessionBeforeSwitchResult,
export interface SessionBeforeBranchResult { SessionBeforeTreeResult,
cancel?: boolean; SessionCompactingResult,
skipConversationRestore?: boolean; } from "../shared-events";
}
export interface SessionBeforeCompactResult {
cancel?: boolean;
compaction?: CompactionResult;
}
export interface SessionCompactingResult {
context?: string[];
prompt?: string;
preserveData?: Record<string, unknown>;
}
export interface SessionBeforeTreeResult {
cancel?: boolean;
summary?: {
summary: string;
details?: unknown;
};
}
// ============================================================================ // ============================================================================
// Message Rendering // Message Rendering
@@ -942,6 +802,9 @@ export type MessageRenderer<T = unknown> = (
// Command Registration // Command Registration
// ============================================================================ // ============================================================================
// fallow-ignore-next-line code-duplication
// Parallel to HookAPI's RegisteredCommand: extensions add
// `getArgumentCompletions` and bind handlers to ExtensionCommandContext.
export interface RegisteredCommand { export interface RegisteredCommand {
name: string; name: string;
description?: string; description?: string;
@@ -1,28 +1,44 @@
/** import type { ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai";
* Hook system types.
*
* Hooks are TypeScript modules that can subscribe to agent lifecycle events
* and interact with the user via UI primitives.
*/
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
import type { Component, TUI } from "@oh-my-pi/pi-tui"; import type { Component, TUI } from "@oh-my-pi/pi-tui";
import type { Rule } from "../../capability/rule";
import type { ModelRegistry } from "../../config/model-registry"; import type { ModelRegistry } from "../../config/model-registry";
import type { EditToolDetails } from "../../edit"; import type { EditToolDetails } from "../../edit";
import type { ExecOptions, ExecResult } from "../../exec/exec"; import type { ExecOptions, ExecResult } from "../../exec/exec";
import type { Theme } from "../../modes/theme/theme"; import type { Theme } from "../../modes/theme/theme";
import type { CompactionPreparation, CompactionResult } from "../../session/compaction";
import type { HookMessage } from "../../session/messages"; import type { HookMessage } from "../../session/messages";
import type { import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager";
BranchSummaryEntry,
CompactionEntry,
ReadonlySessionManager,
SessionEntry,
SessionManager,
} from "../../session/session-manager";
import type { BashToolDetails, FindToolDetails, ReadToolDetails, SearchToolDetails } from "../../tools"; import type { BashToolDetails, FindToolDetails, ReadToolDetails, SearchToolDetails } from "../../tools";
import type { TodoItem } from "../../tools/todo-write"; import type {
AgentEndEvent,
AgentStartEvent,
AutoCompactionEndEvent,
AutoCompactionStartEvent,
AutoRetryEndEvent,
AutoRetryStartEvent,
ContextEvent,
SessionBeforeBranchEvent,
SessionBeforeBranchResult,
SessionBeforeCompactEvent,
SessionBeforeCompactResult,
SessionBeforeSwitchEvent,
SessionBeforeSwitchResult,
SessionBeforeTreeEvent,
SessionBeforeTreeResult,
SessionBranchEvent,
SessionCompactEvent,
SessionCompactingEvent,
SessionCompactingResult,
SessionEvent,
SessionShutdownEvent,
SessionStartEvent,
SessionSwitchEvent,
SessionTreeEvent,
TodoReminderEvent,
ToolCallEventResult,
ToolResultEventResult,
TtsrTriggeredEvent,
TurnEndEvent,
TurnStartEvent,
} from "../shared-events";
// Re-export for backward compatibility // Re-export for backward compatibility
export type { ExecOptions, ExecResult } from "../../exec/exec"; export type { ExecOptions, ExecResult } from "../../exec/exec";
@@ -31,6 +47,11 @@ export type { ExecOptions, ExecResult } from "../../exec/exec";
* UI context for hooks to request interactive UI from the harness. * UI context for hooks to request interactive UI from the harness.
* Each mode (interactive, RPC, print) provides its own implementation. * Each mode (interactive, RPC, print) provides its own implementation.
*/ */
// fallow-ignore-next-line code-duplication
// Parallel to ExtensionUIContext: hooks expose a deliberately narrower UI
// surface — no terminal-input listener, no editor component override, no
// theme management — because hooks are invoked from inside the agent loop
// and must not be able to seize ownership of the editor.
export interface HookUIContext { export interface HookUIContext {
/** /**
* Show a selector and return the user's choice. * Show a selector and return the user's choice.
@@ -137,6 +158,11 @@ export interface HookUIContext {
* Context passed to hook event handlers. * Context passed to hook event handlers.
* For command handlers, see HookCommandContext which extends this with session control methods. * For command handlers, see HookCommandContext which extends this with session control methods.
*/ */
// fallow-ignore-next-line code-duplication
// Parallel to ExtensionContext: hooks see a narrower runtime context (no
// model registry mutation, no system prompt access, no shutdown). The
// overlap in field names is intentional API symmetry; widening hooks to
// match extensions would let hooks call methods that deadlock the agent.
export interface HookContext { export interface HookContext {
/** UI methods for user interaction */ /** UI methods for user interaction */
ui: HookUIContext; ui: HookUIContext;
@@ -165,6 +191,11 @@ export interface HookContext {
* These methods are not available in event handlers because they can cause * These methods are not available in event handlers because they can cause
* deadlocks when called from within the agent loop (e.g., tool_call, context events). * deadlocks when called from within the agent loop (e.g., tool_call, context events).
*/ */
// fallow-ignore-next-line code-duplication
// Parallel to ExtensionCommandContext: hooks intentionally omit
// `switchSession`, `reload`, `compact`, and `getContextUsage` — those are
// safe only from extension command handlers, not from the hook execution
// context.
export interface HookCommandContext extends HookContext { export interface HookCommandContext extends HookContext {
/** Wait for the agent to finish streaming */ /** Wait for the agent to finish streaming */
waitForIdle(): Promise<void>; waitForIdle(): Promise<void>;
@@ -210,138 +241,25 @@ export interface HookCommandContext extends HookContext {
} }
// ============================================================================ // ============================================================================
// Session Events // Session Events (shared with extensions subsystem)
// ============================================================================ // ============================================================================
/** Fired on initial session load */ export type {
export interface SessionStartEvent { ContextEvent,
type: "session_start"; SessionBeforeBranchEvent,
} SessionBeforeCompactEvent,
SessionBeforeSwitchEvent,
/** Fired before switching to another session (can be cancelled) */ SessionBeforeTreeEvent,
export interface SessionBeforeSwitchEvent { SessionBranchEvent,
type: "session_before_switch"; SessionCompactEvent,
/** Reason for the switch */ SessionCompactingEvent,
reason: "new" | "resume" | "fork"; SessionEvent,
/** Session file we're switching to (only for "resume") */ SessionShutdownEvent,
targetSessionFile?: string; SessionStartEvent,
} SessionSwitchEvent,
SessionTreeEvent,
/** Fired after switching to another session */ TreePreparation,
export interface SessionSwitchEvent { } from "../shared-events";
type: "session_switch";
/** Reason for the switch */
reason: "new" | "resume" | "fork";
/** Session file we came from */
previousSessionFile: string | undefined;
}
/** Fired before branching a session (can be cancelled) */
export interface SessionBeforeBranchEvent {
type: "session_before_branch";
/** ID of the entry to branch from */
entryId: string;
}
/** Fired after branching a session */
export interface SessionBranchEvent {
type: "session_branch";
previousSessionFile: string | undefined;
}
/** Fired before context compaction (can be cancelled) */
export interface SessionBeforeCompactEvent {
type: "session_before_compact";
/** Compaction preparation with messages to summarize, file ops, previous summary, etc. */
preparation: CompactionPreparation;
/** Branch entries (root to current leaf). Use to inspect custom state or previous compactions. */
branchEntries: SessionEntry[];
/** Optional user-provided instructions for the summary */
customInstructions?: string;
/** Abort signal - hooks should pass this to LLM calls and check it periodically */
signal: AbortSignal;
}
/** Fired before compaction summarization to customize prompts/context */
export interface SessionCompactingEvent {
type: "session.compacting";
sessionId: string;
messages: AgentMessage[];
}
/** Fired after context compaction */
export interface SessionCompactEvent {
type: "session_compact";
compactionEntry: CompactionEntry;
/** Whether the compaction entry was provided by a hook */
fromExtension: boolean;
}
/** Fired on process exit (SIGINT/SIGTERM) */
export interface SessionShutdownEvent {
type: "session_shutdown";
}
/** Preparation data for tree navigation (used by session_before_tree event) */
export interface TreePreparation {
/** Node being switched to */
targetId: string;
/** Current active leaf (being abandoned), null if no current position */
oldLeafId: string | null;
/** Common ancestor of target and old leaf, null if no common ancestor */
commonAncestorId: string | null;
/** Entries to summarize (old leaf back to common ancestor or compaction) */
entriesToSummarize: SessionEntry[];
/** Whether user chose to summarize */
userWantsSummary: boolean;
}
/** Fired before navigating to a different node in the session tree (can be cancelled) */
export interface SessionBeforeTreeEvent {
type: "session_before_tree";
/** Preparation data for the navigation */
preparation: TreePreparation;
/** Abort signal - honors Escape during summarization (model available via ctx.model) */
signal: AbortSignal;
}
/** Fired after navigating to a different node in the session tree */
export interface SessionTreeEvent {
type: "session_tree";
/** The new active leaf, null if navigated to before first entry */
newLeafId: string | null;
/** Previous active leaf, null if there was no position */
oldLeafId: string | null;
/** Branch summary entry if one was created */
summaryEntry?: BranchSummaryEntry;
/** Whether summary came from hook */
fromExtension?: boolean;
}
/** Union of all session event types */
export type SessionEvent =
| SessionStartEvent
| SessionBeforeSwitchEvent
| SessionSwitchEvent
| SessionBeforeBranchEvent
| SessionBranchEvent
| SessionBeforeCompactEvent
| SessionCompactingEvent
| SessionCompactEvent
| SessionShutdownEvent
| SessionBeforeTreeEvent
| SessionTreeEvent;
/**
* Event data for context event.
* Fired before each LLM call, allowing hooks to modify context non-destructively.
* Original session messages are NOT modified - only the messages sent to the LLM are affected.
*/
export interface ContextEvent {
type: "context";
/** Messages about to be sent to the LLM (deep copy, safe to modify) */
messages: AgentMessage[];
}
/** /**
* Event data for before_agent_start event. * Event data for before_agent_start event.
@@ -356,90 +274,18 @@ export interface BeforeAgentStartEvent {
images?: ImageContent[]; images?: ImageContent[];
} }
/** export type {
* Event data for agent_start event. AgentEndEvent,
* Fired when an agent loop starts (once per user prompt). AgentStartEvent,
*/ AutoCompactionEndEvent,
export interface AgentStartEvent { AutoCompactionStartEvent,
type: "agent_start"; AutoRetryEndEvent,
} AutoRetryStartEvent,
TodoReminderEvent,
/** TtsrTriggeredEvent,
* Event data for agent_end event. TurnEndEvent,
*/ TurnStartEvent,
export interface AgentEndEvent { } from "../shared-events";
type: "agent_end";
messages: AgentMessage[];
}
/**
* Event data for turn_start event.
*/
export interface TurnStartEvent {
type: "turn_start";
turnIndex: number;
timestamp: number;
}
/**
* Event data for turn_end event.
*/
export interface TurnEndEvent {
type: "turn_end";
turnIndex: number;
message: AgentMessage;
toolResults: ToolResultMessage[];
}
/** Event data for auto_compaction_start event. */
export interface AutoCompactionStartEvent {
type: "auto_compaction_start";
reason: "threshold" | "overflow" | "idle";
action: "context-full" | "handoff";
}
/** Event data for auto_compaction_end event. */
export interface AutoCompactionEndEvent {
type: "auto_compaction_end";
action: "context-full" | "handoff";
result: CompactionResult | undefined;
aborted: boolean;
willRetry: boolean;
errorMessage?: string;
/** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */
skipped?: boolean;
}
/** Event data for auto_retry_start event. */
export interface AutoRetryStartEvent {
type: "auto_retry_start";
attempt: number;
maxAttempts: number;
delayMs: number;
errorMessage: string;
}
/** Event data for auto_retry_end event. */
export interface AutoRetryEndEvent {
type: "auto_retry_end";
success: boolean;
attempt: number;
finalError?: string;
}
/** Event data for ttsr_triggered event. */
export interface TtsrTriggeredEvent {
type: "ttsr_triggered";
rules: Rule[];
}
/** Event data for todo_reminder event. */
export interface TodoReminderEvent {
type: "todo_reminder";
todos: TodoItem[];
attempt: number;
maxAttempts: number;
}
/** /**
* Event data for tool_call event. * Event data for tool_call event.
@@ -559,29 +405,7 @@ export interface ContextEventResult {
messages?: Message[]; messages?: Message[];
} }
/** export type { ToolCallEventResult, ToolResultEventResult } from "../shared-events";
* Return type for tool_call event handlers.
* Allows hooks to block tool execution.
*/
export interface ToolCallEventResult {
/** If true, block the tool from executing */
block?: boolean;
/** Reason for blocking (returned to LLM as error) */
reason?: string;
}
/**
* Return type for tool_result event handlers.
* Allows hooks to modify tool results.
*/
export interface ToolResultEventResult {
/** Replacement content array (text and images) */
content?: (TextContent | ImageContent)[];
/** Replacement details */
details?: unknown;
/** Override isError flag */
isError?: boolean;
}
/** /**
* Return type for before_agent_start event handlers. * Return type for before_agent_start event handlers.
@@ -592,65 +416,13 @@ export interface BeforeAgentStartEventResult {
message?: Pick<HookMessage, "customType" | "content" | "display" | "details" | "attribution">; message?: Pick<HookMessage, "customType" | "content" | "display" | "details" | "attribution">;
} }
/** Return type for session_before_switch handlers */ export type {
export interface SessionBeforeSwitchResult { SessionBeforeBranchResult,
/** If true, cancel the switch */ SessionBeforeCompactResult,
cancel?: boolean; SessionBeforeSwitchResult,
} SessionBeforeTreeResult,
SessionCompactingResult,
/** Return type for session_before_branch handlers */ } from "../shared-events";
export interface SessionBeforeBranchResult {
/**
* If true, abort the branch entirely. No new session file is created,
* conversation stays unchanged.
*/
cancel?: boolean;
/**
* If true, the branch proceeds (new session file created, session state updated)
* but the in-memory conversation is NOT rewound to the branch point.
*
* Use case: git-checkpoint hook that restores code state separately.
* The hook handles state restoration itself, so it doesn't want the
* agent's conversation to be rewound (which would lose recent context).
*
* - `cancel: true` → nothing happens, user stays in current session
* - `skipConversationRestore: true` → branch happens, but messages stay as-is
* - neither → branch happens AND messages rewind to branch point (default)
*/
skipConversationRestore?: boolean;
}
/** Return type for session_before_compact handlers */
export interface SessionBeforeCompactResult {
/** If true, cancel the compaction */
cancel?: boolean;
/** Custom compaction result - SessionManager adds id/parentId */
compaction?: CompactionResult;
}
/** Return type for session.compacting handlers */
export interface SessionCompactingResult {
/** Additional context lines to include in summary */
context?: string[];
/** Override the default compaction prompt */
prompt?: string;
/** Custom data to store in compaction entry */
preserveData?: Record<string, unknown>;
}
/** Return type for session_before_tree handlers */
export interface SessionBeforeTreeResult {
/** If true, cancel the navigation entirely */
cancel?: boolean;
/**
* Custom summary (skips default summarizer).
* Only used if preparation.userWantsSummary is true.
*/
summary?: {
summary: string;
details?: unknown;
};
}
// ============================================================================ // ============================================================================
// Hook API // Hook API
@@ -681,6 +453,9 @@ export type HookMessageRenderer<T = unknown> = (
/** /**
* Command registration options. * Command registration options.
*/ */
// fallow-ignore-next-line code-duplication
// Parallel to extensions' RegisteredCommand: hooks bind to
// HookCommandContext and have no argument-completion hook.
export interface RegisteredCommand { export interface RegisteredCommand {
name: string; name: string;
description?: string; description?: string;
@@ -0,0 +1,335 @@
/**
* Event payload and result shapes shared between the extensions and hooks
* subsystems.
*
* Both subsystems observe the same agent/session lifecycle, so the *event*
* payloads (what happened) and the simpler *result* shapes (handler return
* values that don't depend on subsystem-specific identifiers like
* `AgentMessage` vs `Message`) are intentionally identical.
*
* Anything that diverges between the two subsystems — UI context, runtime
* context, command context, tool-call discrimination, or return shapes that
* carry subsystem-specific message types — lives in the per-subsystem
* `types.ts` files and is documented there.
*/
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { ImageContent, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
import type { Rule } from "../capability/rule";
import type { CompactionPreparation, CompactionResult } from "../session/compaction";
import type { BranchSummaryEntry, CompactionEntry, SessionEntry } from "../session/session-manager";
import type { TodoItem } from "../tools/todo-write";
// ============================================================================
// Session Events
// ============================================================================
/** Fired on initial session load */
export interface SessionStartEvent {
type: "session_start";
}
/** Fired before switching to another session (can be cancelled) */
export interface SessionBeforeSwitchEvent {
type: "session_before_switch";
/** Reason for the switch */
reason: "new" | "resume" | "fork";
/** Session file we're switching to (only for "resume") */
targetSessionFile?: string;
}
/** Fired after switching to another session */
export interface SessionSwitchEvent {
type: "session_switch";
/** Reason for the switch */
reason: "new" | "resume" | "fork";
/** Session file we came from */
previousSessionFile: string | undefined;
}
/** Fired before branching a session (can be cancelled) */
export interface SessionBeforeBranchEvent {
type: "session_before_branch";
/** ID of the entry to branch from */
entryId: string;
}
/** Fired after branching a session */
export interface SessionBranchEvent {
type: "session_branch";
previousSessionFile: string | undefined;
}
/** Fired before context compaction (can be cancelled or customized) */
export interface SessionBeforeCompactEvent {
type: "session_before_compact";
/** Compaction preparation with messages to summarize, file ops, previous summary, etc. */
preparation: CompactionPreparation;
/** Branch entries (root to current leaf). Use to inspect custom state or previous compactions. */
branchEntries: SessionEntry[];
/** Optional user-provided instructions for the summary */
customInstructions?: string;
/** Abort signal - handlers should pass this to LLM calls and check it periodically */
signal: AbortSignal;
}
/** Fired before compaction summarization to customize prompts/context */
export interface SessionCompactingEvent {
type: "session.compacting";
sessionId: string;
messages: AgentMessage[];
}
/** Fired after context compaction */
export interface SessionCompactEvent {
type: "session_compact";
compactionEntry: CompactionEntry;
/** Whether the compaction entry was provided by an extension/hook */
fromExtension: boolean;
}
/** Fired on process exit (SIGINT/SIGTERM) */
export interface SessionShutdownEvent {
type: "session_shutdown";
}
/** Preparation data for tree navigation (used by session_before_tree event) */
export interface TreePreparation {
/** Node being switched to */
targetId: string;
/** Current active leaf (being abandoned), null if no current position */
oldLeafId: string | null;
/** Common ancestor of target and old leaf, null if no common ancestor */
commonAncestorId: string | null;
/** Entries to summarize (old leaf back to common ancestor or compaction) */
entriesToSummarize: SessionEntry[];
/** Whether user chose to summarize */
userWantsSummary: boolean;
}
/** Fired before navigating to a different node in the session tree (can be cancelled) */
export interface SessionBeforeTreeEvent {
type: "session_before_tree";
/** Preparation data for the navigation */
preparation: TreePreparation;
/** Abort signal - honors Escape during summarization (model available via ctx.model) */
signal: AbortSignal;
}
/** Fired after navigating to a different node in the session tree */
export interface SessionTreeEvent {
type: "session_tree";
/** The new active leaf, null if navigated to before first entry */
newLeafId: string | null;
/** Previous active leaf, null if there was no position */
oldLeafId: string | null;
/** Branch summary entry if one was created */
summaryEntry?: BranchSummaryEntry;
/** Whether summary came from extension/hook */
fromExtension?: boolean;
}
/** Union of all session event types */
export type SessionEvent =
| SessionStartEvent
| SessionBeforeSwitchEvent
| SessionSwitchEvent
| SessionBeforeBranchEvent
| SessionBranchEvent
| SessionBeforeCompactEvent
| SessionCompactingEvent
| SessionCompactEvent
| SessionShutdownEvent
| SessionBeforeTreeEvent
| SessionTreeEvent;
// ============================================================================
// Agent / Turn Events
// ============================================================================
/**
* Fired before each LLM call.
*
* Original session messages are NOT modified - only the messages sent to the
* LLM are affected when a handler returns a replacement (the return shape
* differs between extensions and hooks; see each subsystem's
* `ContextEventResult`).
*/
export interface ContextEvent {
type: "context";
/** Messages about to be sent to the LLM (deep copy, safe to modify) */
messages: AgentMessage[];
}
/**
* Fired when an agent loop starts (once per user prompt).
*/
export interface AgentStartEvent {
type: "agent_start";
}
/** Fired when an agent loop ends */
export interface AgentEndEvent {
type: "agent_end";
messages: AgentMessage[];
}
/** Fired at the start of each turn */
export interface TurnStartEvent {
type: "turn_start";
turnIndex: number;
timestamp: number;
}
/** Fired at the end of each turn */
export interface TurnEndEvent {
type: "turn_end";
turnIndex: number;
message: AgentMessage;
toolResults: ToolResultMessage[];
}
// ============================================================================
// Auto-compaction / Auto-retry Events
// ============================================================================
/** Fired when auto-compaction starts */
export interface AutoCompactionStartEvent {
type: "auto_compaction_start";
reason: "threshold" | "overflow" | "idle";
action: "context-full" | "handoff";
}
/** Fired when auto-compaction ends */
export interface AutoCompactionEndEvent {
type: "auto_compaction_end";
action: "context-full" | "handoff";
result: CompactionResult | undefined;
aborted: boolean;
willRetry: boolean;
errorMessage?: string;
/** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */
skipped?: boolean;
}
/** Fired when auto-retry starts */
export interface AutoRetryStartEvent {
type: "auto_retry_start";
attempt: number;
maxAttempts: number;
delayMs: number;
errorMessage: string;
}
/** Fired when auto-retry ends */
export interface AutoRetryEndEvent {
type: "auto_retry_end";
success: boolean;
attempt: number;
finalError?: string;
}
// ============================================================================
// TTSR / Todo Reminders
// ============================================================================
/** Fired when TTSR rule matching interrupts generation */
export interface TtsrTriggeredEvent {
type: "ttsr_triggered";
rules: Rule[];
}
/** Fired when todo reminder logic detects unfinished todos */
export interface TodoReminderEvent {
type: "todo_reminder";
todos: TodoItem[];
attempt: number;
maxAttempts: number;
}
// ============================================================================
// Shared Event Result Shapes
// ============================================================================
/**
* Return type for `tool_call` handlers.
* Allows handlers to block tool execution.
*/
export interface ToolCallEventResult {
/** If true, block the tool from executing */
block?: boolean;
/** Reason for blocking (returned to LLM as error) */
reason?: string;
}
/**
* Return type for `tool_result` handlers.
* Allows handlers to modify tool results.
*/
export interface ToolResultEventResult {
/** Replacement content array (text and images) */
content?: (TextContent | ImageContent)[];
/** Replacement details */
details?: unknown;
/** Override isError flag */
isError?: boolean;
}
/** Return type for `session_before_switch` handlers */
export interface SessionBeforeSwitchResult {
/** If true, cancel the switch */
cancel?: boolean;
}
/** Return type for `session_before_branch` handlers */
export interface SessionBeforeBranchResult {
/**
* If true, abort the branch entirely. No new session file is created,
* conversation stays unchanged.
*/
cancel?: boolean;
/**
* If true, the branch proceeds (new session file created, session state updated)
* but the in-memory conversation is NOT rewound to the branch point.
*
* Use case: git-checkpoint handler that restores code state separately.
* The handler handles state restoration itself, so it doesn't want the
* agent's conversation to be rewound (which would lose recent context).
*
* - `cancel: true` → nothing happens, user stays in current session
* - `skipConversationRestore: true` → branch happens, but messages stay as-is
* - neither → branch happens AND messages rewind to branch point (default)
*/
skipConversationRestore?: boolean;
}
/** Return type for `session_before_compact` handlers */
export interface SessionBeforeCompactResult {
/** If true, cancel the compaction */
cancel?: boolean;
/** Custom compaction result - SessionManager adds id/parentId */
compaction?: CompactionResult;
}
/** Return type for `session.compacting` handlers */
export interface SessionCompactingResult {
/** Additional context lines to include in summary */
context?: string[];
/** Override the default compaction prompt */
prompt?: string;
/** Custom data to store in compaction entry */
preserveData?: Record<string, unknown>;
}
/** Return type for `session_before_tree` handlers */
export interface SessionBeforeTreeResult {
/** If true, cancel the navigation entirely */
cancel?: boolean;
/**
* Custom summary (skips default summarizer).
* Only used if preparation.userWantsSummary is true.
*/
summary?: {
summary: string;
details?: unknown;
};
}
@@ -379,4 +379,4 @@ export const MENTAL_MODEL_FIRST_TURN_DEADLINE_MS = 1500;
export const MENTAL_MODEL_REFRESH_INTERVAL_MS = 5 * 60 * 1000; export const MENTAL_MODEL_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
/** Need-only export of the raw seed list for tests. */ /** Need-only export of the raw seed list for tests. */
export const __builtinSeedsForTest: ReadonlyArray<Readonly<RawSeed>> = BUILTIN_SEEDS; export const builtinSeedsForTest: ReadonlyArray<Readonly<RawSeed>> = BUILTIN_SEEDS;
@@ -14,29 +14,10 @@
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as path from "node:path"; import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils"; import { isEnoent } from "@oh-my-pi/pi-utils";
import { AgentRegistry } from "../registry/agent-registry";
import { applyQuery, pathToQuery } from "./json-query"; import { applyQuery, pathToQuery } from "./json-query";
import { artifactsDirsFromRegistry } from "./registry-helpers";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types"; import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
/**
* Snapshot of artifacts dirs for every registered session, deduped.
*
* Prefers `sessionManager.getArtifactsDir()` because subagents adopt the
* parent's manager and report the parent's dir there; dedup then collapses
* the whole agent tree to one entry. Falls back to the raw session file
* when no live session reference is attached.
*/
function artifactsDirsFromRegistry(): string[] {
const dirs: string[] = [];
for (const ref of AgentRegistry.global().list()) {
const dir =
ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null);
if (!dir) continue;
if (!dirs.includes(dir)) dirs.push(dir);
}
return dirs;
}
/** /**
* Handler for agent:// URLs. * Handler for agent:// URLs.
* *
@@ -12,27 +12,9 @@
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as path from "node:path"; import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils"; import { isEnoent } from "@oh-my-pi/pi-utils";
import { AgentRegistry } from "../registry/agent-registry"; import { artifactsDirsFromRegistry } from "./registry-helpers";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types"; import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
/**
* Snapshot of artifacts dirs across all registered sessions, deduped.
*
* Subagents adopt their parent's `ArtifactManager`, so their
* `sessionManager.getArtifactsDir()` returns the parent's dir; dedup
* collapses parent + N subagents to a single entry.
*/
function artifactsDirsFromRegistry(): string[] {
const dirs: string[] = [];
for (const ref of AgentRegistry.global().list()) {
const dir =
ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null);
if (!dir) continue;
if (!dirs.includes(dir)) dirs.push(dir);
}
return dirs;
}
export class ArtifactProtocolHandler implements ProtocolHandler { export class ArtifactProtocolHandler implements ProtocolHandler {
readonly scheme = "artifact"; readonly scheme = "artifact";
readonly immutable = true; readonly immutable = true;
@@ -0,0 +1,25 @@
/**
* Shared helpers for internal-url protocol handlers that resolve IDs against
* registered agent sessions.
*/
import { AgentRegistry } from "../registry/agent-registry";
/**
* Snapshot of artifacts dirs for every registered session, deduped.
*
* Prefers `sessionManager.getArtifactsDir()` because subagents adopt their
* parent's `ArtifactManager` and report the parent's dir there; dedup then
* collapses parent + N subagents (the whole agent tree) to one entry. Falls
* back to the raw session file (with the `.jsonl` suffix stripped) when no
* live session reference is attached.
*/
export function artifactsDirsFromRegistry(): string[] {
const dirs: string[] = [];
for (const ref of AgentRegistry.global().list()) {
const dir =
ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null);
if (!dir) continue;
if (!dirs.includes(dir)) dirs.push(dir);
}
return dirs;
}
@@ -7,19 +7,23 @@ import {
Container, Container,
Ellipsis, Ellipsis,
ImageProtocol, ImageProtocol,
Loader, type Loader,
Spacer,
TERMINAL, TERMINAL,
Text, Text,
type TUI, type TUI,
truncateToWidth, truncateToWidth,
visibleWidth, visibleWidth,
} from "@oh-my-pi/pi-tui"; } from "@oh-my-pi/pi-tui";
import { getSymbolTheme, theme } from "../../modes/theme/theme"; import { theme } from "../../modes/theme/theme";
import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta"; import type { TruncationMeta } from "../../tools/output-meta";
import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel"; import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
import { DynamicBorder } from "./dynamic-border"; import {
import { truncateToVisualLines } from "./visual-truncate"; buildExecutionFrame,
buildStatusFooter,
createCollapsedPreview,
type ExecutionStatus,
resolveExecutionStatus,
} from "./execution-shared";
// Preview line limit when not expanded (matches tool execution behavior) // Preview line limit when not expanded (matches tool execution behavior)
const PREVIEW_LINES = 20; const PREVIEW_LINES = 20;
@@ -31,7 +35,7 @@ const CHUNK_THROTTLE_MS = 50;
export class BashExecutionComponent extends Container { export class BashExecutionComponent extends Container {
#outputLines: string[] = []; #outputLines: string[] = [];
#status: "running" | "complete" | "cancelled" | "error" = "running"; #status: ExecutionStatus = "running";
#exitCode: number | undefined = undefined; #exitCode: number | undefined = undefined;
#loader: Loader; #loader: Loader;
#truncation?: TruncationMeta; #truncation?: TruncationMeta;
@@ -50,34 +54,14 @@ export class BashExecutionComponent extends Container {
// Use dim border for excluded-from-context commands (!! prefix) // Use dim border for excluded-from-context commands (!! prefix)
const colorKey = excludeFromContext ? "dim" : "bashMode"; const colorKey = excludeFromContext ? "dim" : "bashMode";
const borderColor = (str: string) => theme.fg(colorKey, str); const { contentContainer, loader } = buildExecutionFrame(this, ui, colorKey);
this.#contentContainer = contentContainer;
// Add spacer this.#loader = loader;
this.addChild(new Spacer(1));
// Top border
this.addChild(new DynamicBorder(borderColor));
// Content container (holds dynamic content between borders)
this.#contentContainer = new Container();
this.addChild(this.#contentContainer);
// Command header // Command header
this.#headerText = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0); this.#headerText = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);
this.#contentContainer.addChild(this.#headerText); this.#contentContainer.addChild(this.#headerText);
// Loader
this.#loader = new Loader(
ui,
spinner => theme.fg(colorKey, spinner),
text => theme.fg("muted", text),
`Running… (esc to cancel)`,
getSymbolTheme().spinnerFrames,
);
this.#contentContainer.addChild(this.#loader); this.#contentContainer.addChild(this.#loader);
// Bottom border
this.addChild(new DynamicBorder(borderColor));
} }
/** /**
@@ -130,11 +114,7 @@ export class BashExecutionComponent extends Container {
options?: { output?: string; truncation?: TruncationMeta }, options?: { output?: string; truncation?: TruncationMeta },
): void { ): void {
this.#exitCode = exitCode; this.#exitCode = exitCode;
this.#status = cancelled this.#status = resolveExecutionStatus(exitCode, cancelled);
? "cancelled"
: exitCode !== 0 && exitCode !== undefined && exitCode !== null
? "error"
: "complete";
this.#truncation = options?.truncation; this.#truncation = options?.truncation;
if (options?.output !== undefined) { if (options?.output !== undefined) {
this.#setOutput(options.output); this.#setOutput(options.output);
@@ -182,14 +162,7 @@ export class BashExecutionComponent extends Container {
} else { } else {
// Use shared visual truncation utility, recomputed per render width // Use shared visual truncation utility, recomputed per render width
const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n"); const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n");
const previewText = `\n${styledOutput}`; this.#contentContainer.addChild(createCollapsedPreview(`\n${styledOutput}`, PREVIEW_LINES));
this.#contentContainer.addChild({
render: (width: number) => {
const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1);
return visualLines;
},
invalidate: () => {},
});
} }
} }
@@ -197,26 +170,14 @@ export class BashExecutionComponent extends Container {
if (this.#status === "running") { if (this.#status === "running") {
this.#contentContainer.addChild(this.#loader); this.#contentContainer.addChild(this.#loader);
} else { } else {
const statusParts: string[] = []; const footer = buildStatusFooter({
status: this.#status,
// Show how many lines are hidden (collapsed preview) exitCode: this.#exitCode,
if (hiddenLineCount > 0 && !hasSixelOutput) { truncation: this.#truncation,
statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`)); hiddenLineCount,
} suppressHiddenCount: hasSixelOutput,
});
if (this.#status === "cancelled") { if (footer) this.#contentContainer.addChild(footer);
statusParts.push(theme.fg("warning", "(cancelled)"));
} else if (this.#status === "error") {
statusParts.push(theme.fg("error", `(exit ${this.#exitCode})`));
}
if (this.#truncation) {
statusParts.push(theme.fg("warning", formatTruncationMetaNotice(this.#truncation)));
}
if (statusParts.length > 0) {
this.#contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0));
}
} }
} }
@@ -1,9 +1,9 @@
import type { TextContent } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui"; import type { Component } from "@oh-my-pi/pi-tui";
import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui"; import { Box, Container, Spacer } from "@oh-my-pi/pi-tui";
import type { MessageRenderer } from "../../extensibility/extensions/types"; import type { MessageRenderer } from "../../extensibility/extensions/types";
import { getMarkdownTheme, theme } from "../../modes/theme/theme"; import { theme } from "../../modes/theme/theme";
import type { CustomMessage } from "../../session/messages"; import type { CustomMessage } from "../../session/messages";
import { renderFramedMessage } from "./message-frame";
/** /**
* Component that renders a custom message entry from extensions. * Component that renders a custom message entry from extensions.
@@ -41,51 +41,25 @@ export class CustomMessageComponent extends Container {
} }
#rebuild(): void { #rebuild(): void {
// Remove previous content component
if (this.#customComponent) { if (this.#customComponent) {
this.removeChild(this.#customComponent); this.removeChild(this.#customComponent);
this.#customComponent = undefined; this.#customComponent = undefined;
} }
this.removeChild(this.#box); this.removeChild(this.#box);
// Try custom renderer first - it handles its own styling const custom = renderFramedMessage({
if (this.customRenderer) { message: this.message,
try { box: this.#box,
const component = this.customRenderer(this.message, { expanded: this.#expanded }, theme); expanded: this.#expanded,
if (component) { customRenderer: this.customRenderer,
this.#customComponent = component; // Extension messages render full content; no collapse-on-fold behaviour.
this.addChild(component); });
return;
}
} catch {
// Fall through to default rendering
}
}
// Default rendering uses our box if (custom) {
this.addChild(this.#box); this.#customComponent = custom;
this.#box.clear(); this.addChild(custom);
// Default rendering: label + content
const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`));
this.#box.addChild(new Text(label, 0, 0));
this.#box.addChild(new Spacer(1));
// Extract text content
let text: string;
if (typeof this.message.content === "string") {
text = this.message.content;
} else { } else {
text = this.message.content this.addChild(this.#box);
.filter((c): c is TextContent => c.type === "text")
.map(c => c.text)
.join("\n");
} }
this.#box.addChild(
new Markdown(text, 0, 0, getMarkdownTheme(), {
color: (value: string) => theme.fg("customMessageText", value),
}),
);
} }
} }
@@ -4,11 +4,17 @@
*/ */
import { sanitizeText } from "@oh-my-pi/pi-natives"; import { sanitizeText } from "@oh-my-pi/pi-natives";
import { Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; import { Container, type Loader, Text, type TUI } from "@oh-my-pi/pi-tui";
import { getSymbolTheme, highlightCode, theme } from "../../modes/theme/theme"; import { highlightCode, theme } from "../../modes/theme/theme";
import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta"; import type { TruncationMeta } from "../../tools/output-meta";
import { DynamicBorder } from "./dynamic-border"; import {
import { truncateToVisualLines } from "./visual-truncate"; buildExecutionFrame,
buildStatusFooter,
createCollapsedPreview,
type ExecutionColorKey,
type ExecutionStatus,
resolveExecutionStatus,
} from "./execution-shared";
const PREVIEW_LINES = 20; const PREVIEW_LINES = 20;
const MAX_DISPLAY_LINE_CHARS = 4000; const MAX_DISPLAY_LINE_CHARS = 4000;
@@ -17,7 +23,7 @@ export type EvalExecutionLanguage = "python" | "js";
export class EvalExecutionComponent extends Container { export class EvalExecutionComponent extends Container {
#outputLines: string[] = []; #outputLines: string[] = [];
#status: "running" | "complete" | "cancelled" | "error" = "running"; #status: ExecutionStatus = "running";
#exitCode: number | undefined = undefined; #exitCode: number | undefined = undefined;
#loader: Loader; #loader: Loader;
#truncation?: TruncationMeta; #truncation?: TruncationMeta;
@@ -28,7 +34,7 @@ export class EvalExecutionComponent extends Container {
return this.language === "js" ? "javascript" : "python"; return this.language === "js" ? "javascript" : "python";
} }
#formatHeader(colorKey: "dim" | "pythonMode"): Text { #formatHeader(colorKey: ExecutionColorKey): Text {
const prompt = theme.fg(colorKey, theme.bold(">>>")); const prompt = theme.fg(colorKey, theme.bold(">>>"));
const continuation = theme.fg(colorKey, " "); const continuation = theme.fg(colorKey, " ");
const codeLines = highlightCode(this.code, this.#highlightLang()); const codeLines = highlightCode(this.code, this.#highlightLang());
@@ -46,26 +52,13 @@ export class EvalExecutionComponent extends Container {
) { ) {
super(); super();
const colorKey = this.excludeFromContext ? "dim" : "pythonMode"; const colorKey: ExecutionColorKey = this.excludeFromContext ? "dim" : "pythonMode";
const borderColor = (str: string) => theme.fg(colorKey, str); const { contentContainer, loader } = buildExecutionFrame(this, ui, colorKey);
this.#contentContainer = contentContainer;
this.#loader = loader;
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder(borderColor));
this.#contentContainer = new Container();
this.addChild(this.#contentContainer);
this.#contentContainer.addChild(this.#formatHeader(colorKey)); this.#contentContainer.addChild(this.#formatHeader(colorKey));
this.#loader = new Loader(
ui,
spinner => theme.fg(colorKey, spinner),
text => theme.fg("muted", text),
`Running… (esc to cancel)`,
getSymbolTheme().spinnerFrames,
);
this.#contentContainer.addChild(this.#loader); this.#contentContainer.addChild(this.#loader);
this.addChild(new DynamicBorder(borderColor));
} }
setExpanded(expanded: boolean): void { setExpanded(expanded: boolean): void {
@@ -99,11 +92,7 @@ export class EvalExecutionComponent extends Container {
options?: { output?: string; truncation?: TruncationMeta }, options?: { output?: string; truncation?: TruncationMeta },
): void { ): void {
this.#exitCode = exitCode; this.#exitCode = exitCode;
this.#status = cancelled this.#status = resolveExecutionStatus(exitCode, cancelled);
? "cancelled"
: exitCode !== 0 && exitCode !== undefined && exitCode !== null
? "error"
: "complete";
this.#truncation = options?.truncation; this.#truncation = options?.truncation;
if (options?.output !== undefined) { if (options?.output !== undefined) {
this.#setOutput(options.output); this.#setOutput(options.output);
@@ -120,7 +109,7 @@ export class EvalExecutionComponent extends Container {
this.#contentContainer.clear(); this.#contentContainer.clear();
const colorKey = this.excludeFromContext ? "dim" : "pythonMode"; const colorKey: ExecutionColorKey = this.excludeFromContext ? "dim" : "pythonMode";
this.#contentContainer.addChild(this.#formatHeader(colorKey)); this.#contentContainer.addChild(this.#formatHeader(colorKey));
if (availableLines.length > 0) { if (availableLines.length > 0) {
@@ -129,39 +118,20 @@ export class EvalExecutionComponent extends Container {
this.#contentContainer.addChild(new Text(`\n${displayText}`, 1, 0)); this.#contentContainer.addChild(new Text(`\n${displayText}`, 1, 0));
} else { } else {
const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n"); const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n");
const previewText = `\n${styledOutput}`; this.#contentContainer.addChild(createCollapsedPreview(`\n${styledOutput}`, PREVIEW_LINES));
this.#contentContainer.addChild({
render: (width: number) => {
const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1);
return visualLines;
},
invalidate: () => {},
});
} }
} }
if (this.#status === "running") { if (this.#status === "running") {
this.#contentContainer.addChild(this.#loader); this.#contentContainer.addChild(this.#loader);
} else { } else {
const statusParts: string[] = []; const footer = buildStatusFooter({
status: this.#status,
if (hiddenLineCount > 0) { exitCode: this.#exitCode,
statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`)); truncation: this.#truncation,
} hiddenLineCount,
});
if (this.#status === "cancelled") { if (footer) this.#contentContainer.addChild(footer);
statusParts.push(theme.fg("warning", "(cancelled)"));
} else if (this.#status === "error") {
statusParts.push(theme.fg("error", `(exit ${this.#exitCode})`));
}
if (this.#truncation) {
statusParts.push(theme.fg("warning", formatTruncationMetaNotice(this.#truncation)));
}
if (statusParts.length > 0) {
this.#contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0));
}
} }
} }
@@ -0,0 +1,102 @@
/**
* Shared rendering primitives for bash/eval execution components.
*
* Each helper isolates a piece of structure both components share verbatim
* (frame layout, collapsed preview, post-run status line). Differences in
* how each component prepares its header, output lines, or sixel masking
* stay in their respective files.
*/
import { type Component, Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
import { getSymbolTheme, theme } from "../../modes/theme/theme";
import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
import { DynamicBorder } from "./dynamic-border";
import { truncateToVisualLines } from "./visual-truncate";
export type ExecutionStatus = "running" | "complete" | "cancelled" | "error";
/** Theme color keys valid for an execution frame. */
export type ExecutionColorKey = "dim" | "bashMode" | "pythonMode";
/**
* Build the spacer + top border + content container + bottom border scaffold
* that bash and eval execution components share. The caller appends the
* header (command vs `>>>` prompt) and the returned loader to
* `contentContainer` so per-mode order is preserved.
*/
export function buildExecutionFrame(
parent: Container,
ui: TUI,
colorKey: ExecutionColorKey,
): { contentContainer: Container; loader: Loader } {
const borderColor = (str: string) => theme.fg(colorKey, str);
parent.addChild(new Spacer(1));
parent.addChild(new DynamicBorder(borderColor));
const contentContainer = new Container();
parent.addChild(contentContainer);
const loader = new Loader(
ui,
spinner => theme.fg(colorKey, spinner),
text => theme.fg("muted", text),
`Running… (esc to cancel)`,
getSymbolTheme().spinnerFrames,
);
parent.addChild(new DynamicBorder(borderColor));
return { contentContainer, loader };
}
/**
* Wrap a styled preview block in a render-time visual-line truncator.
* Recomputed per render width so wrapping stays in sync with terminal size.
*/
export function createCollapsedPreview(previewText: string, previewLines: number): Component {
return {
render: (width: number) => truncateToVisualLines(previewText, previewLines, width, 1).visualLines,
invalidate: () => {},
};
}
/**
* Build the post-run status block (hidden-line hint, exit/cancel marker,
* truncation notice). Returns undefined when there is nothing to display so
* callers can skip appending a stray Text child.
*/
export function buildStatusFooter(opts: {
status: ExecutionStatus;
exitCode: number | undefined;
truncation: TruncationMeta | undefined;
hiddenLineCount: number;
/** Suppress the "… N more lines" hint (used when sixel passthrough renders the full output). */
suppressHiddenCount?: boolean;
}): Text | undefined {
const parts: string[] = [];
if (opts.hiddenLineCount > 0 && !opts.suppressHiddenCount) {
parts.push(theme.fg("dim", `… ${opts.hiddenLineCount} more lines (ctrl+o to expand)`));
}
if (opts.status === "cancelled") {
parts.push(theme.fg("warning", "(cancelled)"));
} else if (opts.status === "error") {
parts.push(theme.fg("error", `(exit ${opts.exitCode})`));
}
if (opts.truncation) {
parts.push(theme.fg("warning", formatTruncationMetaNotice(opts.truncation)));
}
if (parts.length === 0) return undefined;
return new Text(`\n${parts.join("\n")}`, 1, 0);
}
/**
* Derive the post-run status from an exit code + cancellation flag using the
* same precedence both execution components apply.
*/
export function resolveExecutionStatus(exitCode: number | undefined, cancelled: boolean): ExecutionStatus {
if (cancelled) return "cancelled";
if (exitCode !== 0 && exitCode !== undefined && exitCode !== null) return "error";
return "complete";
}
@@ -1,9 +1,12 @@
import type { TextContent } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui"; import type { Component } from "@oh-my-pi/pi-tui";
import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui"; import { Box, Container, Spacer } from "@oh-my-pi/pi-tui";
import type { HookMessageRenderer } from "../../extensibility/hooks/types"; import type { HookMessageRenderer } from "../../extensibility/hooks/types";
import { getMarkdownTheme, theme } from "../../modes/theme/theme"; import { theme } from "../../modes/theme/theme";
import type { HookMessage } from "../../session/messages"; import type { HookMessage } from "../../session/messages";
import { renderFramedMessage } from "./message-frame";
/** Lines of default markdown body shown before the "…" fold when collapsed. */
const HOOK_COLLAPSED_LINES = 5;
/** /**
* Component that renders a custom message entry from hooks. * Component that renders a custom message entry from hooks.
@@ -41,60 +44,25 @@ export class HookMessageComponent extends Container {
} }
#rebuild(): void { #rebuild(): void {
// Remove previous content component
if (this.#customComponent) { if (this.#customComponent) {
this.removeChild(this.#customComponent); this.removeChild(this.#customComponent);
this.#customComponent = undefined; this.#customComponent = undefined;
} }
this.removeChild(this.#box); this.removeChild(this.#box);
// Try custom renderer first - it handles its own styling const custom = renderFramedMessage({
if (this.customRenderer) { message: this.message,
try { box: this.#box,
const component = this.customRenderer(this.message, { expanded: this.#expanded }, theme); expanded: this.#expanded,
if (component) { customRenderer: this.customRenderer,
// Custom renderer provides its own styled component collapseAfterLines: HOOK_COLLAPSED_LINES,
this.#customComponent = component; });
this.addChild(component);
return;
}
} catch {
// Fall through to default rendering
}
}
// Default rendering uses our box if (custom) {
this.addChild(this.#box); this.#customComponent = custom;
this.#box.clear(); this.addChild(custom);
// Default rendering: label + content
const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`));
this.#box.addChild(new Text(label, 0, 0));
this.#box.addChild(new Spacer(1));
// Extract text content
let text: string;
if (typeof this.message.content === "string") {
text = this.message.content;
} else { } else {
text = this.message.content this.addChild(this.#box);
.filter((c): c is TextContent => c.type === "text")
.map(c => c.text)
.join("\n");
} }
// Limit lines when collapsed
if (!this.#expanded) {
const lines = text.split("\n");
if (lines.length > 5) {
text = `${lines.slice(0, 5).join("\n")}\n…`;
}
}
this.#box.addChild(
new Markdown(text, 0, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("customMessageText", text),
}),
);
} }
} }
@@ -0,0 +1,88 @@
/**
* Shared rendering for extension/hook custom message frames.
*
* Both `CustomMessageComponent` and `HookMessageComponent` wrap a
* `Spacer(1) + Box` layout, try a user-supplied renderer first, and fall
* back to a label + markdown body when the renderer returns nothing or
* throws. The only meaningful difference is that hook messages collapse to
* the first N lines when not expanded; extension messages render in full.
*/
import type { TextContent } from "@oh-my-pi/pi-ai";
import type { Box, Component } from "@oh-my-pi/pi-tui";
import { Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
import { getMarkdownTheme, type Theme, theme } from "../../modes/theme/theme";
/** Message shape consumed by the shared frame. */
export interface FramedMessage {
customType: string;
content: string | (TextContent | { type: string })[];
}
/**
* Callable signature shared by `MessageRenderer` (extensions) and
* `HookMessageRenderer` (hooks). Both narrow `message` to their own type;
* this signature is the structural intersection callers can hand off here.
*/
export type FramedRenderer<M extends FramedMessage> = (
message: M,
options: { expanded: boolean },
theme: Theme,
) => Component | undefined;
export interface RebuildFrameOptions<M extends FramedMessage> {
message: M;
box: Box;
expanded: boolean;
/** Collapse the markdown body to this many lines when `expanded` is false. Omit to never collapse. */
collapseAfterLines?: number;
customRenderer?: FramedRenderer<M>;
}
/**
* Attempt the custom renderer; on failure or undefined return, populate
* `box` with the default `[customType]` label + markdown body and return
* undefined. When the custom renderer succeeds, return its Component so the
* caller can mount it and skip the default box.
*/
export function renderFramedMessage<M extends FramedMessage>(opts: RebuildFrameOptions<M>): Component | undefined {
if (opts.customRenderer) {
try {
const component = opts.customRenderer(opts.message, { expanded: opts.expanded }, theme);
if (component) return component;
} catch {
// Fall through to default rendering
}
}
opts.box.clear();
const label = theme.fg("customMessageLabel", theme.bold(`[${opts.message.customType}]`));
opts.box.addChild(new Text(label, 0, 0));
opts.box.addChild(new Spacer(1));
let text: string;
if (typeof opts.message.content === "string") {
text = opts.message.content;
} else {
text = opts.message.content
.filter((c): c is TextContent => c.type === "text")
.map(c => c.text)
.join("\n");
}
if (!opts.expanded && opts.collapseAfterLines !== undefined) {
const lines = text.split("\n");
if (lines.length > opts.collapseAfterLines) {
text = `${lines.slice(0, opts.collapseAfterLines).join("\n")}\n…`;
}
}
opts.box.addChild(
new Markdown(text, 0, 0, getMarkdownTheme(), {
color: (value: string) => theme.fg("customMessageText", value),
}),
);
return undefined;
}
@@ -2,6 +2,7 @@ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { getSupportedEfforts, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai"; import { getSupportedEfforts, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
import { import {
Container, Container,
fuzzyFilter,
getKeybindings, getKeybindings,
Input, Input,
matchesKey, matchesKey,
@@ -18,7 +19,6 @@ import { resolveModelRoleValue } from "../../config/model-resolver";
import type { Settings } from "../../config/settings"; import type { Settings } from "../../config/settings";
import { type ThemeColor, theme } from "../../modes/theme/theme"; import { type ThemeColor, theme } from "../../modes/theme/theme";
import { getThinkingLevelMetadata } from "../../thinking"; import { getThinkingLevelMetadata } from "../../thinking";
import { fuzzyFilter } from "../../utils/fuzzy";
import { getTabBarTheme } from "../shared"; import { getTabBarTheme } from "../shared";
import { DynamicBorder } from "./dynamic-border"; import { DynamicBorder } from "./dynamic-border";
@@ -1,6 +1,7 @@
import { import {
type Component, type Component,
Container, Container,
fuzzyFilter,
Input, Input,
matchesKey, matchesKey,
padding, padding,
@@ -14,7 +15,6 @@ import { formatBytes } from "@oh-my-pi/pi-utils";
import { theme } from "../../modes/theme/theme"; import { theme } from "../../modes/theme/theme";
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers"; import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
import type { SessionInfo } from "../../session/session-manager"; import type { SessionInfo } from "../../session/session-manager";
import { fuzzyFilter } from "../../utils/fuzzy";
import { DynamicBorder } from "./dynamic-border"; import { DynamicBorder } from "./dynamic-border";
import { HookSelectorComponent } from "./hook-selector"; import { HookSelectorComponent } from "./hook-selector";
@@ -0,0 +1,108 @@
/**
* Shared helpers for /mcp and /ssh command controllers.
*
* Captures argument parsing, source grouping, and chat-message rendering that
* was duplicated between mcp-command-controller and ssh-command-controller.
* Intentionally kept narrow: subcommand routing, help text, success/error
* wording, and add-flow logic stay in the per-controller files because they
* diverge in workflow.
*/
import { Spacer, Text } from "@oh-my-pi/pi-tui";
import type { SourceMeta } from "../../capability/types";
import { shortenPath } from "../../tools/render-utils";
import { DynamicBorder } from "../components/dynamic-border";
import { parseCommandArgs } from "../shared";
import type { InteractiveModeContext } from "../types";
export type ScopeValue = "project" | "user";
export type ScopeFlagResult = { ok: true; scope: ScopeValue } | { ok: false; error: string };
/**
* Validate the value following a `--scope` flag.
*/
export function readScopeFlag(value: string | undefined): ScopeFlagResult {
if (!value || (value !== "project" && value !== "user")) {
return { ok: false, error: "Invalid --scope value. Use project or user." };
}
return { ok: true, scope: value };
}
export type RemoveArgs = { name: string | undefined; scope: ScopeValue };
export type ParseRemoveResult = { ok: true; value: RemoveArgs } | { ok: false; error: string };
/**
* Parse the argument tail of `/<cmd> remove <name> [--scope project|user]`.
*
* `rest` is the text after the subcommand keyword. The caller is responsible
* for emitting the command-specific "<entity> name required" usage hint when
* `value.name` is undefined.
*/
export function parseRemoveArgs(rest: string): ParseRemoveResult {
const tokens = parseCommandArgs(rest);
let name: string | undefined;
let scope: ScopeValue = "project";
let i = 0;
if (tokens.length > 0 && !tokens[0].startsWith("-")) {
name = tokens[0];
i = 1;
}
while (i < tokens.length) {
const token = tokens[i];
if (token === "--scope") {
const r = readScopeFlag(tokens[i + 1]);
if (!r.ok) return { ok: false, error: r.error };
scope = r.scope;
i += 2;
continue;
}
return { ok: false, error: `Unknown option: ${token}` };
}
return { ok: true, value: { name, scope } };
}
/**
* Group capability-loaded items by their source provider+path, yielding each
* group with a display-ready `shortPath`.
*/
export function* groupBySource<T>(
items: Iterable<T>,
getSource: (item: T) => SourceMeta,
): Iterable<{ providerName: string; shortPath: string; items: T[] }> {
const groups = new Map<string, T[]>();
for (const item of items) {
const src = getSource(item);
const key = `${src.providerName}|${src.path}`;
let group = groups.get(key);
if (!group) {
group = [];
groups.set(key, group);
}
group.push(item);
}
for (const [key, grouped] of groups) {
const sepIdx = key.indexOf("|");
yield {
providerName: key.slice(0, sepIdx),
shortPath: shortenPath(key.slice(sepIdx + 1)),
items: grouped,
};
}
}
/**
* Render a message block (DynamicBorder / Text / DynamicBorder) into the chat
* container and request a render.
*/
export function showCommandMessage(ctx: InteractiveModeContext, text: string): void {
ctx.chatContainer.addChild(new Spacer(1));
ctx.chatContainer.addChild(new DynamicBorder());
ctx.chatContainer.addChild(new Text(text, 1, 1));
ctx.chatContainer.addChild(new DynamicBorder());
ctx.ui.requestRender();
}
@@ -37,11 +37,11 @@ import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../
import type { OAuthCredential } from "../../session/auth-storage"; import type { OAuthCredential } from "../../session/auth-storage";
import { shortenPath } from "../../tools/render-utils"; import { shortenPath } from "../../tools/render-utils";
import { openPath } from "../../utils/open"; import { openPath } from "../../utils/open";
import { DynamicBorder } from "../components/dynamic-border";
import { MCPAddWizard } from "../components/mcp-add-wizard"; import { MCPAddWizard } from "../components/mcp-add-wizard";
import { parseCommandArgs } from "../shared"; import { parseCommandArgs } from "../shared";
import { theme } from "../theme/theme"; import { theme } from "../theme/theme";
import type { InteractiveModeContext } from "../types"; import type { InteractiveModeContext } from "../types";
import { groupBySource, parseRemoveArgs, readScopeFlag, showCommandMessage } from "./command-controller-shared";
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> { function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
const { promise: timeoutPromise, reject } = Promise.withResolvers<T>(); const { promise: timeoutPromise, reject } = Promise.withResolvers<T>();
@@ -207,11 +207,11 @@ export class MCPCommandController {
break; break;
} }
if (argToken === "--scope") { if (argToken === "--scope") {
const value = tokens[i + 1]; const r = readScopeFlag(tokens[i + 1]);
if (!value || (value !== "project" && value !== "user")) { if (!r.ok) {
return { scope, error: "Invalid --scope value. Use project or user." }; return { scope, error: r.error };
} }
scope = value; scope = r.scope;
i += 2; i += 2;
continue; continue;
} }
@@ -984,23 +984,7 @@ export class MCPCommandController {
// Show discovered servers (from .claude.json, .cursor/mcp.json, .vscode/mcp.json, etc.) // Show discovered servers (from .claude.json, .cursor/mcp.json, .vscode/mcp.json, etc.)
if (discoveredServers.length > 0) { if (discoveredServers.length > 0) {
// Group by source display name + path for (const { providerName, shortPath, items: entries } of groupBySource(discoveredServers, e => e.source)) {
const bySource = new Map<string, typeof discoveredServers>();
for (const entry of discoveredServers) {
const key = `${entry.source.providerName}|${entry.source.path}`;
let group = bySource.get(key);
if (!group) {
group = [];
bySource.set(key, group);
}
group.push(entry);
}
for (const [key, entries] of bySource) {
const sepIdx = key.indexOf("|");
const providerName = key.slice(0, sepIdx);
const sourcePath = key.slice(sepIdx + 1);
const shortPath = shortenPath(sourcePath);
lines.push(theme.fg("accent", providerName) + theme.fg("muted", ` (${shortPath}):`)); lines.push(theme.fg("accent", providerName) + theme.fg("muted", ` (${shortPath}):`));
for (const { name } of entries) { for (const { name } of entries) {
const state = this.ctx.mcpManager!.getConnectionStatus(name); const state = this.ctx.mcpManager!.getConnectionStatus(name);
@@ -1037,32 +1021,12 @@ export class MCPCommandController {
async #handleRemove(text: string): Promise<void> { async #handleRemove(text: string): Promise<void> {
const match = text.match(/^\/mcp\s+(?:remove|rm)\b\s*(.*)$/i); const match = text.match(/^\/mcp\s+(?:remove|rm)\b\s*(.*)$/i);
const rest = match?.[1]?.trim() ?? ""; const rest = match?.[1]?.trim() ?? "";
const tokens = parseCommandArgs(rest); const parsed = parseRemoveArgs(rest);
if (!parsed.ok) {
let name: string | undefined; this.ctx.showError(parsed.error);
let scope: "project" | "user" = "project";
let i = 0;
if (tokens.length > 0 && !tokens[0].startsWith("-")) {
name = tokens[0];
i = 1;
}
while (i < tokens.length) {
const token = tokens[i];
if (token === "--scope") {
const value = tokens[i + 1];
if (!value || (value !== "project" && value !== "user")) {
this.ctx.showError("Invalid --scope value. Use project or user.");
return;
}
scope = value;
i += 2;
continue;
}
this.ctx.showError(`Unknown option: ${token}`);
return; return;
} }
const { name, scope } = parsed.value;
if (!name) { if (!name) {
this.ctx.showError("Server name required. Usage: /mcp remove <name> [--scope project|user]"); this.ctx.showError("Server name required. Usage: /mcp remove <name> [--scope project|user]");
@@ -1929,10 +1893,6 @@ export class MCPCommandController {
* Show a message in the chat * Show a message in the chat
*/ */
#showMessage(text: string): void { #showMessage(text: string): void {
this.ctx.chatContainer.addChild(new Spacer(1)); showCommandMessage(this.ctx, text);
this.ctx.chatContainer.addChild(new DynamicBorder());
this.ctx.chatContainer.addChild(new Text(text, 1, 1));
this.ctx.chatContainer.addChild(new DynamicBorder());
this.ctx.ui.requestRender();
} }
} }
@@ -3,18 +3,20 @@
* *
* Handles /ssh subcommands for managing SSH host configurations. * Handles /ssh subcommands for managing SSH host configurations.
*/ */
import { Spacer, Text } from "@oh-my-pi/pi-tui";
import { getProjectDir, getSSHConfigPath } from "@oh-my-pi/pi-utils"; import { getProjectDir, getSSHConfigPath } from "@oh-my-pi/pi-utils";
import { type SSHHost, sshCapability } from "../../capability/ssh"; import { type SSHHost, sshCapability } from "../../capability/ssh";
import { loadCapability } from "../../discovery"; import { loadCapability } from "../../discovery";
import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../../ssh/config-writer"; import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../../ssh/config-writer";
import { shortenPath } from "../../tools/render-utils";
import { DynamicBorder } from "../components/dynamic-border";
import { parseCommandArgs } from "../shared"; import { parseCommandArgs } from "../shared";
import { theme } from "../theme/theme"; import { theme } from "../theme/theme";
import type { InteractiveModeContext } from "../types"; import type { InteractiveModeContext } from "../types";
import {
type SSHAddScope = "user" | "project"; groupBySource,
parseRemoveArgs,
readScopeFlag,
type ScopeValue,
showCommandMessage,
} from "./command-controller-shared";
export class SSHCommandController { export class SSHCommandController {
constructor(private ctx: InteractiveModeContext) {} constructor(private ctx: InteractiveModeContext) {}
@@ -90,7 +92,7 @@ export class SSHCommandController {
} }
let name: string | undefined; let name: string | undefined;
let scope: SSHAddScope = "project"; let scope: ScopeValue = "project";
let host: string | undefined; let host: string | undefined;
let username: string | undefined; let username: string | undefined;
let port: number | undefined; let port: number | undefined;
@@ -167,12 +169,12 @@ export class SSHCommandController {
continue; continue;
} }
if (argToken === "--scope") { if (argToken === "--scope") {
const value = tokens[i + 1]; const r = readScopeFlag(tokens[i + 1]);
if (!value || (value !== "project" && value !== "user")) { if (!r.ok) {
this.ctx.showError("Invalid --scope value. Use project or user."); this.ctx.showError(r.error);
return; return;
} }
scope = value; scope = r.scope;
i += 2; i += 2;
continue; continue;
} }
@@ -300,23 +302,7 @@ export class SSHCommandController {
// Show discovered hosts (from ssh.json, .ssh.json in project root, etc.) // Show discovered hosts (from ssh.json, .ssh.json in project root, etc.)
if (discoveredHosts.length > 0) { if (discoveredHosts.length > 0) {
// Group by source for (const { providerName, shortPath, items: hosts } of groupBySource(discoveredHosts, h => h._source)) {
const bySource = new Map<string, SSHHost[]>();
for (const host of discoveredHosts) {
const key = `${host._source.providerName}|${host._source.path}`;
let group = bySource.get(key);
if (!group) {
group = [];
bySource.set(key, group);
}
group.push(host);
}
for (const [key, hosts] of bySource) {
const sepIdx = key.indexOf("|");
const providerName = key.slice(0, sepIdx);
const sourcePath = key.slice(sepIdx + 1);
const shortPath = shortenPath(sourcePath);
lines.push( lines.push(
theme.fg("accent", "Discovered") + theme.fg("accent", "Discovered") +
theme.fg("muted", ` (${providerName}: ${shortPath}):`) + theme.fg("muted", ` (${providerName}: ${shortPath}):`) +
@@ -357,33 +343,12 @@ export class SSHCommandController {
async #handleRemove(text: string): Promise<void> { async #handleRemove(text: string): Promise<void> {
const match = text.match(/^\/ssh\s+(?:remove|rm)\b\s*(.*)$/i); const match = text.match(/^\/ssh\s+(?:remove|rm)\b\s*(.*)$/i);
const rest = match?.[1]?.trim() ?? ""; const rest = match?.[1]?.trim() ?? "";
const tokens = parseCommandArgs(rest); const parsed = parseRemoveArgs(rest);
if (!parsed.ok) {
let name: string | undefined; this.ctx.showError(parsed.error);
let scope: "project" | "user" = "project";
let i = 0;
if (tokens.length > 0 && !tokens[0].startsWith("-")) {
name = tokens[0];
i = 1;
}
while (i < tokens.length) {
const token = tokens[i];
if (token === "--scope") {
const value = tokens[i + 1];
if (!value || (value !== "project" && value !== "user")) {
this.ctx.showError("Invalid --scope value. Use project or user.");
return;
}
scope = value;
i += 2;
continue;
}
this.ctx.showError(`Unknown option: ${token}`);
return; return;
} }
const { name, scope } = parsed.value;
if (!name) { if (!name) {
this.ctx.showError("Host name required. Usage: /ssh remove <name> [--scope project|user]"); this.ctx.showError("Host name required. Usage: /ssh remove <name> [--scope project|user]");
return; return;
@@ -412,10 +377,6 @@ export class SSHCommandController {
* Show a message in the chat * Show a message in the chat
*/ */
#showMessage(text: string): void { #showMessage(text: string): void {
this.ctx.chatContainer.addChild(new Spacer(1)); showCommandMessage(this.ctx, text);
this.ctx.chatContainer.addChild(new DynamicBorder());
this.ctx.chatContainer.addChild(new Text(text, 1, 1));
this.ctx.chatContainer.addChild(new DynamicBorder());
this.ctx.ui.requestRender();
} }
} }
+10 -84
View File
@@ -7,8 +7,8 @@
*/ */
import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai"; import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
import { sanitizeText } from "@oh-my-pi/pi-natives"; import { sanitizeText } from "@oh-my-pi/pi-natives";
import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
import type { AgentSession } from "../session/agent-session"; import type { AgentSession } from "../session/agent-session";
import { initializeExtensions } from "./runtime-init";
/** /**
* Options for print mode. * Options for print mode.
@@ -39,90 +39,16 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
} }
} }
// Set up extensions for print mode (no UI, no command context) // Set up extensions for print mode (no UI, no command context)
const extensionRunner = session.extensionRunner; await initializeExtensions(session, {
if (extensionRunner) { reportSendError: (action, err) => {
extensionRunner.initialize( process.stderr.write(
// ExtensionActions `Extension ${action === "extension_send" ? "sendMessage" : "sendUserMessage"} failed: ${err.message}\n`,
{ );
sendMessage: (message, options) => { },
session.sendCustomMessage(message, options).catch(e => { reportRuntimeError: err => {
process.stderr.write(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}\n`);
});
},
sendUserMessage: (content, options) => {
session.sendUserMessage(content, options).catch(e => {
process.stderr.write(
`Extension sendUserMessage failed: ${e instanceof Error ? e.message : String(e)}\n`,
);
});
},
appendEntry: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},
setLabel: (targetId, label) => {
session.sessionManager.appendLabelChange(targetId, label);
},
getActiveTools: () => session.getActiveToolNames(),
getAllTools: () => session.getAllToolNames(),
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
getCommands: () => [],
setModel: model => runExtensionSetModel(session, model),
getThinkingLevel: () => session.thinkingLevel,
setThinkingLevel: level => session.setThinkingLevel(level),
getSessionName: () => session.sessionManager.getSessionName(),
setSessionName: async name => {
await session.sessionManager.setSessionName(name, "user");
},
},
// ExtensionContextActions
{
getModel: () => session.model,
isIdle: () => !session.isStreaming,
abort: () => session.abort(),
hasPendingMessages: () => session.queuedMessageCount > 0,
shutdown: () => {},
getContextUsage: () => session.getContextUsage(),
getSystemPrompt: () => session.systemPrompt,
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
},
// ExtensionCommandContextActions - commands invokable via prompt("/command")
{
getContextUsage: () => session.getContextUsage(),
waitForIdle: () => session.agent.waitForIdle(),
newSession: async options => {
const success = await session.newSession({ parentSession: options?.parentSession });
if (success && options?.setup) {
await options.setup(session.sessionManager);
}
return { cancelled: !success };
},
branch: async entryId => {
const result = await session.branch(entryId);
return { cancelled: result.cancelled };
},
navigateTree: async (targetId, options) => {
const result = await session.navigateTree(targetId, { summarize: options?.summarize });
return { cancelled: result.cancelled };
},
switchSession: async sessionPath => {
const success = await session.switchSession(sessionPath);
return { cancelled: !success };
},
reload: async () => {
await session.reload();
},
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
},
// No UI context
);
extensionRunner.onError(err => {
process.stderr.write(`Extension error (${err.extensionPath}): ${err.error}\n`); process.stderr.write(`Extension error (${err.extensionPath}): ${err.error}\n`);
}); },
// Emit session_start event });
await extensionRunner.emit({
type: "session_start",
});
}
// Always subscribe to enable session persistence via _handleAgentEvent // Always subscribe to enable session persistence via _handleAgentEvent
session.subscribe(event => { session.subscribe(event => {
+14 -87
View File
@@ -17,9 +17,9 @@ import type {
ExtensionUIDialogOptions, ExtensionUIDialogOptions,
ExtensionWidgetOptions, ExtensionWidgetOptions,
} from "../../extensibility/extensions"; } from "../../extensibility/extensions";
import { runExtensionCompact, runExtensionSetModel } from "../../extensibility/extensions/compact-handler";
import { type Theme, theme } from "../../modes/theme/theme"; import { type Theme, theme } from "../../modes/theme/theme";
import type { AgentSession } from "../../session/agent-session"; import type { AgentSession } from "../../session/agent-session";
import { initializeExtensions } from "../runtime-init";
import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools"; import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
import type { import type {
RpcCommand, RpcCommand,
@@ -421,91 +421,18 @@ export async function runRpcMode(
setToolUIContext?.(rpcUiContext, true); setToolUIContext?.(rpcUiContext, true);
// Set up extensions with RPC-based UI context // Set up extensions with RPC-based UI context
const extensionRunner = session.extensionRunner; await initializeExtensions(session, {
if (extensionRunner) { reportSendError: (action, err) => {
extensionRunner.initialize( output(error(undefined, action, err.message));
// ExtensionActions },
{ reportRuntimeError: err => {
sendMessage: (message, options) => {
session.sendCustomMessage(message, options).catch(e => {
output(error(undefined, "extension_send", e.message));
});
},
sendUserMessage: (content, options) => {
session.sendUserMessage(content, options).catch(e => {
output(error(undefined, "extension_send_user", e.message));
});
},
appendEntry: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},
setLabel: (targetId, label) => {
session.sessionManager.appendLabelChange(targetId, label);
},
getActiveTools: () => session.getActiveToolNames(),
getAllTools: () => session.getAllToolNames(),
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
getCommands: () => [],
setModel: model => runExtensionSetModel(session, model),
getThinkingLevel: () => session.thinkingLevel,
setThinkingLevel: level => session.setThinkingLevel(level),
getSessionName: () => session.sessionManager.getSessionName(),
setSessionName: async name => {
await session.sessionManager.setSessionName(name, "user");
},
},
// ExtensionContextActions
{
getModel: () => session.agent.state.model,
isIdle: () => !session.isStreaming,
abort: () => session.abort(),
hasPendingMessages: () => session.queuedMessageCount > 0,
shutdown: () => {
shutdownState.requested = true;
},
getContextUsage: () => session.getContextUsage(),
getSystemPrompt: () => session.systemPrompt,
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
},
// ExtensionCommandContextActions - commands invokable via prompt("/command")
{
getContextUsage: () => session.getContextUsage(),
waitForIdle: () => session.agent.waitForIdle(),
newSession: async options => {
const success = await session.newSession({ parentSession: options?.parentSession });
// Note: setup callback runs but no UI feedback in RPC mode
if (success && options?.setup) {
await options.setup(session.sessionManager);
}
return { cancelled: !success };
},
branch: async entryId => {
const result = await session.branch(entryId);
return { cancelled: result.cancelled };
},
navigateTree: async (targetId, options) => {
const result = await session.navigateTree(targetId, { summarize: options?.summarize });
return { cancelled: result.cancelled };
},
switchSession: async sessionPath => {
const success = await session.switchSession(sessionPath);
return { cancelled: !success };
},
reload: async () => {
await session.reload();
},
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
},
rpcUiContext,
);
extensionRunner.onError(err => {
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error }); output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
}); },
// Emit session_start event onShutdown: () => {
await extensionRunner.emit({ shutdownState.requested = true;
type: "session_start", },
}); uiContext: rpcUiContext,
} });
// Output all agent events as JSON // Output all agent events as JSON
session.subscribe(event => { session.subscribe(event => {
@@ -850,8 +777,8 @@ export async function runRpcMode(
async function checkShutdownRequested(): Promise<void> { async function checkShutdownRequested(): Promise<void> {
if (!shutdownState.requested) return; if (!shutdownState.requested) return;
if (extensionRunner?.hasHandlers("session_shutdown")) { if (session.extensionRunner?.hasHandlers("session_shutdown")) {
await extensionRunner.emit({ type: "session_shutdown" }); await session.extensionRunner.emit({ type: "session_shutdown" });
} }
process.exit(0); process.exit(0);
@@ -0,0 +1,115 @@
/**
* Shared extension runtime wiring for print and RPC modes.
*
* Both modes initialize the extension runner with the same action handlers
* that delegate to the {@link AgentSession}. Only error reporting, shutdown
* behavior, and UI context differ between callers — those stay as
* caller-supplied hooks.
*/
import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
import type { ExtensionError, ExtensionUIContext } from "../extensibility/extensions/types";
import type { AgentSession } from "../session/agent-session";
/** Action name for an extension-originated send failure. */
export type ExtensionSendAction = "extension_send" | "extension_send_user";
export interface InitializeExtensionsOptions {
/** Reports an error thrown by an extension-initiated send. */
reportSendError: (action: ExtensionSendAction, error: Error) => void;
/** Reports a runtime error surfaced through {@link ExtensionRunner.onError}. */
reportRuntimeError: (error: ExtensionError) => void;
/** Optional shutdown hook (rpc mode signals its loop; print mode is a no-op). */
onShutdown?: () => void;
/** Optional UI context (rpc supplies one; print runs headless). */
uiContext?: ExtensionUIContext;
}
/**
* Initialize the session's extension runner with the standard action set
* shared by non-interactive modes, then emit `session_start`.
*
* No-op when the session was constructed without an extension runner.
*/
export async function initializeExtensions(session: AgentSession, options: InitializeExtensionsOptions): Promise<void> {
const runner = session.extensionRunner;
if (!runner) return;
const { reportSendError, reportRuntimeError, onShutdown, uiContext } = options;
const shutdown = onShutdown ?? (() => {});
runner.initialize(
// ExtensionActions
{
sendMessage: (message, sendOptions) => {
session.sendCustomMessage(message, sendOptions).catch(e => {
reportSendError("extension_send", e instanceof Error ? e : new Error(String(e)));
});
},
sendUserMessage: (content, sendOptions) => {
session.sendUserMessage(content, sendOptions).catch(e => {
reportSendError("extension_send_user", e instanceof Error ? e : new Error(String(e)));
});
},
appendEntry: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},
setLabel: (targetId, label) => {
session.sessionManager.appendLabelChange(targetId, label);
},
getActiveTools: () => session.getActiveToolNames(),
getAllTools: () => session.getAllToolNames(),
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
getCommands: () => [],
setModel: model => runExtensionSetModel(session, model),
getThinkingLevel: () => session.thinkingLevel,
setThinkingLevel: level => session.setThinkingLevel(level),
getSessionName: () => session.sessionManager.getSessionName(),
setSessionName: async name => {
await session.sessionManager.setSessionName(name, "user");
},
},
// ExtensionContextActions
{
getModel: () => session.model,
isIdle: () => !session.isStreaming,
abort: () => session.abort(),
hasPendingMessages: () => session.queuedMessageCount > 0,
shutdown,
getContextUsage: () => session.getContextUsage(),
getSystemPrompt: () => session.systemPrompt,
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
},
// ExtensionCommandContextActions — commands invokable via prompt("/command")
{
getContextUsage: () => session.getContextUsage(),
waitForIdle: () => session.agent.waitForIdle(),
newSession: async newOptions => {
const success = await session.newSession({ parentSession: newOptions?.parentSession });
if (success && newOptions?.setup) {
await newOptions.setup(session.sessionManager);
}
return { cancelled: !success };
},
branch: async entryId => {
const result = await session.branch(entryId);
return { cancelled: result.cancelled };
},
navigateTree: async (targetId, navOptions) => {
const result = await session.navigateTree(targetId, { summarize: navOptions?.summarize });
return { cancelled: result.cancelled };
},
switchSession: async sessionPath => {
const success = await session.switchSession(sessionPath);
return { cancelled: !success };
},
reload: async () => {
await session.reload();
},
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
},
uiContext,
);
runner.onError(reportRuntimeError);
await runner.emit({ type: "session_start" });
}
@@ -2332,12 +2332,12 @@ export function getSymbolTheme(): SymbolTheme {
}; };
} }
let _markdownTheme: MarkdownTheme | undefined; let cachedMarkdownTheme: MarkdownTheme | undefined;
let _markdownThemeRef: Theme | undefined; let cachedMarkdownThemeRef: Theme | undefined;
export function getMarkdownTheme(): MarkdownTheme { export function getMarkdownTheme(): MarkdownTheme {
if (_markdownTheme !== undefined && _markdownThemeRef === theme) { if (cachedMarkdownTheme !== undefined && cachedMarkdownThemeRef === theme) {
return _markdownTheme; return cachedMarkdownTheme;
} }
const markdownTheme: MarkdownTheme = { const markdownTheme: MarkdownTheme = {
heading: (text: string) => theme.fg("mdHeading", text), heading: (text: string) => theme.fg("mdHeading", text),
@@ -2365,8 +2365,8 @@ export function getMarkdownTheme(): MarkdownTheme {
} }
}, },
}; };
_markdownTheme = markdownTheme; cachedMarkdownTheme = markdownTheme;
_markdownThemeRef = theme; cachedMarkdownThemeRef = theme;
return markdownTheme; return markdownTheme;
} }
@@ -245,7 +245,7 @@ export class UiHelpers {
break; break;
} }
default: { default: {
const _exhaustive: never = message; message satisfies never;
} }
} }
return []; return [];
@@ -4684,8 +4684,6 @@ export class AgentSession {
let hookCompaction: CompactionResult | undefined; let hookCompaction: CompactionResult | undefined;
let fromExtension = false; let fromExtension = false;
let hookContext: string[] | undefined;
let hookPrompt: string | undefined;
let preserveData: Record<string, unknown> | undefined; let preserveData: Record<string, unknown> | undefined;
if (this.#extensionRunner?.hasHandlers("session_before_compact")) { if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
@@ -4707,23 +4705,7 @@ export class AgentSession {
} }
} }
if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) { const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
const result = (await this.#extensionRunner.emit({
type: "session.compacting",
sessionId: this.sessionId,
messages: compactMessages,
})) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
hookContext = result?.context;
hookPrompt = result?.prompt;
preserveData = result?.preserveData;
}
const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
if (memoryBackendContext) {
hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
}
let summary: string; let summary: string;
let shortSummary: string | undefined; let shortSummary: string | undefined;
@@ -4731,14 +4713,13 @@ export class AgentSession {
let tokensBefore: number; let tokensBefore: number;
let details: unknown; let details: unknown;
if (hookCompaction) { if (compactionPrep.kind === "fromHook") {
// Extension provided compaction content summary = compactionPrep.summary;
summary = hookCompaction.summary; shortSummary = compactionPrep.shortSummary;
shortSummary = hookCompaction.shortSummary; firstKeptEntryId = compactionPrep.firstKeptEntryId;
firstKeptEntryId = hookCompaction.firstKeptEntryId; tokensBefore = compactionPrep.tokensBefore;
tokensBefore = hookCompaction.tokensBefore; details = compactionPrep.details;
details = hookCompaction.details; preserveData = compactionPrep.preserveData;
preserveData ??= hookCompaction.preserveData;
} else { } else {
// Generate compaction result. Only convert known abort-shaped // Generate compaction result. Only convert known abort-shaped
// rejections (AbortError raised while the abort signal is set, // rejections (AbortError raised while the abort signal is set,
@@ -4757,8 +4738,8 @@ export class AgentSession {
customInstructions, customInstructions,
compactionAbortController.signal, compactionAbortController.signal,
{ {
promptOverride: hookPrompt, promptOverride: compactionPrep.hookPrompt,
extraContext: hookContext, extraContext: compactionPrep.hookContext,
remoteInstructions: this.#baseSystemPrompt.join("\n\n"), remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
}, },
); );
@@ -4767,7 +4748,7 @@ export class AgentSession {
firstKeptEntryId = result.firstKeptEntryId; firstKeptEntryId = result.firstKeptEntryId;
tokensBefore = result.tokensBefore; tokensBefore = result.tokensBefore;
details = result.details; details = result.details;
preserveData = { ...(preserveData ?? {}), ...(result.preserveData ?? {}) }; preserveData = { ...(compactionPrep.preserveData ?? {}), ...(result.preserveData ?? {}) };
} catch (err) { } catch (err) {
if (err instanceof CompactionCancelledError) { if (err instanceof CompactionCancelledError) {
throw err; throw err;
@@ -5727,6 +5708,64 @@ export class AgentSession {
throw this.#buildCompactionAuthError(); throw this.#buildCompactionAuthError();
} }
async #prepareCompactionFromHooks(
preparation: CompactionPreparation,
hookCompaction: CompactionResult | undefined,
): Promise<
| {
kind: "fromHook";
summary: string;
shortSummary: string | undefined;
firstKeptEntryId: string;
tokensBefore: number;
details: unknown;
preserveData: Record<string, unknown> | undefined;
}
| {
kind: "needsLlm";
hookContext: string[] | undefined;
hookPrompt: string | undefined;
preserveData: Record<string, unknown> | undefined;
}
> {
let hookContext: string[] | undefined;
let hookPrompt: string | undefined;
let preserveData: Record<string, unknown> | undefined;
if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) {
const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
const result = (await this.#extensionRunner.emit({
type: "session.compacting",
sessionId: this.sessionId,
messages: compactMessages,
})) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
hookContext = result?.context;
hookPrompt = result?.prompt;
preserveData = result?.preserveData;
}
const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
if (memoryBackendContext) {
hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
}
if (hookCompaction) {
preserveData ??= hookCompaction.preserveData;
return {
kind: "fromHook",
summary: hookCompaction.summary,
shortSummary: hookCompaction.shortSummary,
firstKeptEntryId: hookCompaction.firstKeptEntryId,
tokensBefore: hookCompaction.tokensBefore,
details: hookCompaction.details,
preserveData,
};
}
return { kind: "needsLlm", hookContext, hookPrompt, preserveData };
}
/** /**
* Internal: Run auto-compaction with events. * Internal: Run auto-compaction with events.
*/ */
@@ -5848,8 +5887,6 @@ export class AgentSession {
let hookCompaction: CompactionResult | undefined; let hookCompaction: CompactionResult | undefined;
let fromExtension = false; let fromExtension = false;
let hookContext: string[] | undefined;
let hookPrompt: string | undefined;
let preserveData: Record<string, unknown> | undefined; let preserveData: Record<string, unknown> | undefined;
if (this.#extensionRunner?.hasHandlers("session_before_compact")) { if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
@@ -5878,23 +5915,7 @@ export class AgentSession {
} }
} }
if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) { const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
const result = (await this.#extensionRunner.emit({
type: "session.compacting",
sessionId: this.sessionId,
messages: compactMessages,
})) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
hookContext = result?.context;
hookPrompt = result?.prompt;
preserveData = result?.preserveData;
}
const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
if (memoryBackendContext) {
hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
}
let summary: string; let summary: string;
let shortSummary: string | undefined; let shortSummary: string | undefined;
@@ -5902,14 +5923,13 @@ export class AgentSession {
let tokensBefore: number; let tokensBefore: number;
let details: unknown; let details: unknown;
if (hookCompaction) { if (compactionPrep.kind === "fromHook") {
// Extension provided compaction content summary = compactionPrep.summary;
summary = hookCompaction.summary; shortSummary = compactionPrep.shortSummary;
shortSummary = hookCompaction.shortSummary; firstKeptEntryId = compactionPrep.firstKeptEntryId;
firstKeptEntryId = hookCompaction.firstKeptEntryId; tokensBefore = compactionPrep.tokensBefore;
tokensBefore = hookCompaction.tokensBefore; details = compactionPrep.details;
details = hookCompaction.details; preserveData = compactionPrep.preserveData;
preserveData ??= hookCompaction.preserveData;
} else { } else {
const candidates = this.#getCompactionModelCandidates(availableModels); const candidates = this.#getCompactionModelCandidates(availableModels);
const retrySettings = this.settings.getGroup("retry"); const retrySettings = this.settings.getGroup("retry");
@@ -5924,8 +5944,8 @@ export class AgentSession {
while (true) { while (true) {
try { try {
compactResult = await compact(preparation, candidate, apiKey, undefined, autoCompactionSignal, { compactResult = await compact(preparation, candidate, apiKey, undefined, autoCompactionSignal, {
promptOverride: hookPrompt, promptOverride: compactionPrep.hookPrompt,
extraContext: hookContext, extraContext: compactionPrep.hookContext,
remoteInstructions: this.#baseSystemPrompt.join("\n\n"), remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
metadata: this.agent.metadataForProvider(candidate.provider), metadata: this.agent.metadataForProvider(candidate.provider),
initiatorOverride: "agent", initiatorOverride: "agent",
@@ -6003,7 +6023,7 @@ export class AgentSession {
firstKeptEntryId = compactResult.firstKeptEntryId; firstKeptEntryId = compactResult.firstKeptEntryId;
tokensBefore = compactResult.tokensBefore; tokensBefore = compactResult.tokensBefore;
details = compactResult.details; details = compactResult.details;
preserveData = { ...(preserveData ?? {}), ...(compactResult.preserveData ?? {}) }; preserveData = { ...(compactionPrep.preserveData ?? {}), ...(compactResult.preserveData ?? {}) };
} }
if (autoCompactionSignal.aborted) { if (autoCompactionSignal.aborted) {
@@ -364,8 +364,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
attribution: m.attribution ?? "agent", attribution: m.attribution ?? "agent",
}; };
default: default:
// biome-ignore lint/correctness/noSwitchDeclarations: fine m satisfies never;
const _exhaustiveCheck: never = m;
return undefined; return undefined;
} }
}) })
+21 -120
View File
@@ -7,33 +7,27 @@ import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox"; import { type Static, Type } from "@sinclair/typebox";
import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { computeLineHash, HL_BODY_SEP } from "../hashline/hash"; import { computeLineHash, HL_BODY_SEP } from "../hashline/hash";
import { InternalUrlRouter } from "../internal-urls";
import type { Theme } from "../modes/theme/theme"; import type { Theme } from "../modes/theme/theme";
import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" }; import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import { resolveFileDisplayMode } from "../utils/file-display-mode"; import { resolveFileDisplayMode } from "../utils/file-display-mode";
import type { ToolSession } from "."; import type { ToolSession } from ".";
import { createFileRecorder, formatResultPath } from "./file-recorder"; import { createFileRecorder, formatResultPath } from "./file-recorder";
import { formatGroupedFiles } from "./grouped-file-output"; import { formatGroupedFiles } from "./grouped-file-output";
import type { OutputMeta } from "./output-meta"; import type { OutputMeta } from "./output-meta";
import { resolveToolSearchScope } from "./path-utils";
import { import {
formatPathRelativeToCwd, appendParseErrorsBulletList,
hasGlobPathChars, createCachedComponent,
normalizePathLikeInput,
parseSearchPath,
partitionExistingPaths,
resolveExplicitSearchPaths,
resolveToCwd,
} from "./path-utils";
import {
dedupeParseErrors, dedupeParseErrors,
formatCodeFrameLine, formatCodeFrameLine,
formatCount, formatCount,
formatEmptyMessage, formatEmptyMessage,
formatErrorMessage, formatErrorMessage,
formatParseErrors, formatParseErrors,
PARSE_ERRORS_LIMIT, formatParseErrorsCountLabel,
PREVIEW_LIMITS, PREVIEW_LIMITS,
splitGroupsByBlankLine,
} from "./render-utils"; } from "./render-utils";
import { queueResolveHandler } from "./resolve"; import { queueResolveHandler } from "./resolve";
import { ToolError } from "./tool-errors"; import { ToolError } from "./tool-errors";
@@ -205,63 +199,12 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
const normalizedRewrites = Object.fromEntries(ops); const normalizedRewrites = Object.fromEntries(ops);
const maxFiles = $envpos("PI_MAX_AST_FILES", 1000); const maxFiles = $envpos("PI_MAX_AST_FILES", 1000);
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd); const scope = await resolveToolSearchScope({
let searchPath: string; rawPaths: params.paths,
let scopePath: string; cwd: this.session.cwd,
let globFilter: string | undefined; internalUrlAction: "rewrite",
let multiTargets: Array<{ basePath: string; glob?: string }> | undefined; });
const rawPaths = params.paths.map(normalizePathLikeInput); const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
if (rawPaths.some(rawPath => rawPath.length === 0)) {
throw new ToolError("`paths` must contain non-empty paths or globs");
}
const internalRouter = InternalUrlRouter.instance();
const resolvedPathInputs: string[] = [];
for (const rawPath of rawPaths) {
if (!internalRouter.canHandle(rawPath)) {
resolvedPathInputs.push(rawPath);
continue;
}
if (hasGlobPathChars(rawPath)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
}
const resource = await internalRouter.resolve(rawPath);
if (!resource.sourcePath) {
throw new ToolError(`Cannot rewrite internal URL without backing file: ${rawPath}`);
}
resolvedPathInputs.push(resource.sourcePath);
}
let effectivePathInputs = resolvedPathInputs;
if (resolvedPathInputs.length > 1) {
const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath);
if (partition.valid.length === 0) {
throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
}
effectivePathInputs = partition.valid;
}
if (effectivePathInputs.length === 1) {
const parsedPath = parseSearchPath(effectivePathInputs[0] ?? ".");
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
globFilter = parsedPath.glob;
scopePath = formatScopePath(searchPath);
} else {
const multiSearchPath = await resolveExplicitSearchPaths(effectivePathInputs, this.session.cwd, globFilter);
if (!multiSearchPath) {
throw new ToolError("`paths` must contain at least one path or glob");
}
searchPath = multiSearchPath.basePath;
globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob;
multiTargets = multiSearchPath.targets;
scopePath = multiSearchPath.scopePath;
}
const resolvedSearchPath = searchPath;
scopePath = scopePath ?? formatScopePath(resolvedSearchPath);
let isDirectory: boolean;
try {
const stat = await Bun.file(resolvedSearchPath).stat();
isDirectory = stat.isDirectory();
} catch {
throw new ToolError(`Path not found: ${scopePath}`);
}
const result = await runAstEditOnce(multiTargets, resolvedSearchPath, globFilter, { const result = await runAstEditOnce(multiTargets, resolvedSearchPath, globFilter, {
rewrites: normalizedRewrites, rewrites: normalizedRewrites,
@@ -502,15 +445,7 @@ export const astEditToolRenderer = {
if (filesSearched > 0) meta.push(`searched ${filesSearched}`); if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
const header = renderStatusLine({ icon: "warning", title: "AST Edit", description, meta }, uiTheme); const header = renderStatusLine({ icon: "warning", title: "AST Edit", description, meta }, uiTheme);
const lines = [header, formatEmptyMessage("No replacements made", uiTheme)]; const lines = [header, formatEmptyMessage("No replacements made", uiTheme)];
if (details?.parseErrors?.length) { appendParseErrorsBulletList(lines, details?.parseErrors, uiTheme);
const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT);
for (const err of capped) {
lines.push(uiTheme.fg("warning", ` - ${err}`));
}
if (details.parseErrors.length > PARSE_ERRORS_LIMIT) {
lines.push(uiTheme.fg("dim", ` … ${details.parseErrors.length - PARSE_ERRORS_LIMIT} more`));
}
}
return new Text(lines.join("\n"), 0, 0); return new Text(lines.join("\n"), 0, 0);
} }
@@ -523,28 +458,7 @@ export const astEditToolRenderer = {
const description = rewriteCount === 1 ? args?.ops?.[0]?.pat : undefined; const description = rewriteCount === 1 ? args?.ops?.[0]?.pat : undefined;
const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? ""; const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
const rawLines = textContent.split("\n"); const allGroups = splitGroupsByBlankLine(textContent.split("\n"));
const hasSeparators = rawLines.some(line => line.trim().length === 0);
const allGroups: string[][] = [];
if (hasSeparators) {
let current: string[] = [];
for (const line of rawLines) {
if (line.trim().length === 0) {
if (current.length > 0) {
allGroups.push(current);
current = [];
}
continue;
}
current.push(line);
}
if (current.length > 0) allGroups.push(current);
} else {
const nonEmpty = rawLines.filter(line => line.trim().length > 0);
if (nonEmpty.length > 0) {
allGroups.push(nonEmpty);
}
}
const changeGroups = allGroups.filter( const changeGroups = allGroups.filter(
group => !group[0]?.startsWith("Safety cap reached") && !group[0]?.startsWith("Parse issues:"), group => !group[0]?.startsWith("Safety cap reached") && !group[0]?.startsWith("Parse issues:"),
); );
@@ -560,23 +474,15 @@ export const astEditToolRenderer = {
extraLines.push(uiTheme.fg("warning", "limit reached; narrow path")); extraLines.push(uiTheme.fg("warning", "limit reached; narrow path"));
} }
if (details?.parseErrors?.length) { if (details?.parseErrors?.length) {
const total = details.parseErrors.length; extraLines.push(uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors)));
const label =
total > PARSE_ERRORS_LIMIT
? `${PARSE_ERRORS_LIMIT} / ${total} parse issues`
: `${total} parse issue${total !== 1 ? "s" : ""}`;
extraLines.push(uiTheme.fg("warning", label));
} }
let cached: RenderCache | undefined; return createCachedComponent(
return { () => options.expanded,
render(width: number): string[] { width => {
const { expanded } = options;
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
const changeLines = renderTreeList( const changeLines = renderTreeList(
{ {
items: changeGroups, items: changeGroups,
expanded, expanded: options.expanded,
maxCollapsed: changeGroups.length, maxCollapsed: changeGroups.length,
maxCollapsedLines: COLLAPSED_CHANGE_LIMIT, maxCollapsedLines: COLLAPSED_CHANGE_LIMIT,
itemType: "change", itemType: "change",
@@ -591,14 +497,9 @@ export const astEditToolRenderer = {
}, },
uiTheme, uiTheme,
); );
const rendered = [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); return [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
cached = { key, lines: rendered };
return rendered;
}, },
invalidate() { );
cached = undefined;
},
};
}, },
mergeCallAndResult: true, mergeCallAndResult: true,
}; };
+21 -119
View File
@@ -6,34 +6,28 @@ import { Text } from "@oh-my-pi/pi-tui";
import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox"; import { type Static, Type } from "@sinclair/typebox";
import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { InternalUrlRouter } from "../internal-urls";
import type { Theme } from "../modes/theme/theme"; import type { Theme } from "../modes/theme/theme";
import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" }; import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" };
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import { resolveFileDisplayMode } from "../utils/file-display-mode"; import { resolveFileDisplayMode } from "../utils/file-display-mode";
import type { ToolSession } from "."; import type { ToolSession } from ".";
import { createFileRecorder, formatResultPath } from "./file-recorder"; import { createFileRecorder, formatResultPath } from "./file-recorder";
import { formatGroupedFiles } from "./grouped-file-output"; import { formatGroupedFiles } from "./grouped-file-output";
import { formatMatchLine } from "./match-line-format"; import { formatMatchLine } from "./match-line-format";
import type { OutputMeta } from "./output-meta"; import type { OutputMeta } from "./output-meta";
import { resolveToolSearchScope } from "./path-utils";
import { import {
formatPathRelativeToCwd, appendParseErrorsBulletList,
hasGlobPathChars, createCachedComponent,
normalizePathLikeInput,
parseSearchPath,
partitionExistingPaths,
resolveExplicitSearchPaths,
resolveToCwd,
} from "./path-utils";
import {
dedupeParseErrors, dedupeParseErrors,
formatCodeFrameLine, formatCodeFrameLine,
formatCount, formatCount,
formatEmptyMessage, formatEmptyMessage,
formatErrorMessage, formatErrorMessage,
formatParseErrors, formatParseErrors,
PARSE_ERRORS_LIMIT, formatParseErrorsCountLabel,
PREVIEW_LIMITS, PREVIEW_LIMITS,
splitGroupsByBlankLine,
} from "./render-utils"; } from "./render-utils";
import { ToolError } from "./tool-errors"; import { ToolError } from "./tool-errors";
import { toolResult } from "./tool-result"; import { toolResult } from "./tool-result";
@@ -150,64 +144,12 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
if (!Number.isFinite(skip) || skip < 0) { if (!Number.isFinite(skip) || skip < 0) {
throw new ToolError("skip must be a non-negative number"); throw new ToolError("skip must be a non-negative number");
} }
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd); const scope = await resolveToolSearchScope({
let searchPath: string; rawPaths: params.paths,
let scopePath: string; cwd: this.session.cwd,
let globFilter: string | undefined; internalUrlAction: "search",
let multiTargets: Array<{ basePath: string; glob?: string }> | undefined; });
const rawPaths = params.paths.map(normalizePathLikeInput); const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
if (rawPaths.some(rawPath => rawPath.length === 0)) {
throw new ToolError("`paths` must contain non-empty paths or globs");
}
const internalRouter = InternalUrlRouter.instance();
const resolvedPathInputs: string[] = [];
for (const rawPath of rawPaths) {
if (!internalRouter.canHandle(rawPath)) {
resolvedPathInputs.push(rawPath);
continue;
}
if (hasGlobPathChars(rawPath)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
}
const resource = await internalRouter.resolve(rawPath);
if (!resource.sourcePath) {
throw new ToolError(`Cannot search internal URL without backing file: ${rawPath}`);
}
resolvedPathInputs.push(resource.sourcePath);
}
let effectivePathInputs = resolvedPathInputs;
if (resolvedPathInputs.length > 1) {
const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath);
if (partition.valid.length === 0) {
throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
}
effectivePathInputs = partition.valid;
}
if (effectivePathInputs.length === 1) {
const parsedPath = parseSearchPath(effectivePathInputs[0] ?? ".");
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
globFilter = parsedPath.glob;
scopePath = formatScopePath(searchPath);
} else {
const multiSearchPath = await resolveExplicitSearchPaths(effectivePathInputs, this.session.cwd, globFilter);
if (!multiSearchPath) {
throw new ToolError("`paths` must contain at least one path or glob");
}
searchPath = multiSearchPath.basePath;
globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob;
multiTargets = multiSearchPath.targets;
scopePath = multiSearchPath.scopePath;
}
const resolvedSearchPath = searchPath;
scopePath = scopePath ?? formatScopePath(resolvedSearchPath);
let isDirectory: boolean;
try {
const stat = await Bun.file(resolvedSearchPath).stat();
isDirectory = stat.isDirectory();
} catch {
throw new ToolError(`Path not found: ${scopePath}`);
}
const DEFAULT_AST_LIMIT = 50; const DEFAULT_AST_LIMIT = 50;
const result = multiTargets const result = multiTargets
@@ -388,13 +330,7 @@ export const astGrepToolRenderer = {
const lines = [header, formatEmptyMessage("No matches found", uiTheme)]; const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
if (details?.parseErrors?.length) { if (details?.parseErrors?.length) {
lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `paths` before concluding absence")); lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `paths` before concluding absence"));
const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT); appendParseErrorsBulletList(lines, details.parseErrors, uiTheme);
for (const err of capped) {
lines.push(uiTheme.fg("warning", ` - ${err}`));
}
if (details.parseErrors.length > PARSE_ERRORS_LIMIT) {
lines.push(uiTheme.fg("dim", ` … ${details.parseErrors.length - PARSE_ERRORS_LIMIT} more`));
}
} }
return new Text(lines.join("\n"), 0, 0); return new Text(lines.join("\n"), 0, 0);
} }
@@ -411,28 +347,7 @@ export const astGrepToolRenderer = {
); );
const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? ""; const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
const rawLines = textContent.split("\n"); const allGroups = splitGroupsByBlankLine(textContent.split("\n"));
const hasSeparators = rawLines.some(line => line.trim().length === 0);
const allGroups: string[][] = [];
if (hasSeparators) {
let current: string[] = [];
for (const line of rawLines) {
if (line.trim().length === 0) {
if (current.length > 0) {
allGroups.push(current);
current = [];
}
continue;
}
current.push(line);
}
if (current.length > 0) allGroups.push(current);
} else {
const nonEmpty = rawLines.filter(line => line.trim().length > 0);
if (nonEmpty.length > 0) {
allGroups.push(nonEmpty);
}
}
const matchGroups = allGroups.filter( const matchGroups = allGroups.filter(
group => !group[0]?.startsWith("Result limit reached") && !group[0]?.startsWith("Parse issues:"), group => !group[0]?.startsWith("Result limit reached") && !group[0]?.startsWith("Parse issues:"),
); );
@@ -442,24 +357,16 @@ export const astGrepToolRenderer = {
extraLines.push(uiTheme.fg("warning", "limit reached; narrow paths or increase limit")); extraLines.push(uiTheme.fg("warning", "limit reached; narrow paths or increase limit"));
} }
if (details?.parseErrors?.length) { if (details?.parseErrors?.length) {
const total = details.parseErrors.length; extraLines.push(uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors)));
const label =
total > PARSE_ERRORS_LIMIT
? `${PARSE_ERRORS_LIMIT} / ${total} parse issues`
: `${total} parse issue${total !== 1 ? "s" : ""}`;
extraLines.push(uiTheme.fg("warning", label));
} }
let cached: RenderCache | undefined; return createCachedComponent(
return { () => options.expanded,
render(width: number): string[] { width => {
const { expanded } = options;
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
const matchLines = renderTreeList( const matchLines = renderTreeList(
{ {
items: matchGroups, items: matchGroups,
expanded, expanded: options.expanded,
maxCollapsed: matchGroups.length, maxCollapsed: matchGroups.length,
maxCollapsedLines: COLLAPSED_MATCH_LIMIT, maxCollapsedLines: COLLAPSED_MATCH_LIMIT,
itemType: "match", itemType: "match",
@@ -473,14 +380,9 @@ export const astGrepToolRenderer = {
}, },
uiTheme, uiTheme,
); );
const rendered = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
cached = { key, lines: rendered };
return rendered;
}, },
invalidate() { );
cached = undefined;
},
};
}, },
mergeCallAndResult: true, mergeCallAndResult: true,
}; };
@@ -3,7 +3,7 @@ import { Process, ProcessStatus } from "@oh-my-pi/pi-natives";
import type { Browser, Page } from "puppeteer-core"; import type { Browser, Page } from "puppeteer-core";
import { ToolError, throwIfAborted } from "../tool-errors"; import { ToolError, throwIfAborted } from "../tool-errors";
export const ATTACH_TARGET_SKIP_PATTERN = const ATTACH_TARGET_SKIP_PATTERN =
/request[\s_-]?handler|devtools|background[\s_-]?(?:page|host)|service[\s_-]?worker/i; /request[\s_-]?handler|devtools|background[\s_-]?(?:page|host)|service[\s_-]?worker/i;
/** /**
@@ -62,7 +62,7 @@ export async function waitForCdp(cdpUrl: string, timeoutMs: number, signal?: Abo
* accepts both `--flag=value` and `--flag value`). Returns null if absent or * accepts both `--flag=value` and `--flag value`). Returns null if absent or
* malformed. * malformed.
*/ */
export function findCdpPortInArgs(args: string[]): number | null { function findCdpPortInArgs(args: string[]): number | null {
for (const arg of args) { for (const arg of args) {
const m = /^--remote-debugging-port=(\d+)$/.exec(arg); const m = /^--remote-debugging-port=(\d+)$/.exec(arg);
if (m) { if (m) {
@@ -80,7 +80,7 @@ export function findCdpPortInArgs(args: string[]): number | null {
} }
/** One-shot probe: returns true when `/json/version` answers 200 within the timeout. */ /** One-shot probe: returns true when `/json/version` answers 200 within the timeout. */
export async function probeCdpAt(port: number, signal?: AbortSignal): Promise<boolean> { async function probeCdpAt(port: number, signal?: AbortSignal): Promise<boolean> {
const probeTimeout = AbortSignal.timeout(1500); const probeTimeout = AbortSignal.timeout(1500);
const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout; const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout;
try { try {
@@ -30,12 +30,12 @@ export const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1
* connection dropped, etc.). * connection dropped, etc.).
*/ */
export const BROWSER_PROTOCOL_TIMEOUT_MS = 60_000; export const BROWSER_PROTOCOL_TIMEOUT_MS = 60_000;
export const STEALTH_IGNORE_DEFAULT_ARGS = [ const STEALTH_IGNORE_DEFAULT_ARGS = [
"--disable-extensions", "--disable-extensions",
"--disable-default-apps", "--disable-default-apps",
"--disable-component-extensions-with-background-pages", "--disable-component-extensions-with-background-pages",
]; ];
export const STEALTH_ACCEPT_LANGUAGE = "en-US,en"; const STEALTH_ACCEPT_LANGUAGE = "en-US,en";
const PUPPETEER_SOURCE_URL_SUFFIX = "//# sourceURL=__puppeteer_evaluation_script__"; const PUPPETEER_SOURCE_URL_SUFFIX = "//# sourceURL=__puppeteer_evaluation_script__";
@@ -82,7 +82,7 @@ export async function loadPuppeteerInWorker(safeDir: string): Promise<typeof Pup
* The browser is cached under ~/.omp/puppeteer (getPuppeteerDir). * The browser is cached under ~/.omp/puppeteer (getPuppeteerDir).
*/ */
let chromiumExecutablePromise: Promise<string | undefined> | undefined; let chromiumExecutablePromise: Promise<string | undefined> | undefined;
export async function ensureChromiumExecutable(): Promise<string | undefined> { async function ensureChromiumExecutable(): Promise<string | undefined> {
const sysChrome = resolveSystemChromium(); const sysChrome = resolveSystemChromium();
if (sysChrome) return sysChrome; if (sysChrome) return sysChrome;
const envPath = process.env.PUPPETEER_EXECUTABLE_PATH; const envPath = process.env.PUPPETEER_EXECUTABLE_PATH;
@@ -138,7 +138,7 @@ export async function ensureChromiumExecutable(): Promise<string | undefined> {
return chromiumExecutablePromise; return chromiumExecutablePromise;
} }
let _resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found let resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found
function isExecutableFile(p: string): boolean { function isExecutableFile(p: string): boolean {
try { try {
@@ -209,19 +209,19 @@ function systemChromiumCandidates(): string[] {
return candidates; return candidates;
} }
export function resolveSystemChromium(): string | undefined { function resolveSystemChromium(): string | undefined {
if (_resolvedChromium !== undefined) return _resolvedChromium ?? undefined; if (resolvedChromium !== undefined) return resolvedChromium ?? undefined;
const seen = new Set<string>(); const seen = new Set<string>();
for (const candidate of systemChromiumCandidates()) { for (const candidate of systemChromiumCandidates()) {
if (!candidate || seen.has(candidate)) continue; if (!candidate || seen.has(candidate)) continue;
seen.add(candidate); seen.add(candidate);
if (isExecutableFile(candidate)) { if (isExecutableFile(candidate)) {
_resolvedChromium = candidate; resolvedChromium = candidate;
logger.debug("Using system Chrome/Chromium", { path: candidate }); logger.debug("Using system Chrome/Chromium", { path: candidate });
return candidate; return candidate;
} }
} }
_resolvedChromium = null; resolvedChromium = null;
return undefined; return undefined;
} }
@@ -26,10 +26,6 @@ export interface BrowserHandle {
const browsers = new Map<string, BrowserHandle>(); const browsers = new Map<string, BrowserHandle>();
export function listBrowsers(): BrowserHandle[] {
return [...browsers.values()];
}
function browserKey(kind: BrowserKind): string { function browserKey(kind: BrowserKind): string {
switch (kind.kind) { switch (kind.kind) {
case "headless": case "headless":
@@ -166,7 +162,7 @@ export async function releaseBrowser(handle: BrowserHandle, opts: { kill: boolea
} }
} }
export async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> { async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
if (handle.kind.kind === "headless") { if (handle.kind.kind === "headless") {
if (handle.browser.connected) { if (handle.browser.connected) {
try { try {
@@ -89,10 +89,6 @@ export function getTab(name: string): TabSession | undefined {
return tabs.get(name); return tabs.get(name);
} }
export function listTabs(): TabSession[] {
return [...tabs.values()];
}
export async function acquireTab( export async function acquireTab(
name: string, name: string,
browser: BrowserHandle, browser: BrowserHandle,
+20 -38
View File
@@ -12,15 +12,7 @@ import { InternalUrlRouter } from "../internal-urls";
import type { Theme } from "../modes/theme/theme"; import type { Theme } from "../modes/theme/theme";
import findDescription from "../prompts/tools/find.md" with { type: "text" }; import findDescription from "../prompts/tools/find.md" with { type: "text" };
import { type TruncationResult, truncateHead } from "../session/streaming-output"; import { type TruncationResult, truncateHead } from "../session/streaming-output";
import { import { Ellipsis, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
Ellipsis,
Hasher,
type RenderCache,
renderFileList,
renderStatusLine,
renderTreeList,
truncateToWidth,
} from "../tui";
import type { ToolSession } from "."; import type { ToolSession } from ".";
import { applyListLimit } from "./list-limit"; import { applyListLimit } from "./list-limit";
import { formatFullOutputReference, type OutputMeta } from "./output-meta"; import { formatFullOutputReference, type OutputMeta } from "./output-meta";
@@ -33,7 +25,13 @@ import {
resolveExplicitFindPatterns, resolveExplicitFindPatterns,
resolveToCwd, resolveToCwd,
} from "./path-utils"; } from "./path-utils";
import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils"; import {
createCachedComponent,
formatCount,
formatEmptyMessage,
formatErrorMessage,
PREVIEW_LIMITS,
} from "./render-utils";
import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors"; import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
import { toolResult } from "./tool-result"; import { toolResult } from "./tool-result";
@@ -401,30 +399,22 @@ export const findToolRenderer = {
}, },
uiTheme, uiTheme,
); );
let cached: RenderCache | undefined; return createCachedComponent(
return { () => options.expanded,
render(width: number): string[] { width => {
const { expanded } = options;
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
const listLines = renderTreeList( const listLines = renderTreeList(
{ {
items: lines, items: lines,
expanded, expanded: options.expanded,
maxCollapsed: COLLAPSED_LIST_LIMIT, maxCollapsed: COLLAPSED_LIST_LIMIT,
itemType: "file", itemType: "file",
renderItem: line => uiTheme.fg("accent", line), renderItem: line => uiTheme.fg("accent", line),
}, },
uiTheme, uiTheme,
); );
const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
cached = { key, lines: result };
return result;
}, },
invalidate() { );
cached = undefined;
},
};
} }
const fileCount = details?.fileCount ?? 0; const fileCount = details?.fileCount ?? 0;
@@ -467,28 +457,20 @@ export const findToolRenderer = {
} }
if (missingNote) extraLines.push(missingNote); if (missingNote) extraLines.push(missingNote);
let cached: RenderCache | undefined; return createCachedComponent(
return { () => options.expanded,
render(width: number): string[] { width => {
const { expanded } = options;
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
const fileLines = renderFileList( const fileLines = renderFileList(
{ {
files: files.map(entry => ({ path: entry, isDirectory: entry.endsWith("/") })), files: files.map(entry => ({ path: entry, isDirectory: entry.endsWith("/") })),
expanded, expanded: options.expanded,
maxCollapsed: COLLAPSED_LIST_LIMIT, maxCollapsed: COLLAPSED_LIST_LIMIT,
}, },
uiTheme, uiTheme,
); );
const result = [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); return [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
cached = { key, lines: result };
return result;
}, },
invalidate() { );
cached = undefined;
},
};
}, },
mergeCallAndResult: true, mergeCallAndResult: true,
}; };
@@ -1,7 +1,8 @@
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { type Api, type AssistantMessage, completeSimple, type Model } from "@oh-my-pi/pi-ai"; import { type Api, completeSimple, type Model } from "@oh-my-pi/pi-ai";
import { prompt } from "@oh-my-pi/pi-utils"; import { prompt } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox"; import { type Static, Type } from "@sinclair/typebox";
import { extractTextContent } from "../commit/utils";
import { expandRoleAlias, resolveModelFromString } from "../config/model-resolver"; import { expandRoleAlias, resolveModelFromString } from "../config/model-resolver";
import inspectImageDescription from "../prompts/tools/inspect-image.md" with { type: "text" }; import inspectImageDescription from "../prompts/tools/inspect-image.md" with { type: "text" };
import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-system.md" with { type: "text" }; import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-system.md" with { type: "text" };
@@ -30,14 +31,6 @@ export interface InspectImageToolDetails {
mimeType: string; mimeType: string;
} }
function extractResponseText(message: AssistantMessage): string {
return message.content
.filter(content => content.type === "text")
.map(content => content.text)
.join("")
.trim();
}
export class InspectImageTool implements AgentTool<typeof inspectImageSchema, InspectImageToolDetails> { export class InspectImageTool implements AgentTool<typeof inspectImageSchema, InspectImageToolDetails> {
readonly name = "inspect_image"; readonly name = "inspect_image";
readonly label = "InspectImage"; readonly label = "InspectImage";
@@ -151,7 +144,7 @@ export class InspectImageTool implements AgentTool<typeof inspectImageSchema, In
throw new ToolError("inspect_image request aborted."); throw new ToolError("inspect_image request aborted.");
} }
const text = extractResponseText(response); const text = extractTextContent(response);
if (!text) { if (!text) {
throw new ToolError("inspect_image model returned no text output."); throw new ToolError("inspect_image model returned no text output.");
} }
@@ -3,6 +3,8 @@ import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import * as url from "node:url"; import * as url from "node:url";
import { isEnoent } from "@oh-my-pi/pi-utils"; import { isEnoent } from "@oh-my-pi/pi-utils";
import { InternalUrlRouter } from "../internal-urls";
import { ToolError } from "./tool-errors";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
const FILE_LINE_RANGE_RE = /^(?:L?\d+(?:[-+]L?\d+)?|raw|conflicts)$/i; const FILE_LINE_RANGE_RE = /^(?:L?\d+(?:[-+]L?\d+)?|raw|conflicts)$/i;
@@ -567,3 +569,124 @@ export function resolveReadPath(filePath: string, cwd: string): string {
return resolved; return resolved;
} }
// =============================================================================
// Tool-scope resolution (search/ast tools)
// =============================================================================
export interface ToolScopeOptions {
rawPaths: string[];
cwd: string;
/** Verb used in the "Cannot {action} internal URL without a backing file: …" message. */
internalUrlAction: string;
/** Collect absolute paths flagged immutable by their internal-URL handler. */
trackImmutableSources?: boolean;
/** Honor `exactFilePaths` from {@link resolveExplicitSearchPaths} (search-only). */
surfaceExactFilePaths?: boolean;
/** Extra hint appended to "Path not found" when stat fails and the user supplied multiple paths. */
multipathStatHint?: string;
}
export interface ToolScopeResolution {
searchPath: string;
scopePath: string;
globFilter: string | undefined;
isDirectory: boolean;
multiTargets?: ResolvedSearchTarget[];
exactFilePaths?: string[];
missingPaths: string[];
immutableSourcePaths: Set<string>;
}
/**
* Shared path-input pipeline for `search`, `ast_grep`, and `ast_edit`:
* 1. normalize + reject empty paths,
* 2. resolve internal URLs through {@link InternalUrlRouter} to backing files,
* 3. partition existing vs missing when multiple paths are supplied,
* 4. derive a single search base path / glob, or a multi-target list,
* 5. stat the resolved base path so callers can branch on directory vs file scope.
*/
export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<ToolScopeResolution> {
const { rawPaths: inputs, cwd, internalUrlAction } = opts;
const rawPaths = inputs.map(normalizePathLikeInput);
if (rawPaths.some(rawPath => rawPath.length === 0)) {
throw new ToolError("`paths` must contain non-empty paths or globs");
}
const internalRouter = InternalUrlRouter.instance();
const resolvedPathInputs: string[] = [];
const immutableSourcePaths = new Set<string>();
for (const rawPath of rawPaths) {
if (!internalRouter.canHandle(rawPath)) {
resolvedPathInputs.push(rawPath);
continue;
}
if (hasGlobPathChars(rawPath)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
}
const resource = await internalRouter.resolve(rawPath);
if (!resource.sourcePath) {
throw new ToolError(`Cannot ${internalUrlAction} internal URL without a backing file: ${rawPath}`);
}
if (opts.trackImmutableSources && resource.immutable) {
immutableSourcePaths.add(path.resolve(resource.sourcePath));
}
resolvedPathInputs.push(resource.sourcePath);
}
let missingPaths: string[] = [];
let effectivePaths = resolvedPathInputs;
if (resolvedPathInputs.length > 1) {
const partition = await partitionExistingPaths(resolvedPathInputs, cwd, parseSearchPath);
if (partition.valid.length === 0) {
throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
}
effectivePaths = partition.valid;
missingPaths = partition.missing;
}
let searchPath: string;
let scopePath: string;
let globFilter: string | undefined;
let multiTargets: ResolvedSearchTarget[] | undefined;
let exactFilePaths: string[] | undefined;
if (effectivePaths.length === 1) {
const parsedPath = parseSearchPath(effectivePaths[0] ?? ".");
searchPath = resolveToCwd(parsedPath.basePath, cwd);
globFilter = parsedPath.glob;
scopePath = formatPathRelativeToCwd(searchPath, cwd);
} else {
const multiSearchPath = await resolveExplicitSearchPaths(effectivePaths, cwd);
if (!multiSearchPath) {
throw new ToolError("`paths` must contain at least one path or glob");
}
searchPath = multiSearchPath.basePath;
multiTargets = multiSearchPath.targets;
if (opts.surfaceExactFilePaths) {
exactFilePaths = multiSearchPath.exactFilePaths;
globFilter = exactFilePaths || multiTargets ? undefined : multiSearchPath.glob;
} else {
globFilter = multiTargets ? undefined : multiSearchPath.glob;
}
scopePath = multiSearchPath.scopePath;
}
let isDirectory: boolean;
try {
const stat = await Bun.file(searchPath).stat();
isDirectory = stat.isDirectory();
} catch {
const hint = opts.multipathStatHint && rawPaths.length > 1 ? opts.multipathStatHint : "";
throw new ToolError(`Path not found: ${scopePath}${hint}`);
}
return {
searchPath,
scopePath,
globFilter,
isDirectory,
multiTargets,
exactFilePaths,
missingPaths,
immutableSourcePaths,
};
}
-2
View File
@@ -1321,8 +1321,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
const imageMetadata = await readImageMetadata(absolutePath); const imageMetadata = await readImageMetadata(absolutePath);
const mimeType = imageMetadata?.mimeType; const mimeType = imageMetadata?.mimeType;
const ext = path.extname(absolutePath).toLowerCase(); const ext = path.extname(absolutePath).toLowerCase();
const _hasEditTool = this.session.hasEditTool ?? true;
const _language = getLanguageFromPath(absolutePath);
const shouldConvertWithMarkit = CONVERTIBLE_EXTENSIONS.has(ext); const shouldConvertWithMarkit = CONVERTIBLE_EXTENSIONS.has(ext);
// Read the file based on type // Read the file based on type
let content: Array<TextContent | ImageContent> | undefined; let content: Array<TextContent | ImageContent> | undefined;
@@ -9,10 +9,12 @@ import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import type { ToolCallContext } from "@oh-my-pi/pi-agent-core"; import type { ToolCallContext } from "@oh-my-pi/pi-agent-core";
import type { Ellipsis } from "@oh-my-pi/pi-natives"; import type { Ellipsis } from "@oh-my-pi/pi-natives";
import type { Component } from "@oh-my-pi/pi-tui";
import { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui"; import { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
import { pluralize } from "@oh-my-pi/pi-utils"; import { pluralize } from "@oh-my-pi/pi-utils";
import { settings } from "../config/settings"; import { settings } from "../config/settings";
import type { Theme } from "../modes/theme/theme"; import type { Theme } from "../modes/theme/theme";
import { Hasher } from "../tui/utils";
import { formatDimensionNote, type ResizedImage } from "../utils/image-resize"; import { formatDimensionNote, type ResizedImage } from "../utils/image-resize";
export { Ellipsis } from "@oh-my-pi/pi-natives"; export { Ellipsis } from "@oh-my-pi/pi-natives";
@@ -642,6 +644,96 @@ export function formatParseErrors(errors: string[]): string[] {
return [header, ...capped.map(err => `- ${err}`)]; return [header, ...capped.map(err => `- ${err}`)];
} }
// =============================================================================
// Renderer helpers shared by search / find / ast tools
// =============================================================================
/**
* Group `rawLines` by blank-line separators, mirroring the historical search /
* ast-grep / ast-edit renderer behavior: if any blank line is present, splits on
* runs of blank lines; otherwise collapses non-empty lines into a single group.
*/
export function splitGroupsByBlankLine(rawLines: string[]): string[][] {
const hasSeparators = rawLines.some(line => line.trim().length === 0);
const groups: string[][] = [];
if (hasSeparators) {
let current: string[] = [];
for (const line of rawLines) {
if (line.trim().length === 0) {
if (current.length > 0) {
groups.push(current);
current = [];
}
continue;
}
current.push(line);
}
if (current.length > 0) groups.push(current);
} else {
const nonEmpty = rawLines.filter(line => line.trim().length > 0);
if (nonEmpty.length > 0) {
groups.push(nonEmpty);
}
}
return groups;
}
/**
* Standard width+expand keyed render cache used by every search-style tool
* renderer. `compute` re-runs only when the cache key changes; the returned
* Component is the canonical `{ render, invalidate }` pair.
*/
export function createCachedComponent(
getExpanded: () => boolean,
compute: (width: number, expanded: boolean) => string[],
): Component {
let cached: { key: bigint; lines: string[] } | undefined;
return {
render(width: number): string[] {
const expanded = getExpanded();
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
const lines = compute(width, expanded);
cached = { key, lines };
return lines;
},
invalidate() {
cached = undefined;
},
};
}
/**
* Append the indented bullet list of parse errors (capped at
* {@link PARSE_ERRORS_LIMIT}) to `lines`, with an overflow summary line if the
* total exceeds the cap. No-op when `parseErrors` is empty.
*/
export function appendParseErrorsBulletList(
lines: string[],
parseErrors: readonly string[] | undefined,
theme: Theme,
): void {
if (!parseErrors || parseErrors.length === 0) return;
const capped = parseErrors.slice(0, PARSE_ERRORS_LIMIT);
for (const err of capped) {
lines.push(theme.fg("warning", ` - ${err}`));
}
if (parseErrors.length > PARSE_ERRORS_LIMIT) {
lines.push(theme.fg("dim", ` … ${parseErrors.length - PARSE_ERRORS_LIMIT} more`));
}
}
/**
* Human-readable summary string for the parse-issues count, capped by
* {@link PARSE_ERRORS_LIMIT}.
*/
export function formatParseErrorsCountLabel(parseErrors: readonly string[]): string {
const total = parseErrors.length;
return total > PARSE_ERRORS_LIMIT
? `${PARSE_ERRORS_LIMIT} / ${total} parse issues`
: `${total} parse issue${total !== 1 ? "s" : ""}`;
}
// ============================================================================= // =============================================================================
// LSP Batching // LSP Batching
// ============================================================================= // =============================================================================
+35 -131
View File
@@ -8,32 +8,25 @@ import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox"; import { type Static, Type } from "@sinclair/typebox";
import { getFileReadCache } from "../edit/file-read-cache"; import { getFileReadCache } from "../edit/file-read-cache";
import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { InternalUrlRouter } from "../internal-urls";
import type { Theme } from "../modes/theme/theme"; import type { Theme } from "../modes/theme/theme";
import searchDescription from "../prompts/tools/search.md" with { type: "text" }; import searchDescription from "../prompts/tools/search.md" with { type: "text" };
import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output"; import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output";
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import { resolveFileDisplayMode } from "../utils/file-display-mode"; import { resolveFileDisplayMode } from "../utils/file-display-mode";
import type { ToolSession } from "."; import type { ToolSession } from ".";
import { createFileRecorder, formatResultPath } from "./file-recorder"; import { createFileRecorder, formatResultPath } from "./file-recorder";
import { formatGroupedFiles } from "./grouped-file-output"; import { formatGroupedFiles } from "./grouped-file-output";
import { formatMatchLine } from "./match-line-format"; import { formatMatchLine } from "./match-line-format";
import { formatFullOutputReference, type OutputMeta } from "./output-meta"; import { formatFullOutputReference, type OutputMeta } from "./output-meta";
import { resolveToolSearchScope } from "./path-utils";
import { import {
formatPathRelativeToCwd, createCachedComponent,
hasGlobPathChars,
normalizePathLikeInput,
parseSearchPath,
partitionExistingPaths,
resolveExplicitSearchPaths,
resolveToCwd,
} from "./path-utils";
import {
formatCodeFrameLine, formatCodeFrameLine,
formatCount, formatCount,
formatEmptyMessage, formatEmptyMessage,
formatErrorMessage, formatErrorMessage,
PREVIEW_LIMITS, PREVIEW_LIMITS,
splitGroupsByBlankLine,
} from "./render-utils"; } from "./render-utils";
import { ToolError } from "./tool-errors"; import { ToolError } from "./tool-errors";
import { toolResult } from "./tool-result"; import { toolResult } from "./tool-result";
@@ -141,78 +134,26 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n"); const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
const effectiveMultiline = patternHasNewline; const effectiveMultiline = patternHasNewline;
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd); const scope = await resolveToolSearchScope({
let searchPath: string; rawPaths: paths,
let scopePath: string; cwd: this.session.cwd,
let exactFilePaths: string[] | undefined; internalUrlAction: "search",
let multiTargets: Array<{ basePath: string; glob?: string }> | undefined; trackImmutableSources: true,
let globFilter: string | undefined; surfaceExactFilePaths: true,
const rawPaths = paths.map(normalizePathLikeInput); multipathStatHint: " (`paths` entries must each exist relative to cwd)",
if (rawPaths.some(rawPath => rawPath.length === 0)) { });
throw new ToolError("`paths` must contain non-empty paths or globs"); const {
} searchPath,
const internalRouter = InternalUrlRouter.instance(); scopePath,
const resolvedPathInputs: string[] = []; isDirectory,
// Absolute filesystem paths whose source is immutable (e.g. artifact://, multiTargets,
// pi://, skill://). Hashline anchors are suppressed for these on a exactFilePaths,
// per-file basis, leaving editable mixed-in files untouched. missingPaths,
const immutableSourcePaths = new Set<string>(); immutableSourcePaths,
for (const rawPath of rawPaths) { } = scope;
if (!internalRouter.canHandle(rawPath)) { const { globFilter } = scope;
resolvedPathInputs.push(rawPath);
continue;
}
if (hasGlobPathChars(rawPath)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
}
const resource = await internalRouter.resolve(rawPath);
if (!resource.sourcePath) {
throw new ToolError(`Cannot search internal URL without a backing file: ${rawPath}`);
}
if (resource.immutable) {
immutableSourcePaths.add(path.resolve(resource.sourcePath));
}
resolvedPathInputs.push(resource.sourcePath);
}
const baseDisplayMode = resolveFileDisplayMode(this.session); const baseDisplayMode = resolveFileDisplayMode(this.session);
const immutableDisplayMode = resolveFileDisplayMode(this.session, { immutable: true }); const immutableDisplayMode = resolveFileDisplayMode(this.session, { immutable: true });
// Tolerate missing entries in a multi-path call: skip ones whose base
// directory is gone, and only error if every entry is missing. Single
// missing path keeps the original ENOENT semantics.
let missingPaths: string[] = [];
let effectivePaths = resolvedPathInputs;
if (resolvedPathInputs.length > 1) {
const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath);
if (partition.valid.length === 0) {
throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
}
effectivePaths = partition.valid;
missingPaths = partition.missing;
}
if (effectivePaths.length === 1) {
const parsedPath = parseSearchPath(effectivePaths[0] ?? ".");
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
globFilter = parsedPath.glob;
scopePath = formatScopePath(searchPath);
} else {
const multiSearchPath = await resolveExplicitSearchPaths(effectivePaths, this.session.cwd, globFilter);
if (!multiSearchPath) {
throw new ToolError("`paths` must contain at least one path or glob");
}
searchPath = multiSearchPath.basePath;
exactFilePaths = multiSearchPath.exactFilePaths;
multiTargets = multiSearchPath.targets;
globFilter = exactFilePaths || multiTargets ? undefined : multiSearchPath.glob;
scopePath = multiSearchPath.scopePath;
}
let isDirectory: boolean;
try {
const stat = await Bun.file(searchPath).stat();
isDirectory = stat.isDirectory();
} catch {
const hint = rawPaths.length > 1 ? " (`paths` entries must each exist relative to cwd)" : "";
throw new ToolError(`Path not found: ${scopePath}${hint}`);
}
const effectiveOutputMode = GrepOutputMode.Content; const effectiveOutputMode = GrepOutputMode.Content;
// Multi-scope = more than one file may match. We fetch up to // Multi-scope = more than one file may match. We fetch up to
@@ -531,16 +472,13 @@ export const searchToolRenderer = {
{ icon: "success", title: "Search", description, meta: [formatCount("item", lines.length)] }, { icon: "success", title: "Search", description, meta: [formatCount("item", lines.length)] },
uiTheme, uiTheme,
); );
let cached: RenderCache | undefined; return createCachedComponent(
return { () => options.expanded,
render(width: number): string[] { width => {
const { expanded } = options;
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
const listLines = renderTreeList( const listLines = renderTreeList(
{ {
items: lines, items: lines,
expanded, expanded: options.expanded,
maxCollapsed: COLLAPSED_TEXT_LIMIT, maxCollapsed: COLLAPSED_TEXT_LIMIT,
maxCollapsedLines: COLLAPSED_TEXT_LIMIT, maxCollapsedLines: COLLAPSED_TEXT_LIMIT,
itemType: "item", itemType: "item",
@@ -548,14 +486,9 @@ export const searchToolRenderer = {
}, },
uiTheme, uiTheme,
); );
const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
cached = { key, lines: result };
return result;
}, },
invalidate() { );
cached = undefined;
},
};
} }
const matchCount = details?.matchCount ?? 0; const matchCount = details?.matchCount ?? 0;
@@ -591,28 +524,7 @@ export const searchToolRenderer = {
); );
const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? ""; const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
const rawLines = textContent.split("\n"); const matchGroups = splitGroupsByBlankLine(textContent.split("\n"));
const hasSeparators = rawLines.some(line => line.trim().length === 0);
const matchGroups: string[][] = [];
if (hasSeparators) {
let current: string[] = [];
for (const line of rawLines) {
if (line.trim().length === 0) {
if (current.length > 0) {
matchGroups.push(current);
current = [];
}
continue;
}
current.push(line);
}
if (current.length > 0) matchGroups.push(current);
} else {
const nonEmpty = rawLines.filter(line => line.trim().length > 0);
if (nonEmpty.length > 0) {
matchGroups.push(nonEmpty);
}
}
const renderedFileLimit = details?.fileLimitReached; const renderedFileLimit = details?.fileLimitReached;
const renderedPerFileLimit = details?.perFileLimitReached; const renderedPerFileLimit = details?.perFileLimitReached;
@@ -629,17 +541,14 @@ export const searchToolRenderer = {
} }
if (missingNote) extraLines.push(missingNote); if (missingNote) extraLines.push(missingNote);
let cached: RenderCache | undefined; return createCachedComponent(
return { () => options.expanded,
render(width: number): string[] { width => {
const { expanded } = options;
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
const collapsedMatchLineBudget = Math.max(COLLAPSED_TEXT_LIMIT - extraLines.length, 0); const collapsedMatchLineBudget = Math.max(COLLAPSED_TEXT_LIMIT - extraLines.length, 0);
const matchLines = renderTreeList( const matchLines = renderTreeList(
{ {
items: matchGroups, items: matchGroups,
expanded, expanded: options.expanded,
maxCollapsed: matchGroups.length, maxCollapsed: matchGroups.length,
maxCollapsedLines: collapsedMatchLineBudget, maxCollapsedLines: collapsedMatchLineBudget,
itemType: "match", itemType: "match",
@@ -652,14 +561,9 @@ export const searchToolRenderer = {
}, },
uiTheme, uiTheme,
); );
const result = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
cached = { key, lines: result };
return result;
}, },
invalidate() { );
cached = undefined;
},
};
}, },
mergeCallAndResult: true, mergeCallAndResult: true,
}; };
@@ -10,6 +10,7 @@ import path from "node:path";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { ImageContent } from "@oh-my-pi/pi-ai"; import type { ImageContent } from "@oh-my-pi/pi-ai";
import { glob } from "@oh-my-pi/pi-natives"; import { glob } from "@oh-my-pi/pi-natives";
import { fuzzyMatch } from "@oh-my-pi/pi-tui";
import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils"; import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils";
import { formatHashLines } from "../hashline/hash"; import { formatHashLines } from "../hashline/hash";
import type { FileMentionMessage } from "../session/messages"; import type { FileMentionMessage } from "../session/messages";
@@ -20,7 +21,6 @@ import {
truncateHeadBytes, truncateHeadBytes,
} from "../session/streaming-output"; } from "../session/streaming-output";
import { resolveReadPath } from "../tools/path-utils"; import { resolveReadPath } from "../tools/path-utils";
import { fuzzyMatch } from "./fuzzy";
import { formatDimensionNote, resizeImage } from "./image-resize"; import { formatDimensionNote, resizeImage } from "./image-resize";
/** Regex to match @filepath patterns in text */ /** Regex to match @filepath patterns in text */
-108
View File
@@ -1,108 +0,0 @@
// Fuzzy search. Matches if all query characters appear in order (not necessarily consecutive).
// Lower score = better match.
export interface FuzzyMatch {
matches: boolean;
score: number;
}
export function fuzzyMatch(query: string, text: string): FuzzyMatch {
const queryLower = query.toLowerCase();
const textLower = text.toLowerCase();
if (queryLower.length === 0) {
return { matches: true, score: 0 };
}
if (queryLower.length > textLower.length) {
return { matches: false, score: 0 };
}
let queryIndex = 0;
let score = 0;
let lastMatchIndex = -1;
let consecutiveMatches = 0;
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
if (textLower[i] === queryLower[queryIndex]) {
const isWordBoundary = i === 0 || /[\s\-_./]/.test(textLower[i - 1]!);
// Reward consecutive character matches (e.g., typing "foo" matches "foobar" better than "f_o_o")
if (lastMatchIndex === i - 1) {
consecutiveMatches++;
score -= consecutiveMatches * 5;
} else {
consecutiveMatches = 0;
// Penalize gaps between matched characters
if (lastMatchIndex >= 0) {
score += (i - lastMatchIndex - 1) * 2;
}
}
// Reward matches at word boundaries (start of words are more likely intentional targets)
if (isWordBoundary) {
score -= 10;
}
// Slight penalty for matches later in the string (prefer earlier matches)
score += i * 0.1;
lastMatchIndex = i;
queryIndex++;
}
}
// Not all query characters were found in order
if (queryIndex < queryLower.length) {
return { matches: false, score: 0 };
}
return { matches: true, score };
}
// Filter and sort items by fuzzy match quality (best matches first)
// Supports space-separated tokens: all tokens must match, sorted by match count then score
export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
if (!query.trim()) {
return items;
}
// Split query into tokens
const tokens = query
.trim()
.split(/\s+/)
.filter(t => t.length > 0);
if (tokens.length === 0) {
return items;
}
const results: { item: T; totalScore: number }[] = [];
for (const item of items) {
const text = getText(item);
let totalScore = 0;
let allMatch = true;
// Check each token against the text - ALL must match
for (const token of tokens) {
const match = fuzzyMatch(token, text);
if (match.matches) {
totalScore += match.score;
} else {
allMatch = false;
break;
}
}
// Only include if all tokens match
if (allMatch) {
results.push({ item, totalScore });
}
}
// Sort by score (asc, lower is better)
results.sort((a, b) => a.totalScore - b.totalScore);
return results.map(r => r.item);
}
-17
View File
@@ -124,10 +124,6 @@ export function parseKeySequences(sequences: string[]): VimKeyToken[] {
return tokens; return tokens;
} }
export function tokensToReplay(tokens: readonly VimKeyToken[]): string[] {
return tokens.map(token => token.value);
}
export function replayTokens(values: readonly string[]): VimKeyToken[] { export function replayTokens(values: readonly string[]): VimKeyToken[] {
return values.map((value, index) => ({ return values.map((value, index) => ({
value, value,
@@ -136,16 +132,3 @@ export function replayTokens(values: readonly string[]): VimKeyToken[] {
offset: index, offset: index,
})); }));
} }
export function formatVimError(error: unknown): string {
if (!(error instanceof VimError)) {
return error instanceof Error ? error.message : String(error);
}
const base = error.message;
if (!error.location) {
return base;
}
return `${base} (sequence ${error.location.sequenceIndex + 1}, token ${error.location.offset + 1})`;
}
+1 -1
View File
@@ -13,7 +13,7 @@ import type {
export const VIM_OPEN_VIEWPORT_LINES = 80; export const VIM_OPEN_VIEWPORT_LINES = 80;
export const VIM_DEFAULT_VIEWPORT_LINES = 10; export const VIM_DEFAULT_VIEWPORT_LINES = 10;
export const VIM_TAB_DISPLAY = "→"; const VIM_TAB_DISPLAY = "→";
const VIM_INLINE_CURSOR = "▏"; const VIM_INLINE_CURSOR = "▏";
const VIM_VIEWPORT_WIDTH = 140; const VIM_VIEWPORT_WIDTH = 140;
+1 -1
View File
@@ -164,7 +164,7 @@ export function clonePosition(position: Position): Position {
return { line: position.line, col: position.col }; return { line: position.line, col: position.col };
} }
export function comparePositions(left: Position, right: Position): number { function comparePositions(left: Position, right: Position): number {
if (left.line !== right.line) { if (left.line !== right.line) {
return left.line - right.line; return left.line - right.line;
} }
+2 -2
View File
@@ -12,7 +12,7 @@ import {
} from "@agentclientprotocol/sdk/dist/schema/zod.gen.js"; } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js";
import type { Model } from "@oh-my-pi/pi-ai"; import type { Model } from "@oh-my-pi/pi-ai";
import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils"; import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils";
import { _resetSettingsForTest, Settings } from "../src/config/settings"; import { resetSettingsForTest, Settings } from "../src/config/settings";
import { AcpAgent } from "../src/modes/acp/acp-agent"; import { AcpAgent } from "../src/modes/acp/acp-agent";
import type { PlanModeState } from "../src/plan-mode/state"; import type { PlanModeState } from "../src/plan-mode/state";
import type { AgentSession, AgentSessionEvent } from "../src/session/agent-session"; import type { AgentSession, AgentSessionEvent } from "../src/session/agent-session";
@@ -316,7 +316,7 @@ afterEach(async () => {
setAgentDir(fallbackAgentDir); setAgentDir(fallbackAgentDir);
delete process.env.PI_CODING_AGENT_DIR; delete process.env.PI_CODING_AGENT_DIR;
} }
_resetSettingsForTest(); resetSettingsForTest();
for (const root of cleanupRoots.splice(0)) { for (const root of cleanupRoots.splice(0)) {
await fs.promises.rm(root, { recursive: true, force: true }); await fs.promises.rm(root, { recursive: true, force: true });
@@ -45,7 +45,7 @@ import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-cor
import { type AssistantMessage, getBundledModel, type ToolCall } from "@oh-my-pi/pi-ai"; import { type AssistantMessage, getBundledModel, type ToolCall } from "@oh-my-pi/pi-ai";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; 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 { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
@@ -165,7 +165,7 @@ describe("BashTool through AgentSession runs children in their own session (e2e)
tempDir = path.join(os.tmpdir(), `pi-bash-detach-test-${Snowflake.next()}`); tempDir = path.join(os.tmpdir(), `pi-bash-detach-test-${Snowflake.next()}`);
fs.mkdirSync(tempDir, { recursive: true }); fs.mkdirSync(tempDir, { recursive: true });
_resetSettingsForTest(); resetSettingsForTest();
// Fresh isolated Settings rooted in tempDir so we don't pick up the // Fresh isolated Settings rooted in tempDir so we don't pick up the
// developer's real config (snapshots, shell prefix, etc). // developer's real config (snapshots, shell prefix, etc).
await Settings.init({ inMemory: true, cwd: tempDir }); await Settings.init({ inMemory: true, cwd: tempDir });
@@ -243,7 +243,7 @@ describe("BashTool through AgentSession runs children in their own session (e2e)
}); });
afterAll(() => { afterAll(() => {
_resetSettingsForTest(); resetSettingsForTest();
}); });
it.skipIf(skip)("spawned child runs as its own session leader, not in the host's session", async () => { it.skipIf(skip)("spawned child runs as its own session leader, not in the host's session", async () => {
@@ -36,15 +36,6 @@ function createAssistantMessage(
}; };
} }
async function _waitFor(predicate: () => boolean, timeoutMs = 1000): 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");
}
type AutoRetryStartEvent = Extract<AgentSessionEvent, { type: "auto_retry_start" }>; type AutoRetryStartEvent = Extract<AgentSessionEvent, { type: "auto_retry_start" }>;
type AutoRetryEndEvent = Extract<AgentSessionEvent, { type: "auto_retry_end" }>; type AutoRetryEndEvent = Extract<AgentSessionEvent, { type: "auto_retry_end" }>;
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller"; import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller";
import { getProjectAgentDir, Snowflake } from "@oh-my-pi/pi-utils"; import { getProjectAgentDir, Snowflake } from "@oh-my-pi/pi-utils";
import { YAML } from "bun"; import { YAML } from "bun";
@@ -13,7 +13,7 @@ describe("autocompleteMaxVisible setting", () => {
let projectDir: string; let projectDir: string;
beforeEach(() => { beforeEach(() => {
_resetSettingsForTest(); resetSettingsForTest();
testDir = path.join(os.tmpdir(), "test-autocomplete-settings", Snowflake.next()); testDir = path.join(os.tmpdir(), "test-autocomplete-settings", Snowflake.next());
agentDir = path.join(testDir, "agent"); agentDir = path.join(testDir, "agent");
projectDir = path.join(testDir, "project"); projectDir = path.join(testDir, "project");
@@ -22,7 +22,7 @@ describe("autocompleteMaxVisible setting", () => {
}); });
afterEach(() => { afterEach(() => {
_resetSettingsForTest(); resetSettingsForTest();
if (fs.existsSync(testDir)) { if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true }); fs.rmSync(testDir, { recursive: true });
} }
@@ -34,7 +34,7 @@ describe("autocompleteMaxVisible setting", () => {
await settings.flush(); await settings.flush();
// Re-init to verify persistence // Re-init to verify persistence
_resetSettingsForTest(); resetSettingsForTest();
const settings2 = await Settings.init({ cwd: projectDir, agentDir }); const settings2 = await Settings.init({ cwd: projectDir, agentDir });
expect(settings2.get("autocompleteMaxVisible")).toBe(10); expect(settings2.get("autocompleteMaxVisible")).toBe(10);
}); });
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { executeBash } from "@oh-my-pi/pi-coding-agent/exec/bash-executor"; import { executeBash } from "@oh-my-pi/pi-coding-agent/exec/bash-executor";
import { DEFAULT_MAX_BYTES } from "@oh-my-pi/pi-coding-agent/session/streaming-output"; import { DEFAULT_MAX_BYTES } from "@oh-my-pi/pi-coding-agent/session/streaming-output";
import * as shellSnapshot from "@oh-my-pi/pi-coding-agent/utils/shell-snapshot"; import * as shellSnapshot from "@oh-my-pi/pi-coding-agent/utils/shell-snapshot";
@@ -20,12 +20,12 @@ describe("executeBash", () => {
beforeEach(async () => { beforeEach(async () => {
tempDir = makeTempDir(); tempDir = makeTempDir();
_resetSettingsForTest(); resetSettingsForTest();
await Settings.init({ inMemory: true, cwd: tempDir }); await Settings.init({ inMemory: true, cwd: tempDir });
}); });
afterEach(() => { afterEach(() => {
_resetSettingsForTest(); resetSettingsForTest();
vi.restoreAllMocks(); vi.restoreAllMocks();
if (fs.existsSync(tempDir)) { if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true }); fs.rmSync(tempDir, { recursive: true });
@@ -4,21 +4,21 @@ import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils"; import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils";
import { runConfigCommand } from "../src/cli/config-cli"; import { runConfigCommand } from "../src/cli/config-cli";
import { _resetSettingsForTest } from "../src/config/settings"; import { resetSettingsForTest } from "../src/config/settings";
let testAgentDir = ""; let testAgentDir = "";
const originalAgentDir = process.env.PI_CODING_AGENT_DIR; const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
const fallbackAgentDir = path.join(getConfigRootDir(), "agent"); const fallbackAgentDir = path.join(getConfigRootDir(), "agent");
beforeEach(async () => { beforeEach(async () => {
_resetSettingsForTest(); resetSettingsForTest();
testAgentDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-config-cli-")); testAgentDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-config-cli-"));
setAgentDir(testAgentDir); setAgentDir(testAgentDir);
}); });
afterEach(async () => { afterEach(async () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
_resetSettingsForTest(); resetSettingsForTest();
if (originalAgentDir) { if (originalAgentDir) {
setAgentDir(originalAgentDir); setAgentDir(originalAgentDir);
} else { } else {
@@ -2,21 +2,21 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { getDefaultTabWidth, getIndentation, Snowflake, setDefaultTabWidth } from "@oh-my-pi/pi-utils"; import { getDefaultTabWidth, getIndentation, Snowflake, setDefaultTabWidth } from "@oh-my-pi/pi-utils";
describe("indentation resolver", () => { describe("indentation resolver", () => {
let tempDir = ""; let tempDir = "";
beforeEach(async () => { beforeEach(async () => {
_resetSettingsForTest(); resetSettingsForTest();
setDefaultTabWidth(3); setDefaultTabWidth(3);
tempDir = path.join(os.tmpdir(), "pi-spacing", Snowflake.next()); tempDir = path.join(os.tmpdir(), "pi-spacing", Snowflake.next());
await fs.mkdir(tempDir, { recursive: true }); await fs.mkdir(tempDir, { recursive: true });
}); });
afterEach(async () => { afterEach(async () => {
_resetSettingsForTest(); resetSettingsForTest();
setDefaultTabWidth(3); setDefaultTabWidth(3);
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
}); });
@@ -2,7 +2,7 @@ import { beforeAll, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { import {
applyHashlineEdits, applyHashlineEdits,
buildCompactHashlineDiffPreview, buildCompactHashlineDiffPreview,
@@ -27,7 +27,7 @@ import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { Value } from "@sinclair/typebox/value"; import { Value } from "@sinclair/typebox/value";
beforeAll(async () => { beforeAll(async () => {
_resetSettingsForTest(); resetSettingsForTest();
await Settings.init({ inMemory: true, cwd: process.cwd() }); await Settings.init({ inMemory: true, cwd: process.cwd() });
}); });
@@ -3,7 +3,7 @@ import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { type ContextFile, contextFileCapability } from "@oh-my-pi/pi-coding-agent/capability/context-file"; import { type ContextFile, contextFileCapability } from "@oh-my-pi/pi-coding-agent/capability/context-file";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { initializeWithSettings, loadCapability } from "@oh-my-pi/pi-coding-agent/discovery"; import { initializeWithSettings, loadCapability } from "@oh-my-pi/pi-coding-agent/discovery";
describe("disabledExtensions runtime filtering", () => { describe("disabledExtensions runtime filtering", () => {
@@ -12,7 +12,7 @@ describe("disabledExtensions runtime filtering", () => {
let originalHome: string | undefined; let originalHome: string | undefined;
beforeEach(async () => { beforeEach(async () => {
_resetSettingsForTest(); resetSettingsForTest();
originalHome = process.env.HOME; originalHome = process.env.HOME;
tempHomeDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-disabled-ext-home-")); tempHomeDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-disabled-ext-home-"));
process.env.HOME = tempHomeDir; process.env.HOME = tempHomeDir;
@@ -32,7 +32,7 @@ describe("disabledExtensions runtime filtering", () => {
}); });
afterEach(async () => { afterEach(async () => {
_resetSettingsForTest(); resetSettingsForTest();
vi.restoreAllMocks(); vi.restoreAllMocks();
if (originalHome === undefined) { if (originalHome === undefined) {
delete process.env.HOME; delete process.env.HOME;
@@ -19,7 +19,7 @@ import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core";
import { type AssistantMessage, getBundledModel, type StopReason, type ToolCall } from "@oh-my-pi/pi-ai"; import { type AssistantMessage, getBundledModel, type StopReason, type ToolCall } from "@oh-my-pi/pi-ai";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { EditTool } from "@oh-my-pi/pi-coding-agent/edit"; import { EditTool } from "@oh-my-pi/pi-coding-agent/edit";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; 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 { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
@@ -251,7 +251,7 @@ it("multi-entry edit on an auto-generated file surfaces isError + error text ins
// The auto-generated guard reads from the *global* settings singleton, so we // The auto-generated guard reads from the *global* settings singleton, so we
// must initialize it (the per-tool `Settings.isolated(...)` we pass into the // must initialize it (the per-tool `Settings.isolated(...)` we pass into the
// EditTool isn't what the guard sees). // EditTool isn't what the guard sees).
_resetSettingsForTest(); resetSettingsForTest();
await Settings.init({ inMemory: true, cwd: tempDir, overrides: { "edit.blockAutoGenerated": true } }); await Settings.init({ inMemory: true, cwd: tempDir, overrides: { "edit.blockAutoGenerated": true } });
try { try {
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { import {
DEFAULT_FUZZY_THRESHOLD, DEFAULT_FUZZY_THRESHOLD,
EditTool, EditTool,
@@ -40,13 +40,13 @@ const noopBeginDeferred = (_p: string) => ({
let tempDir: string; let tempDir: string;
beforeEach(async () => { beforeEach(async () => {
_resetSettingsForTest(); resetSettingsForTest();
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-edit-diff-")); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-edit-diff-"));
await Settings.init({ inMemory: true, cwd: tempDir }); await Settings.init({ inMemory: true, cwd: tempDir });
}); });
afterEach(async () => { afterEach(async () => {
_resetSettingsForTest(); resetSettingsForTest();
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
}); });
@@ -8,9 +8,9 @@ import * as path from "node:path";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { discoverAndLoadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; import { discoverAndLoadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader";
import { import {
__test_setExtensionHandlerTimeoutMs,
EXTENSION_HANDLER_TIMEOUT_MS, EXTENSION_HANDLER_TIMEOUT_MS,
ExtensionRunner, ExtensionRunner,
testSetExtensionHandlerTimeoutMs,
} from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner"; } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
@@ -34,7 +34,7 @@ describe("ExtensionRunner", () => {
}); });
afterEach(() => { afterEach(() => {
__test_setExtensionHandlerTimeoutMs(EXTENSION_HANDLER_TIMEOUT_MS); testSetExtensionHandlerTimeoutMs(EXTENSION_HANDLER_TIMEOUT_MS);
authStorage.close(); authStorage.close();
tempDir.removeSync(); tempDir.removeSync();
}); });
@@ -644,7 +644,7 @@ describe("ExtensionRunner", () => {
runner.onError(err => { runner.onError(err => {
errors.push(err); errors.push(err);
}); });
__test_setExtensionHandlerTimeoutMs(50); testSetExtensionHandlerTimeoutMs(50);
const startedAt = performance.now(); const startedAt = performance.now();
await runner.emit({ type: "session_start" }); await runner.emit({ type: "session_start" });
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { fuzzyFilter, fuzzyMatch } from "@oh-my-pi/pi-coding-agent/utils/fuzzy"; import { fuzzyFilter, fuzzyMatch } from "@oh-my-pi/pi-tui";
describe("fuzzyMatch", () => { describe("fuzzyMatch", () => {
test("empty query matches everything with score 0", () => { test("empty query matches everything with score 0", () => {
@@ -9,7 +9,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { hindsightBackend, reloadMentalModelsForSession } from "@oh-my-pi/pi-coding-agent/hindsight/backend"; import { hindsightBackend, reloadMentalModelsForSession } from "@oh-my-pi/pi-coding-agent/hindsight/backend";
import { HindsightApi } from "@oh-my-pi/pi-coding-agent/hindsight/client"; import { HindsightApi } from "@oh-my-pi/pi-coding-agent/hindsight/client";
import type { HindsightSessionState } from "@oh-my-pi/pi-coding-agent/hindsight/state"; import type { HindsightSessionState } from "@oh-my-pi/pi-coding-agent/hindsight/state";
@@ -76,7 +76,7 @@ function makeFakeSession(deps: FakeSessionDeps) {
describe("hindsightBackend.start", () => { describe("hindsightBackend.start", () => {
beforeEach(() => { beforeEach(() => {
_resetSettingsForTest(); resetSettingsForTest();
}); });
afterEach(() => { afterEach(() => {
@@ -235,7 +235,7 @@ describe("hindsightBackend.start", () => {
describe("hindsightBackend.preCompactionContext", () => { describe("hindsightBackend.preCompactionContext", () => {
beforeEach(() => { beforeEach(() => {
_resetSettingsForTest(); resetSettingsForTest();
}); });
afterEach(() => { afterEach(() => {
@@ -297,7 +297,7 @@ describe("hindsightBackend.preCompactionContext", () => {
describe("hindsightBackend first-turn injection", () => { describe("hindsightBackend first-turn injection", () => {
beforeEach(() => { beforeEach(() => {
_resetSettingsForTest(); resetSettingsForTest();
}); });
afterEach(() => { afterEach(() => {
@@ -480,7 +480,7 @@ describe("hindsightBackend first-turn injection", () => {
describe("hindsightBackend.clear", () => { describe("hindsightBackend.clear", () => {
beforeEach(() => { beforeEach(() => {
_resetSettingsForTest(); resetSettingsForTest();
}); });
afterEach(() => { afterEach(() => {
@@ -8,7 +8,7 @@
*/ */
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { HindsightApi } from "@oh-my-pi/pi-coding-agent/hindsight/client"; import { HindsightApi } from "@oh-my-pi/pi-coding-agent/hindsight/client";
import type { HindsightConfig } from "@oh-my-pi/pi-coding-agent/hindsight/config"; import type { HindsightConfig } from "@oh-my-pi/pi-coding-agent/hindsight/config";
import { HindsightSessionState } from "@oh-my-pi/pi-coding-agent/hindsight/state"; import { HindsightSessionState } from "@oh-my-pi/pi-coding-agent/hindsight/state";
@@ -94,7 +94,7 @@ function registerState(client: HindsightApi, settings?: Settings, opts: Register
describe("Hindsight tool factories", () => { describe("Hindsight tool factories", () => {
beforeEach(() => { beforeEach(() => {
_resetSettingsForTest(); resetSettingsForTest();
registeredState = undefined; registeredState = undefined;
}); });
@@ -122,7 +122,7 @@ describe("Hindsight tool factories", () => {
describe("retain.execute", () => { describe("retain.execute", () => {
beforeEach(() => { beforeEach(() => {
_resetSettingsForTest(); resetSettingsForTest();
registeredState = undefined; registeredState = undefined;
}); });
@@ -210,7 +210,7 @@ describe("retain.execute", () => {
describe("recall.execute", () => { describe("recall.execute", () => {
beforeEach(() => { beforeEach(() => {
_resetSettingsForTest(); resetSettingsForTest();
registeredState = undefined; registeredState = undefined;
}); });
@@ -278,7 +278,7 @@ describe("recall.execute", () => {
describe("reflect.execute", () => { describe("reflect.execute", () => {
beforeEach(() => { beforeEach(() => {
_resetSettingsForTest(); resetSettingsForTest();
registeredState = undefined; registeredState = undefined;
}); });
@@ -1,7 +1,7 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path"; import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core"; import { Agent } from "@oh-my-pi/pi-agent-core";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { TempDir } from "@oh-my-pi/pi-utils"; import { TempDir } from "@oh-my-pi/pi-utils";
import { ModelRegistry } from "../src/config/model-registry"; import { ModelRegistry } from "../src/config/model-registry";
@@ -24,7 +24,7 @@ describe("InteractiveMode.setEditorComponent", () => {
}); });
beforeEach(async () => { beforeEach(async () => {
_resetSettingsForTest(); resetSettingsForTest();
tempDir = TempDir.createSync("@pi-editor-component-"); tempDir = TempDir.createSync("@pi-editor-component-");
await Settings.init({ inMemory: true, cwd: tempDir.path() }); await Settings.init({ inMemory: true, cwd: tempDir.path() });
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
@@ -56,7 +56,7 @@ describe("InteractiveMode.setEditorComponent", () => {
await session?.dispose(); await session?.dispose();
authStorage?.close(); authStorage?.close();
tempDir?.removeSync(); tempDir?.removeSync();
_resetSettingsForTest(); resetSettingsForTest();
}); });
it("replaces the editor and rebinds interactive handlers", () => { it("replaces the editor and rebinds interactive handlers", () => {

Some files were not shown because too many files have changed in this diff Show More