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": {
"enabled": true,
"includes": ["**"],
"rules": {
"recommended": true,
"a11y": "off",
"correctness": {
"noUnusedImports": "error",
"noUnusedVariables": {
"level": "warn",
"fix": "none"
},
"noVoidTypeReturn": "off"
},
"style": {
+2 -3
View File
@@ -131,12 +131,11 @@
},
"devDependencies": {
"@biomejs/biome": "catalog:",
"@bufbuild/protoc-gen-es": "catalog:",
"prettier": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"lint-staged": "catalog:",
"prettier": "catalog:"
"lint-staged": "catalog:"
},
"lint-staged": {
"*.{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 {
createHarmonyAuditEvent,
extractHarmonyRemoved,
type HarmonyDetection,
type HarmonyRecoveredToolCall,
isHarmonyLeakMitigationTarget,
recoverHarmonyToolCall,
signalListLabel,
} from "./harmony-leak";
import type {
@@ -502,26 +500,6 @@ async function streamAssistantResponse(
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
// stream and reuse the same race promise for every iterator.next() instead of
// allocating Promise.withResolvers and add/removeEventListener per event.
-1
View File
@@ -36,7 +36,6 @@ const FENCE_RE = /^\s*(?:```+|~~~+)/;
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";
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
// 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);
for await (const _event of stream) {
for await (const _ of stream) {
// consume
}
+1 -1
View File
@@ -27,7 +27,7 @@ export function pushAlphaThenDoneEvent(
}
}
export function createUsage(): Usage {
function createUsage(): Usage {
return {
input: 0,
output: 0,
@@ -6,17 +6,15 @@ import type {
ResponseInput,
} from "openai/resources/responses/responses";
import { getEnvApiKey } from "../stream";
import {
type Api,
type AssistantMessage,
type Context,
type Model,
type ServiceTier,
type StreamFunction,
type StreamOptions,
shouldSendServiceTier,
type Tool,
type ToolChoice,
import type {
AssistantMessage,
Context,
Model,
ServiceTier,
StreamFunction,
StreamOptions,
Tool,
ToolChoice,
} from "../types";
import { normalizeSystemPrompts } from "../utils";
import { createAbortSourceTracker } from "../utils/abort";
@@ -33,8 +31,11 @@ import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice";
import { normalizeOpenAIResponsesPromptCacheKey, supportsDeveloperRole } from "./openai-responses";
import {
appendResponsesToolResultMessages,
applyCommonResponsesSamplingParams,
applyResponsesReasoningParams,
convertResponsesAssistantMessage,
convertResponsesInputContent,
createInitialResponsesAssistantMessage,
normalizeResponsesToolCallIdForTransform,
processResponsesStream,
} from "./openai-responses-shared";
@@ -101,23 +102,11 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
let firstTokenTime: number | undefined;
const deploymentName = resolveDeploymentName(model, options);
const output: AssistantMessage = {
role: "assistant",
content: [],
api: "azure-openai-responses" 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(),
};
const output: AssistantMessage = createInitialResponsesAssistantMessage(
"azure-openai-responses",
model.provider,
model.id,
);
let rawRequestDump: RawHttpRequestDump | undefined;
const abortTracker = createAbortSourceTracker(options?.signal);
const firstEventTimeoutAbortError = new Error(AZURE_OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE);
@@ -279,31 +268,7 @@ function buildParams(
prompt_cache_key: normalizeOpenAIResponsesPromptCacheKey(options?.sessionId),
};
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, model.provider)) {
params.service_tier = options.serviceTier;
}
applyCommonResponsesSamplingParams(params, options, model.provider);
if (context.tools) {
params.tools = convertTools(context.tools);
@@ -312,36 +277,7 @@ function buildParams(
}
}
if (model.reasoning) {
// 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",
},
],
});
}
}
}
applyResponsesReasoningParams(params, model, options, messages);
return params;
}
+14 -82
View File
@@ -29,17 +29,22 @@ import { ANTIGRAVITY_SYSTEM_INSTRUCTION, getAntigravityUserAgent, getGeminiCliHe
import {
convertMessages,
convertTools,
type GoogleThinkingLevel,
isThinkingPart,
mapStopReasonString,
mapToolChoice,
nextToolCallId,
pushBlockEndEvent,
pushToolCallEvents,
retainThoughtSignature,
startTextOrThinkingBlock,
} from "./google-shared";
/**
* Thinking level for Gemini 3 models.
* Mirrors Google's ThinkingLevel enum values.
* Thinking level for Gemini 3 models. Re-exported from `google-shared` so existing
* `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 {
toolChoice?: "auto" | "none" | "any";
@@ -72,9 +77,6 @@ export {
getGeminiCliUserAgent,
} from "./google-gemini-headers";
// Counter for generating unique tool call IDs
let toolCallCounter = 0;
// Retry configuration
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 1000;
@@ -426,37 +428,9 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
(!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);
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 });
pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
}
currentBlock = startTextOrThinkingBlock(isThinking, output, stream, ensureStarted);
}
if (currentBlock.type === "thinking") {
currentBlock.thinking += part.text;
@@ -488,30 +462,14 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
if (part.functionCall) {
hasContent = true;
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,
});
}
pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
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 toolCallId = needsNewId ? nextToolCallId(part.functionCall.name || "tool") : providedId;
const toolCall: ToolCall = {
type: "toolCall",
@@ -523,19 +481,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
output.content.push(toolCall);
ensureStarted();
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,
});
pushToolCallEvents(toolCall, blockIndex(), output, stream);
}
}
}
@@ -572,21 +518,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
}
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,
});
}
pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
}
return hasContent;
+446 -4
View File
@@ -1,8 +1,35 @@
/**
* Shared utilities for Google Generative AI and Google Cloud Code Assist providers.
*/
import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai";
import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types";
import {
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 { transformMessages } from "./transform-messages";
import { NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
@@ -11,6 +38,26 @@ export { sanitizeSchemaForGoogle };
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".
*
@@ -342,8 +389,7 @@ export function mapStopReason(reason: FinishReason): StopReason {
case FinishReason.NO_IMAGE:
return "error";
default: {
const _exhaustive: never = reason;
throw new Error(`Unhandled stop reason: ${_exhaustive}`);
throw new Error(`Unhandled stop reason: ${reason satisfies never}`);
}
}
}
@@ -361,3 +407,399 @@ export function mapStopReasonString(reason: string): StopReason {
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;
}
+16 -368
View File
@@ -1,317 +1,38 @@
import {
type GenerateContentConfig,
type GenerateContentParameters,
GoogleGenAI,
type ThinkingConfig,
ThinkingLevel,
} from "@google/genai";
import { GoogleGenAI } from "@google/genai";
import { $env } from "@oh-my-pi/pi-utils";
import { calculateCost } from "../models";
import type {
Api,
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";
import type { Context, Model, StreamFunction } from "../types";
import type { AssistantMessageEventStream } from "../utils/event-stream";
import { buildGoogleGenerateContentParams, type GoogleSharedStreamOptions, streamGoogleGenAI } from "./google-shared";
export interface GoogleVertexOptions extends StreamOptions {
toolChoice?: "auto" | "none" | "any";
thinking?: {
enabled: boolean;
budgetTokens?: number; // -1 for dynamic, 0 to disable
level?: GoogleThinkingLevel;
};
export interface GoogleVertexOptions extends GoogleSharedStreamOptions {
project?: string;
location?: string;
}
interface GoogleVertexSamplingConfig extends GenerateContentConfig {
topP?: number;
topK?: number;
minP?: number;
presencePenalty?: number;
repetitionPenalty?: number;
}
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"> = (
model: Model<"google-vertex">,
context: Context,
options?: GoogleVertexOptions,
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
(async () => {
const startTime = Date.now();
let firstTokenTime: number | undefined;
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 {
): AssistantMessageEventStream =>
streamGoogleGenAI({
model,
options,
api: "google-vertex",
retainTextSignature: true,
prepare: () => {
const apiKey = resolveApiKey(options);
const project = apiKey ? undefined : resolveProject(options);
const location = apiKey ? undefined : resolveLocation(options);
const client = apiKey ? createClientWithApiKey(model, apiKey) : createClient(model, project!, location!);
const params = buildParams(model, context, options);
options?.onPayload?.(params);
rawRequestDump = {
provider: model.provider,
api: output.api,
model: model.id,
method: "POST",
url: apiKey
const params = buildGoogleGenerateContentParams(model, context, options ?? {});
const 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,
: `https://${location}-aiplatform.googleapis.com/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent`;
return { client, params, url };
},
};
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 {
if (!model.headers) {
@@ -364,76 +85,3 @@ function resolveLocation(options?: GoogleVertexOptions): string {
}
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;
}
+14 -357
View File
@@ -1,297 +1,28 @@
import {
type GenerateContentConfig,
type GenerateContentParameters,
GoogleGenAI,
type ThinkingConfig,
} from "@google/genai";
import { calculateCost } from "../models";
import { GoogleGenAI } from "@google/genai";
import { getEnvApiKey } from "../stream";
import type {
Api,
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";
import type { Context, Model, StreamFunction } from "../types";
import type { AssistantMessageEventStream } from "../utils/event-stream";
import { buildGoogleGenerateContentParams, type GoogleSharedStreamOptions, streamGoogleGenAI } from "./google-shared";
export interface GoogleOptions extends StreamOptions {
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 type GoogleOptions = GoogleSharedStreamOptions;
export const streamGoogle: StreamFunction<"google-generative-ai"> = (
model: Model<"google-generative-ai">,
context: Context,
options?: GoogleOptions,
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
(async () => {
const startTime = Date.now();
let firstTokenTime: number | undefined;
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 {
): AssistantMessageEventStream =>
streamGoogleGenAI({
model,
options,
api: "google-generative-ai",
prepare: () => {
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
const client = createClient(model, apiKey);
const params = buildParams(model, context, options);
options?.onPayload?.(params);
rawRequestDump = {
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,
const params = buildGoogleGenerateContentParams(model, context, options ?? {});
const url = model.baseUrl ? `${model.baseUrl}/models/${model.id}:streamGenerateContent` : undefined;
return { client, params, url };
},
};
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 {
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,
});
}
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;
}
+13 -94
View File
@@ -9,19 +9,21 @@
* Note: Kimi calculates TPM rate limits based on max_tokens, not actual output.
*/
import { ANTHROPIC_THINKING } from "../stream";
import type { Api, Context, Model, SimpleStreamOptions } from "../types";
import { AssistantMessageEventStream } from "../utils/event-stream";
import type { Api, Context, Model } from "../types";
import type { AssistantMessageEventStream } from "../utils/event-stream";
import { getKimiCommonHeaders } from "../utils/oauth/kimi";
import { streamAnthropic, streamOpenAICompletions } from "./register-builtins";
import { createProviderErrorMessage } from "./shared/error-message";
import {
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
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" */
format?: KimiApiFormat;
}
@@ -35,96 +37,13 @@ export function streamKimi(
context: Context,
options?: KimiOptions,
): AssistantMessageEventStream {
const stream = new AssistantMessageEventStream();
const format = options?.format ?? "anthropic";
// Async IIFE to handle header fetching and stream piping
(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 streamOpenAIAnthropicShim(model, context, options, {
anthropicBaseUrl: KIMI_ANTHROPIC_BASE_URL,
defaultFormat: "anthropic",
extraHeaders: getKimiCommonHeaders,
});
}
})();
return stream;
}
/**
* 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,
ResponseInput,
ResponseInputContent,
ResponseInputImage,
ResponseInputText,
ResponseOutputMessage,
ResponseReasoningItem,
} from "openai/resources/responses/responses";
@@ -35,7 +33,6 @@ import {
createOpenAIResponsesHistoryPayload,
getOpenAIResponsesHistoryItems,
getOpenAIResponsesHistoryPayload,
normalizeResponsesToolCallId,
normalizeSystemPrompts,
} from "../utils";
import { AssistantMessageEventStream } from "../utils/event-stream";
@@ -54,14 +51,15 @@ import {
import { parseCodexError } from "./openai-codex/response-handler";
import { normalizeOpenAIResponsesPromptCacheKey } from "./openai-responses";
import {
appendResponsesToolResultMessages,
convertResponsesAssistantMessage,
convertResponsesInputContent,
encodeResponsesToolCallId,
encodeTextSignatureV1,
mapOpenAIResponsesStopReason,
parseTextSignature,
populateResponsesUsageFromResponse,
} from "./openai-responses-shared";
import { transformMessages } from "./transform-messages";
import { joinTextWithImagePlaceholder } from "./vision-guard";
export interface OpenAICodexResponsesOptions extends StreamOptions {
reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
@@ -1233,19 +1231,7 @@ function handleResponseCompleted(
}
).response;
if (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 },
};
}
populateResponsesUsageFromResponse(output, response?.usage);
if (typeof response?.id === "string" && response.id.length > 0) {
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
// `function_call_output` (OpenAI rejects mismatched pairs).
const customCallIds = new Set<string>();
const knownCallIds = new Set<string>();
for (const msg of transformedMessages) {
if (msg.role === "user" || msg.role === "developer") {
@@ -2407,57 +2394,14 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
continue;
}
const outputItems: ResponseInput = [];
for (const block of msg.content) {
if (block.type === "thinking" && msg.stopReason !== "error") {
if (block.thinkingSignature) {
outputItems.push(JSON.parse(block.thinkingSignature) as ResponseReasoningItem);
}
continue;
}
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),
});
}
}
const outputItems = convertResponsesAssistantMessage(
msg as AssistantMessage,
model,
msgIndex,
knownCallIds,
true,
customCallIds,
);
if (outputItems.length > 0) {
messages.push(...outputItems);
}
@@ -2466,49 +2410,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
}
if (msg.role === "toolResult") {
const supportsImages = model.input.includes("image");
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 });
}
appendResponsesToolResultMessages(messages, msg, model, false, knownCallIds, customCallIds);
}
msgIndex += 1;
@@ -64,6 +64,7 @@ import {
resolveGitHubCopilotBaseUrl,
} from "./github-copilot-headers";
import { detectOpenAICompat, type ResolvedOpenAICompat, resolveOpenAICompat } from "./openai-completions-compat";
import { createInitialResponsesAssistantMessage } from "./openai-responses-shared";
import { transformMessages } from "./transform-messages";
import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
@@ -338,23 +339,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
let firstTokenTime: number | undefined;
let getCapturedErrorResponse: (() => CapturedHttpErrorResponse | undefined) | undefined;
const output: AssistantMessage = {
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(),
};
const output: AssistantMessage = createInitialResponsesAssistantMessage(model.api, model.provider, model.id);
let rawRequestDump: RawHttpRequestDump | undefined;
const abortTracker = createAbortSourceTracker(options?.signal);
const firstEventTimeoutAbortError = new Error(OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE);
@@ -12,17 +12,20 @@ import type {
ResponseReasoningItem,
} from "openai/resources/responses/responses";
import { calculateCost } from "../models";
import type {
Api,
AssistantMessage,
ImageContent,
Model,
StopReason,
TextContent,
TextSignatureV1,
ThinkingContent,
ToolCall,
ToolResultMessage,
import {
type Api,
type AssistantMessage,
type ImageContent,
type Model,
type ServiceTier,
type StopReason,
type StreamOptions,
shouldSendServiceTier,
type TextContent,
type TextSignatureV1,
type ThinkingContent,
type ToolCall,
type ToolResultMessage,
} from "../types";
import { normalizeResponsesToolCallId } from "../utils";
import type { AssistantMessageEventStream } from "../utils/event-stream";
@@ -540,19 +543,7 @@ export async function processResponsesStream<TApi extends Api>(
if (response?.id) {
output.responseId = response.id;
}
if (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 },
};
}
populateResponsesUsageFromResponse(output, response?.usage);
calculateCost(model, output.usage);
output.stopReason = mapOpenAIResponsesStopReason(response?.status);
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";
import { getEnvApiKey } from "../stream";
import {
type Api,
type AssistantMessage,
type CacheRetention,
type Context,
@@ -19,7 +18,6 @@ import {
type ServiceTier,
type StreamFunction,
type StreamOptions,
shouldSendServiceTier,
type Tool,
type ToolChoice,
} from "../types";
@@ -54,10 +52,13 @@ import {
import { compactGrammarDefinition } from "./grammar";
import {
appendResponsesToolResultMessages,
applyCommonResponsesSamplingParams,
applyResponsesReasoningParams,
collectCustomCallIds,
collectKnownCallIds,
convertResponsesAssistantMessage,
convertResponsesInputContent,
createInitialResponsesAssistantMessage,
normalizeResponsesToolCallIdForTransform,
processResponsesStream,
} from "./openai-responses-shared";
@@ -187,23 +188,11 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
const startTime = Date.now();
let firstTokenTime: number | undefined;
const output: AssistantMessage = {
role: "assistant",
content: [],
api: "openai-responses" 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(),
};
const output: AssistantMessage = createInitialResponsesAssistantMessage(
"openai-responses",
model.provider,
model.id,
);
let rawRequestDump: RawHttpRequestDump | undefined;
const abortTracker = createAbortSourceTracker(options?.signal);
const firstEventTimeoutAbortError = new Error(OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE);
@@ -418,31 +407,7 @@ function buildParams(
store: false,
};
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, model.provider)) {
params.service_tier = options.serviceTier;
}
applyCommonResponsesSamplingParams(params, options, model.provider);
if (context.tools) {
params.tools = convertTools(context.tools, supportsStrictMode(model), model);
@@ -460,36 +425,9 @@ function buildParams(
}
}
if (model.reasoning) {
// 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: 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",
},
],
});
}
}
applyResponsesReasoningParams(params, model, options, messages, effort =>
mapReasoningEffort(effort as NonNullable<OpenAIResponsesOptions["reasoning"]>, model.compat?.reasoningEffortMap),
);
return { conversationMessages, params };
}
+13 -100
View File
@@ -8,18 +8,20 @@
* @see https://dev.synthetic.new/docs/api/overview
*/
import { ANTHROPIC_THINKING } from "../stream";
import type { Api, Context, Model, SimpleStreamOptions } from "../types";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { streamAnthropic, streamOpenAICompletions } from "./register-builtins";
import { createProviderErrorMessage } from "./shared/error-message";
import type { Api, Context, Model } from "../types";
import type { AssistantMessageEventStream } from "../utils/event-stream";
import {
type OpenAIAnthropicApiFormat,
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_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" */
format?: SyntheticApiFormat;
}
@@ -33,102 +35,13 @@ export function streamSynthetic(
context: Context,
options?: SyntheticOptions,
): AssistantMessageEventStream {
const stream = new AssistantMessageEventStream();
const format = options?.format ?? "openai";
// Async IIFE to handle stream piping
(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 streamOpenAIAnthropicShim(model, context, options, {
anthropicBaseUrl: SYNTHETIC_NEW_ANTHROPIC_BASE_URL,
openaiBaseUrl: SYNTHETIC_NEW_BASE_URL,
defaultFormat: "openai",
});
}
})();
return stream;
}
/**
* Check if a model is a Synthetic model.
*/
+1 -1
View File
@@ -64,7 +64,7 @@ type _CheckExhaustive =
? true
: ["ApiOptionsMap is missing some KnownApi values", Exclude<KnownApi, keyof ApiOptionsMap>]
: ["ApiOptionsMap doesn't extend Record<KnownApi, StreamOptions>"];
const _exhaustive: _CheckExhaustive = true;
true satisfies _CheckExhaustive;
export type OptionsForApi<TApi extends Api> =
| StreamOptions
| (TApi extends keyof ApiOptionsMap ? ApiOptionsMap[TApi] : never);
-2
View File
@@ -10,8 +10,6 @@
import type { OAuthController, OAuthProvider } from "./types";
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";
/**
@@ -470,7 +470,7 @@ describe("anthropic stream envelope handling", () => {
vi.spyOn(Messages.prototype, "create").mockImplementation(() => createRawSseRequest(frames) as never);
const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" });
for await (const _event of stream) {
for await (const _ of stream) {
// drain stream
}
const result = await stream.result();
@@ -497,7 +497,7 @@ describe("anthropic stream envelope handling", () => {
});
const eagerStream = streamAnthropic(model, toolContext, { apiKey: "sk-ant-test" });
for await (const _event of eagerStream) {
for await (const _ of eagerStream) {
// drain stream
}
await eagerStream.result();
@@ -507,7 +507,7 @@ describe("anthropic stream envelope handling", () => {
toolContext,
{ apiKey: "sk-ant-test" },
);
for await (const _event of disabledStream) {
for await (const _ of disabledStream) {
// drain stream
}
await disabledStream.result();
@@ -534,7 +534,7 @@ describe("anthropic stream envelope handling", () => {
apiKey: "sk-ant-test",
cacheRetention: "long",
});
for await (const _event of stream) {
for await (const _ of stream) {
// drain stream
}
await stream.result();
@@ -227,7 +227,6 @@ describe("loginGitHubCopilot", () => {
});
it("device flow error", async () => {
let _pollCount = 0;
const fetchMock = vi.fn(async (input: string | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "https://github.com/login/device/code") {
@@ -237,7 +236,6 @@ describe("loginGitHubCopilot", () => {
});
}
if (url === "https://github.com/login/oauth/access_token") {
_pollCount++;
return new Response(JSON.stringify({ error: "access_denied", error_description: "User denied" }), {
status: 200,
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.
* 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.
+1 -1
View File
@@ -501,7 +501,7 @@ describe("Generate E2E Tests", () => {
);
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
const _beforeCount = event.messages.length;
const filtered = event.messages.filter(m => {
if (m.role === "user" && Array.isArray(m.content)) {
const hasOldContext = m.content.some(
@@ -1,3 +1,4 @@
import * as git from "../utils/git";
import type { ASIData, ASIValue, MetricDirection, NumericMetricMap } from "./types";
export const METRIC_LINE_PREFIX = "METRIC";
@@ -199,3 +200,19 @@ function sanitizeAsiValue(value: unknown): ASIValue | 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 * as git from "../../utils/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 {
buildExperimentState,
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(
state: ExperimentState,
experiment: ExperimentResult,
@@ -18,6 +18,8 @@ import {
killTree,
parseAsiLines,
parseMetricLines,
tryGitPrefix,
tryGitStatus,
} from "../helpers";
import { buildExperimentState } from "../state";
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: {
command: string[];
cwd: string;
+1 -1
View File
@@ -2,11 +2,11 @@
* List available models with optional fuzzy search
*/
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 type { ModelRegistry } from "../config/model-registry";
import { discoverAndLoadExtensions, loadExtensions } from "../extensibility/extensions";
import { EventBus } from "../utils/event-bus";
import { fuzzyFilter } from "../utils/fuzzy";
interface ProviderRow {
provider: string;
+3 -13
View File
@@ -5,10 +5,11 @@
*/
import * as path from "node:path";
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 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";
export interface ShellCommandArgs {
@@ -41,17 +42,6 @@ export function parseShellArgs(args: string[]): ShellCommandArgs | undefined {
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> {
if (!process.stdin.isTTY) {
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";
}
export function _resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" {
export function resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" {
return resolveUpdateMethod(ompPath, bunBinDir);
}
async function resolveUpdateTarget(): Promise<UpdateTarget> {
@@ -1,52 +1,16 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
import type { Api, Model } from "@oh-my-pi/pi-ai";
import { completeSimple } from "@oh-my-pi/pi-ai";
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 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 { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "../utils";
import { createConventionalAnalysisTool, parseConventionalAnalysisResponse } from "../shared-llm";
const ConventionalAnalysisTool = {
name: "create_conventional_analysis",
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()),
}),
};
const ConventionalAnalysisTool = createConventionalAnalysisTool(
"Analyze a diff and return conventional commit classification.",
);
export interface ConventionalAnalysisInput {
model: Model<Api>;
@@ -96,27 +60,5 @@ export async function generateConventionalAnalysis({
{ apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) },
);
return parseAnalysisFromResponse(response);
}
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);
return parseConventionalAnalysisResponse(response, ConventionalAnalysisTool);
}
@@ -1,52 +1,14 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
import type { Api, Model } from "@oh-my-pi/pi-ai";
import { completeSimple } from "@oh-my-pi/pi-ai";
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 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 { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "../utils";
import { createConventionalAnalysisTool, parseConventionalAnalysisResponse } from "../shared-llm";
const ReduceTool = {
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()),
}),
};
const ReduceTool = createConventionalAnalysisTool("Synthesize file observations into a conventional commit analysis.");
export interface ReducePhaseInput {
model: Model<Api>;
@@ -83,26 +45,5 @@ export async function runReducePhase({
{ apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) },
);
return parseAnalysisResponse(response);
}
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);
return parseConventionalAnalysisResponse(response, ReduceTool);
}
+2 -2
View File
@@ -25,8 +25,8 @@ import type { CommitCommandArgs, ConventionalAnalysis } from "./types";
const SUMMARY_MAX_CHARS = 72;
const RECENT_COMMITS_COUNT = 8;
let _typesDescription: string | undefined;
const TYPES_DESCRIPTION = (): string => (_typesDescription ??= prompt.render(typesDescriptionPrompt));
let typesDescription: string | undefined;
const TYPES_DESCRIPTION = (): string => (typesDescription ??= prompt.render(typesDescriptionPrompt));
/**
* 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;
let __referenceDataCache: CanonicalReferenceData | undefined;
let referenceDataCache: CanonicalReferenceData | undefined;
const EMPTY_COMPILED_EQUIVALENCE: CompiledEquivalenceConfig = {
overrides: new Map<string, string>(),
exclude: new Set<string>(),
};
const __resolutionCache: WeakMap<
CompiledEquivalenceConfig,
WeakMap<Model<Api>, ResolvedCanonicalModel>
> = new WeakMap();
const resolutionCache: WeakMap<CompiledEquivalenceConfig, WeakMap<Model<Api>, ResolvedCanonicalModel>> = new WeakMap();
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.]+(?:[-_/][a-z0-9.]+)*)(?::|$)/i,
@@ -98,8 +95,8 @@ function shouldReplaceReference(existing: Model<Api> | undefined, candidate: Mod
}
function createCanonicalReferenceData(): CanonicalReferenceData {
if (__referenceDataCache) {
return __referenceDataCache;
if (referenceDataCache) {
return referenceDataCache;
}
const references = new Map<string, Model<Api>>();
for (const provider of getBundledProviders()) {
@@ -112,11 +109,11 @@ function createCanonicalReferenceData(): CanonicalReferenceData {
}
}
const officialIds = new Set(references.keys());
__referenceDataCache = {
referenceDataCache = {
references: Object.freeze(references) as Map<string, Model<Api>>,
officialIds: Object.freeze(officialIds) as Set<string>,
};
return __referenceDataCache;
return referenceDataCache;
}
function normalizeSelectorKey(selector: string): string {
@@ -668,10 +665,10 @@ export function buildCanonicalModelIndex(
const byId = new Map<string, CanonicalModelRecord>();
const bySelector = new Map<string, string>();
let modelCache = __resolutionCache.get(compiledEquivalence);
let modelCache = resolutionCache.get(compiledEquivalence);
if (!modelCache) {
modelCache = new WeakMap<Model<Api>, ResolvedCanonicalModel>();
__resolutionCache.set(compiledEquivalence, modelCache);
resolutionCache.set(compiledEquivalence, modelCache);
}
for (const model of models) {
@@ -12,10 +12,10 @@ import {
type Model,
modelsAreEqual,
} from "@oh-my-pi/pi-ai";
import { fuzzyMatch } from "@oh-my-pi/pi-tui";
import chalk from "chalk";
import MODEL_PRIO from "../priority.json" with { type: "json" };
import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking";
import { fuzzyMatch } from "../utils/fuzzy";
import { isAuthenticated, kNoAuth, MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry";
import type { Settings } from "./settings";
@@ -607,9 +607,6 @@ export function resolveModelRoleValue(
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);
if (!effectivePatterns || effectivePatterns.length === 0) {
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.
* @internal
*/
export function _resetSettingsForTest(): void {
export function resetSettingsForTest(): void {
globalInstance = 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
* 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;
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
* before invoking the transpiler.
*/
export function stripTypeScript(code: string): string {
function stripTypeScript(code: string): string {
if (!LOOKS_LIKE_TS.test(code)) return code;
try {
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;
return {
enabled: true,
@@ -63,7 +63,7 @@ export type ExtensionErrorListener = (error: ExtensionError) => void;
export const EXTENSION_HANDLER_TIMEOUT_MS = 30_000;
let extensionHandlerTimeoutMs = EXTENSION_HANDLER_TIMEOUT_MS;
export function __test_setExtensionHandlerTimeoutMs(timeoutMs: number): void {
export function testSetExtensionHandlerTimeoutMs(timeoutMs: number): void {
extensionHandlerTimeoutMs = timeoutMs;
}
@@ -18,13 +18,11 @@ import type {
ProviderResponseMetadata,
SimpleStreamOptions,
TextContent,
ToolResultMessage,
} from "@oh-my-pi/pi-ai";
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 { AutocompleteItem, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
import type { Static, TSchema } from "@sinclair/typebox";
import type { Rule } from "../../capability/rule";
import type { KeybindingsManager } from "../../config/keybindings";
import type { ModelRegistry } from "../../config/model-registry";
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 { CustomEditor } from "../../modes/components/custom-editor";
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 {
BranchSummaryEntry,
CompactionEntry,
ReadonlySessionManager,
SessionEntry,
SessionManager,
} from "../../session/session-manager";
import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager";
import type {
BashToolDetails,
BashToolInput,
@@ -53,8 +45,39 @@ import type {
SearchToolInput,
WriteToolInput,
} from "../../tools";
import type { TodoItem } from "../../tools/todo-write";
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";
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.
* 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 {
/** Show a selector and return the user's choice. */
select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined>;
@@ -221,6 +249,11 @@ export interface CompactOptions {
/**
* 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 {
/** UI methods for user interaction */
ui: ExtensionUIContext;
@@ -256,6 +289,10 @@ export interface ExtensionContext {
* Extended context for command handlers.
* 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 {
/** Get current context usage for the active model. */
getContextUsage(): ContextUsage | undefined;
@@ -373,115 +410,30 @@ export interface ResourcesDiscoverResult {
}
// ============================================================================
// Session Events
// Session Events (shared with hooks subsystem)
// ============================================================================
/** 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: "new" | "resume" | "fork";
targetSessionFile?: string;
}
/** Fired after switching to another session */
export interface SessionSwitchEvent {
type: "session_switch";
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;
export type {
SessionBeforeBranchEvent,
SessionBeforeCompactEvent,
SessionBeforeSwitchEvent,
SessionBeforeTreeEvent,
SessionBranchEvent,
SessionCompactEvent,
SessionCompactingEvent,
SessionEvent,
SessionShutdownEvent,
SessionStartEvent,
SessionSwitchEvent,
SessionTreeEvent,
TreePreparation,
} from "../shared-events";
// ============================================================================
// Agent Events
// ============================================================================
/** Fired before each LLM call. Can modify messages. */
export interface ContextEvent {
type: "context";
messages: AgentMessage[];
}
export type { ContextEvent } from "../shared-events";
/** Fired before a provider request is sent. Can replace the payload. */
export interface BeforeProviderRequestEvent {
@@ -502,31 +454,7 @@ export interface BeforeAgentStartEvent {
systemPrompt: string[];
}
/** Fired when an agent loop starts */
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[];
}
export type { AgentEndEvent, AgentStartEvent, TurnEndEvent, TurnStartEvent } from "../shared-events";
/** Fired when a message starts (user, assistant, or toolResult) */
export interface MessageStartEvent {
@@ -574,55 +502,14 @@ export interface ToolExecutionEndEvent {
isError: boolean;
}
/** 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;
}
/** 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;
}
export type {
AutoCompactionEndEvent,
AutoCompactionStartEvent,
AutoRetryEndEvent,
AutoRetryStartEvent,
TodoReminderEvent,
TtsrTriggeredEvent,
} from "../shared-events";
/** 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 {
@@ -857,10 +744,7 @@ export interface ContextEventResult {
export type BeforeProviderRequestEventResult = unknown;
export interface ToolCallEventResult {
block?: boolean;
reason?: string;
}
export type { ToolCallEventResult } from "../shared-events";
/** Result from input event handler */
export interface InputEventResult {
@@ -884,11 +768,7 @@ export interface UserPythonEventResult {
result?: PythonResult;
}
export interface ToolResultEventResult {
content?: (TextContent | ImageContent)[];
details?: unknown;
isError?: boolean;
}
export type { ToolResultEventResult } from "../shared-events";
export interface BeforeAgentStartEventResult {
message?: Pick<CustomMessage, "customType" | "content" | "display" | "details" | "attribution">;
@@ -896,33 +776,13 @@ export interface BeforeAgentStartEventResult {
systemPrompt?: string[];
}
export interface SessionBeforeSwitchResult {
cancel?: boolean;
}
export interface SessionBeforeBranchResult {
cancel?: boolean;
skipConversationRestore?: boolean;
}
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;
};
}
export type {
SessionBeforeBranchResult,
SessionBeforeCompactResult,
SessionBeforeSwitchResult,
SessionBeforeTreeResult,
SessionCompactingResult,
} from "../shared-events";
// ============================================================================
// Message Rendering
@@ -942,6 +802,9 @@ export type MessageRenderer<T = unknown> = (
// Command Registration
// ============================================================================
// fallow-ignore-next-line code-duplication
// Parallel to HookAPI's RegisteredCommand: extensions add
// `getArgumentCompletions` and bind handlers to ExtensionCommandContext.
export interface RegisteredCommand {
name: string;
description?: string;
@@ -1,28 +1,44 @@
/**
* 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 { ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai";
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 { EditToolDetails } from "../../edit";
import type { ExecOptions, ExecResult } from "../../exec/exec";
import type { Theme } from "../../modes/theme/theme";
import type { CompactionPreparation, CompactionResult } from "../../session/compaction";
import type { HookMessage } from "../../session/messages";
import type {
BranchSummaryEntry,
CompactionEntry,
ReadonlySessionManager,
SessionEntry,
SessionManager,
} from "../../session/session-manager";
import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager";
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
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.
* 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 {
/**
* Show a selector and return the user's choice.
@@ -137,6 +158,11 @@ export interface HookUIContext {
* Context passed to hook event handlers.
* 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 {
/** UI methods for user interaction */
ui: HookUIContext;
@@ -165,6 +191,11 @@ export interface HookContext {
* 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).
*/
// 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 {
/** Wait for the agent to finish streaming */
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 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) */
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[];
}
export type {
ContextEvent,
SessionBeforeBranchEvent,
SessionBeforeCompactEvent,
SessionBeforeSwitchEvent,
SessionBeforeTreeEvent,
SessionBranchEvent,
SessionCompactEvent,
SessionCompactingEvent,
SessionEvent,
SessionShutdownEvent,
SessionStartEvent,
SessionSwitchEvent,
SessionTreeEvent,
TreePreparation,
} from "../shared-events";
/**
* Event data for before_agent_start event.
@@ -356,90 +274,18 @@ export interface BeforeAgentStartEvent {
images?: ImageContent[];
}
/**
* Event data for agent_start event.
* Fired when an agent loop starts (once per user prompt).
*/
export interface AgentStartEvent {
type: "agent_start";
}
/**
* Event data for agent_end event.
*/
export interface AgentEndEvent {
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;
}
export type {
AgentEndEvent,
AgentStartEvent,
AutoCompactionEndEvent,
AutoCompactionStartEvent,
AutoRetryEndEvent,
AutoRetryStartEvent,
TodoReminderEvent,
TtsrTriggeredEvent,
TurnEndEvent,
TurnStartEvent,
} from "../shared-events";
/**
* Event data for tool_call event.
@@ -559,29 +405,7 @@ export interface ContextEventResult {
messages?: Message[];
}
/**
* 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;
}
export type { ToolCallEventResult, ToolResultEventResult } from "../shared-events";
/**
* Return type for before_agent_start event handlers.
@@ -592,65 +416,13 @@ export interface BeforeAgentStartEventResult {
message?: Pick<HookMessage, "customType" | "content" | "display" | "details" | "attribution">;
}
/** 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 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;
};
}
export type {
SessionBeforeBranchResult,
SessionBeforeCompactResult,
SessionBeforeSwitchResult,
SessionBeforeTreeResult,
SessionCompactingResult,
} from "../shared-events";
// ============================================================================
// Hook API
@@ -681,6 +453,9 @@ export type HookMessageRenderer<T = unknown> = (
/**
* 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 {
name: 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;
/** 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 path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { AgentRegistry } from "../registry/agent-registry";
import { applyQuery, pathToQuery } from "./json-query";
import { artifactsDirsFromRegistry } from "./registry-helpers";
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.
*
@@ -12,27 +12,9 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
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";
/**
* 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 {
readonly scheme = "artifact";
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,
Ellipsis,
ImageProtocol,
Loader,
Spacer,
type Loader,
TERMINAL,
Text,
type TUI,
truncateToWidth,
visibleWidth,
} from "@oh-my-pi/pi-tui";
import { getSymbolTheme, theme } from "../../modes/theme/theme";
import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
import { theme } from "../../modes/theme/theme";
import type { TruncationMeta } from "../../tools/output-meta";
import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
import { DynamicBorder } from "./dynamic-border";
import { truncateToVisualLines } from "./visual-truncate";
import {
buildExecutionFrame,
buildStatusFooter,
createCollapsedPreview,
type ExecutionStatus,
resolveExecutionStatus,
} from "./execution-shared";
// Preview line limit when not expanded (matches tool execution behavior)
const PREVIEW_LINES = 20;
@@ -31,7 +35,7 @@ const CHUNK_THROTTLE_MS = 50;
export class BashExecutionComponent extends Container {
#outputLines: string[] = [];
#status: "running" | "complete" | "cancelled" | "error" = "running";
#status: ExecutionStatus = "running";
#exitCode: number | undefined = undefined;
#loader: Loader;
#truncation?: TruncationMeta;
@@ -50,34 +54,14 @@ export class BashExecutionComponent extends Container {
// Use dim border for excluded-from-context commands (!! prefix)
const colorKey = excludeFromContext ? "dim" : "bashMode";
const borderColor = (str: string) => theme.fg(colorKey, str);
// Add spacer
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);
const { contentContainer, loader } = buildExecutionFrame(this, ui, colorKey);
this.#contentContainer = contentContainer;
this.#loader = loader;
// Command header
this.#headerText = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);
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);
// Bottom border
this.addChild(new DynamicBorder(borderColor));
}
/**
@@ -130,11 +114,7 @@ export class BashExecutionComponent extends Container {
options?: { output?: string; truncation?: TruncationMeta },
): void {
this.#exitCode = exitCode;
this.#status = cancelled
? "cancelled"
: exitCode !== 0 && exitCode !== undefined && exitCode !== null
? "error"
: "complete";
this.#status = resolveExecutionStatus(exitCode, cancelled);
this.#truncation = options?.truncation;
if (options?.output !== undefined) {
this.#setOutput(options.output);
@@ -182,14 +162,7 @@ export class BashExecutionComponent extends Container {
} else {
// Use shared visual truncation utility, recomputed per render width
const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n");
const previewText = `\n${styledOutput}`;
this.#contentContainer.addChild({
render: (width: number) => {
const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1);
return visualLines;
},
invalidate: () => {},
});
this.#contentContainer.addChild(createCollapsedPreview(`\n${styledOutput}`, PREVIEW_LINES));
}
}
@@ -197,26 +170,14 @@ export class BashExecutionComponent extends Container {
if (this.#status === "running") {
this.#contentContainer.addChild(this.#loader);
} else {
const statusParts: string[] = [];
// Show how many lines are hidden (collapsed preview)
if (hiddenLineCount > 0 && !hasSixelOutput) {
statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`));
}
if (this.#status === "cancelled") {
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));
}
const footer = buildStatusFooter({
status: this.#status,
exitCode: this.#exitCode,
truncation: this.#truncation,
hiddenLineCount,
suppressHiddenCount: hasSixelOutput,
});
if (footer) this.#contentContainer.addChild(footer);
}
}
@@ -1,9 +1,9 @@
import type { TextContent } from "@oh-my-pi/pi-ai";
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 { getMarkdownTheme, theme } from "../../modes/theme/theme";
import { theme } from "../../modes/theme/theme";
import type { CustomMessage } from "../../session/messages";
import { renderFramedMessage } from "./message-frame";
/**
* Component that renders a custom message entry from extensions.
@@ -41,51 +41,25 @@ export class CustomMessageComponent extends Container {
}
#rebuild(): void {
// Remove previous content component
if (this.#customComponent) {
this.removeChild(this.#customComponent);
this.#customComponent = undefined;
}
this.removeChild(this.#box);
// Try custom renderer first - it handles its own styling
if (this.customRenderer) {
try {
const component = this.customRenderer(this.message, { expanded: this.#expanded }, theme);
if (component) {
this.#customComponent = component;
this.addChild(component);
return;
}
} catch {
// Fall through to default rendering
}
}
const custom = renderFramedMessage({
message: this.message,
box: this.#box,
expanded: this.#expanded,
customRenderer: this.customRenderer,
// Extension messages render full content; no collapse-on-fold behaviour.
});
// Default rendering uses our box
this.addChild(this.#box);
this.#box.clear();
// 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;
if (custom) {
this.#customComponent = custom;
this.addChild(custom);
} else {
text = this.message.content
.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),
}),
);
this.addChild(this.#box);
}
}
}
@@ -4,11 +4,17 @@
*/
import { sanitizeText } from "@oh-my-pi/pi-natives";
import { Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
import { getSymbolTheme, highlightCode, theme } from "../../modes/theme/theme";
import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
import { DynamicBorder } from "./dynamic-border";
import { truncateToVisualLines } from "./visual-truncate";
import { Container, type Loader, Text, type TUI } from "@oh-my-pi/pi-tui";
import { highlightCode, theme } from "../../modes/theme/theme";
import type { TruncationMeta } from "../../tools/output-meta";
import {
buildExecutionFrame,
buildStatusFooter,
createCollapsedPreview,
type ExecutionColorKey,
type ExecutionStatus,
resolveExecutionStatus,
} from "./execution-shared";
const PREVIEW_LINES = 20;
const MAX_DISPLAY_LINE_CHARS = 4000;
@@ -17,7 +23,7 @@ export type EvalExecutionLanguage = "python" | "js";
export class EvalExecutionComponent extends Container {
#outputLines: string[] = [];
#status: "running" | "complete" | "cancelled" | "error" = "running";
#status: ExecutionStatus = "running";
#exitCode: number | undefined = undefined;
#loader: Loader;
#truncation?: TruncationMeta;
@@ -28,7 +34,7 @@ export class EvalExecutionComponent extends Container {
return this.language === "js" ? "javascript" : "python";
}
#formatHeader(colorKey: "dim" | "pythonMode"): Text {
#formatHeader(colorKey: ExecutionColorKey): Text {
const prompt = theme.fg(colorKey, theme.bold(">>>"));
const continuation = theme.fg(colorKey, " ");
const codeLines = highlightCode(this.code, this.#highlightLang());
@@ -46,26 +52,13 @@ export class EvalExecutionComponent extends Container {
) {
super();
const colorKey = this.excludeFromContext ? "dim" : "pythonMode";
const borderColor = (str: string) => theme.fg(colorKey, str);
const colorKey: ExecutionColorKey = this.excludeFromContext ? "dim" : "pythonMode";
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.#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.addChild(new DynamicBorder(borderColor));
}
setExpanded(expanded: boolean): void {
@@ -99,11 +92,7 @@ export class EvalExecutionComponent extends Container {
options?: { output?: string; truncation?: TruncationMeta },
): void {
this.#exitCode = exitCode;
this.#status = cancelled
? "cancelled"
: exitCode !== 0 && exitCode !== undefined && exitCode !== null
? "error"
: "complete";
this.#status = resolveExecutionStatus(exitCode, cancelled);
this.#truncation = options?.truncation;
if (options?.output !== undefined) {
this.#setOutput(options.output);
@@ -120,7 +109,7 @@ export class EvalExecutionComponent extends Container {
this.#contentContainer.clear();
const colorKey = this.excludeFromContext ? "dim" : "pythonMode";
const colorKey: ExecutionColorKey = this.excludeFromContext ? "dim" : "pythonMode";
this.#contentContainer.addChild(this.#formatHeader(colorKey));
if (availableLines.length > 0) {
@@ -129,39 +118,20 @@ export class EvalExecutionComponent extends Container {
this.#contentContainer.addChild(new Text(`\n${displayText}`, 1, 0));
} else {
const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n");
const previewText = `\n${styledOutput}`;
this.#contentContainer.addChild({
render: (width: number) => {
const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1);
return visualLines;
},
invalidate: () => {},
});
this.#contentContainer.addChild(createCollapsedPreview(`\n${styledOutput}`, PREVIEW_LINES));
}
}
if (this.#status === "running") {
this.#contentContainer.addChild(this.#loader);
} else {
const statusParts: string[] = [];
if (hiddenLineCount > 0) {
statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`));
}
if (this.#status === "cancelled") {
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));
}
const footer = buildStatusFooter({
status: this.#status,
exitCode: this.#exitCode,
truncation: this.#truncation,
hiddenLineCount,
});
if (footer) this.#contentContainer.addChild(footer);
}
}
@@ -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 { 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 { getMarkdownTheme, theme } from "../../modes/theme/theme";
import { theme } from "../../modes/theme/theme";
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.
@@ -41,60 +44,25 @@ export class HookMessageComponent extends Container {
}
#rebuild(): void {
// Remove previous content component
if (this.#customComponent) {
this.removeChild(this.#customComponent);
this.#customComponent = undefined;
}
this.removeChild(this.#box);
// Try custom renderer first - it handles its own styling
if (this.customRenderer) {
try {
const component = this.customRenderer(this.message, { expanded: this.#expanded }, theme);
if (component) {
// Custom renderer provides its own styled component
this.#customComponent = component;
this.addChild(component);
return;
}
} catch {
// Fall through to default rendering
}
}
const custom = renderFramedMessage({
message: this.message,
box: this.#box,
expanded: this.#expanded,
customRenderer: this.customRenderer,
collapseAfterLines: HOOK_COLLAPSED_LINES,
});
// Default rendering uses our box
this.addChild(this.#box);
this.#box.clear();
// 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;
if (custom) {
this.#customComponent = custom;
this.addChild(custom);
} else {
text = this.message.content
.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.addChild(this.#box);
}
}
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 {
Container,
fuzzyFilter,
getKeybindings,
Input,
matchesKey,
@@ -18,7 +19,6 @@ import { resolveModelRoleValue } from "../../config/model-resolver";
import type { Settings } from "../../config/settings";
import { type ThemeColor, theme } from "../../modes/theme/theme";
import { getThinkingLevelMetadata } from "../../thinking";
import { fuzzyFilter } from "../../utils/fuzzy";
import { getTabBarTheme } from "../shared";
import { DynamicBorder } from "./dynamic-border";
@@ -1,6 +1,7 @@
import {
type Component,
Container,
fuzzyFilter,
Input,
matchesKey,
padding,
@@ -14,7 +15,6 @@ import { formatBytes } from "@oh-my-pi/pi-utils";
import { theme } from "../../modes/theme/theme";
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
import type { SessionInfo } from "../../session/session-manager";
import { fuzzyFilter } from "../../utils/fuzzy";
import { DynamicBorder } from "./dynamic-border";
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 { shortenPath } from "../../tools/render-utils";
import { openPath } from "../../utils/open";
import { DynamicBorder } from "../components/dynamic-border";
import { MCPAddWizard } from "../components/mcp-add-wizard";
import { parseCommandArgs } from "../shared";
import { theme } from "../theme/theme";
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> {
const { promise: timeoutPromise, reject } = Promise.withResolvers<T>();
@@ -207,11 +207,11 @@ export class MCPCommandController {
break;
}
if (argToken === "--scope") {
const value = tokens[i + 1];
if (!value || (value !== "project" && value !== "user")) {
return { scope, error: "Invalid --scope value. Use project or user." };
const r = readScopeFlag(tokens[i + 1]);
if (!r.ok) {
return { scope, error: r.error };
}
scope = value;
scope = r.scope;
i += 2;
continue;
}
@@ -984,23 +984,7 @@ export class MCPCommandController {
// Show discovered servers (from .claude.json, .cursor/mcp.json, .vscode/mcp.json, etc.)
if (discoveredServers.length > 0) {
// Group by source display name + path
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);
for (const { providerName, shortPath, items: entries } of groupBySource(discoveredServers, e => e.source)) {
lines.push(theme.fg("accent", providerName) + theme.fg("muted", ` (${shortPath}):`));
for (const { name } of entries) {
const state = this.ctx.mcpManager!.getConnectionStatus(name);
@@ -1037,32 +1021,12 @@ export class MCPCommandController {
async #handleRemove(text: string): Promise<void> {
const match = text.match(/^\/mcp\s+(?:remove|rm)\b\s*(.*)$/i);
const rest = match?.[1]?.trim() ?? "";
const tokens = parseCommandArgs(rest);
let name: string | undefined;
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}`);
const parsed = parseRemoveArgs(rest);
if (!parsed.ok) {
this.ctx.showError(parsed.error);
return;
}
const { name, scope } = parsed.value;
if (!name) {
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
*/
#showMessage(text: string): void {
this.ctx.chatContainer.addChild(new Spacer(1));
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();
showCommandMessage(this.ctx, text);
}
}
@@ -3,18 +3,20 @@
*
* 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 { type SSHHost, sshCapability } from "../../capability/ssh";
import { loadCapability } from "../../discovery";
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 { theme } from "../theme/theme";
import type { InteractiveModeContext } from "../types";
type SSHAddScope = "user" | "project";
import {
groupBySource,
parseRemoveArgs,
readScopeFlag,
type ScopeValue,
showCommandMessage,
} from "./command-controller-shared";
export class SSHCommandController {
constructor(private ctx: InteractiveModeContext) {}
@@ -90,7 +92,7 @@ export class SSHCommandController {
}
let name: string | undefined;
let scope: SSHAddScope = "project";
let scope: ScopeValue = "project";
let host: string | undefined;
let username: string | undefined;
let port: number | undefined;
@@ -167,12 +169,12 @@ export class SSHCommandController {
continue;
}
if (argToken === "--scope") {
const value = tokens[i + 1];
if (!value || (value !== "project" && value !== "user")) {
this.ctx.showError("Invalid --scope value. Use project or user.");
const r = readScopeFlag(tokens[i + 1]);
if (!r.ok) {
this.ctx.showError(r.error);
return;
}
scope = value;
scope = r.scope;
i += 2;
continue;
}
@@ -300,23 +302,7 @@ export class SSHCommandController {
// Show discovered hosts (from ssh.json, .ssh.json in project root, etc.)
if (discoveredHosts.length > 0) {
// Group by 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);
for (const { providerName, shortPath, items: hosts } of groupBySource(discoveredHosts, h => h._source)) {
lines.push(
theme.fg("accent", "Discovered") +
theme.fg("muted", ` (${providerName}: ${shortPath}):`) +
@@ -357,33 +343,12 @@ export class SSHCommandController {
async #handleRemove(text: string): Promise<void> {
const match = text.match(/^\/ssh\s+(?:remove|rm)\b\s*(.*)$/i);
const rest = match?.[1]?.trim() ?? "";
const tokens = parseCommandArgs(rest);
let name: string | undefined;
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.");
const parsed = parseRemoveArgs(rest);
if (!parsed.ok) {
this.ctx.showError(parsed.error);
return;
}
scope = value;
i += 2;
continue;
}
this.ctx.showError(`Unknown option: ${token}`);
return;
}
const { name, scope } = parsed.value;
if (!name) {
this.ctx.showError("Host name required. Usage: /ssh remove <name> [--scope project|user]");
return;
@@ -412,10 +377,6 @@ export class SSHCommandController {
* Show a message in the chat
*/
#showMessage(text: string): void {
this.ctx.chatContainer.addChild(new Spacer(1));
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();
showCommandMessage(this.ctx, text);
}
}
+6 -80
View File
@@ -7,8 +7,8 @@
*/
import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
import { sanitizeText } from "@oh-my-pi/pi-natives";
import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
import type { AgentSession } from "../session/agent-session";
import { initializeExtensions } from "./runtime-init";
/**
* 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)
const extensionRunner = session.extensionRunner;
if (extensionRunner) {
extensionRunner.initialize(
// ExtensionActions
{
sendMessage: (message, options) => {
session.sendCustomMessage(message, options).catch(e => {
process.stderr.write(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}\n`);
});
},
sendUserMessage: (content, options) => {
session.sendUserMessage(content, options).catch(e => {
await initializeExtensions(session, {
reportSendError: (action, err) => {
process.stderr.write(
`Extension sendUserMessage failed: ${e instanceof Error ? e.message : String(e)}\n`,
`Extension ${action === "extension_send" ? "sendMessage" : "sendUserMessage"} failed: ${err.message}\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 => {
reportRuntimeError: err => {
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
session.subscribe(event => {
+10 -83
View File
@@ -17,9 +17,9 @@ import type {
ExtensionUIDialogOptions,
ExtensionWidgetOptions,
} from "../../extensibility/extensions";
import { runExtensionCompact, runExtensionSetModel } from "../../extensibility/extensions/compact-handler";
import { type Theme, theme } from "../../modes/theme/theme";
import type { AgentSession } from "../../session/agent-session";
import { initializeExtensions } from "../runtime-init";
import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
import type {
RpcCommand,
@@ -421,91 +421,18 @@ export async function runRpcMode(
setToolUIContext?.(rpcUiContext, true);
// Set up extensions with RPC-based UI context
const extensionRunner = session.extensionRunner;
if (extensionRunner) {
extensionRunner.initialize(
// ExtensionActions
{
sendMessage: (message, options) => {
session.sendCustomMessage(message, options).catch(e => {
output(error(undefined, "extension_send", e.message));
});
await initializeExtensions(session, {
reportSendError: (action, err) => {
output(error(undefined, action, err.message));
},
sendUserMessage: (content, options) => {
session.sendUserMessage(content, options).catch(e => {
output(error(undefined, "extension_send_user", e.message));
});
reportRuntimeError: err => {
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
},
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: () => {
onShutdown: () => {
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 });
uiContext: rpcUiContext,
});
// Emit session_start event
await extensionRunner.emit({
type: "session_start",
});
}
// Output all agent events as JSON
session.subscribe(event => {
@@ -850,8 +777,8 @@ export async function runRpcMode(
async function checkShutdownRequested(): Promise<void> {
if (!shutdownState.requested) return;
if (extensionRunner?.hasHandlers("session_shutdown")) {
await extensionRunner.emit({ type: "session_shutdown" });
if (session.extensionRunner?.hasHandlers("session_shutdown")) {
await session.extensionRunner.emit({ type: "session_shutdown" });
}
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 _markdownThemeRef: Theme | undefined;
let cachedMarkdownTheme: MarkdownTheme | undefined;
let cachedMarkdownThemeRef: Theme | undefined;
export function getMarkdownTheme(): MarkdownTheme {
if (_markdownTheme !== undefined && _markdownThemeRef === theme) {
return _markdownTheme;
if (cachedMarkdownTheme !== undefined && cachedMarkdownThemeRef === theme) {
return cachedMarkdownTheme;
}
const markdownTheme: MarkdownTheme = {
heading: (text: string) => theme.fg("mdHeading", text),
@@ -2365,8 +2365,8 @@ export function getMarkdownTheme(): MarkdownTheme {
}
},
};
_markdownTheme = markdownTheme;
_markdownThemeRef = theme;
cachedMarkdownTheme = markdownTheme;
cachedMarkdownThemeRef = theme;
return markdownTheme;
}
@@ -245,7 +245,7 @@ export class UiHelpers {
break;
}
default: {
const _exhaustive: never = message;
message satisfies never;
}
}
return [];
@@ -4684,8 +4684,6 @@ export class AgentSession {
let hookCompaction: CompactionResult | undefined;
let fromExtension = false;
let hookContext: string[] | undefined;
let hookPrompt: string | undefined;
let preserveData: Record<string, unknown> | undefined;
if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
@@ -4707,23 +4705,7 @@ export class AgentSession {
}
}
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];
}
const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
let summary: string;
let shortSummary: string | undefined;
@@ -4731,14 +4713,13 @@ export class AgentSession {
let tokensBefore: number;
let details: unknown;
if (hookCompaction) {
// Extension provided compaction content
summary = hookCompaction.summary;
shortSummary = hookCompaction.shortSummary;
firstKeptEntryId = hookCompaction.firstKeptEntryId;
tokensBefore = hookCompaction.tokensBefore;
details = hookCompaction.details;
preserveData ??= hookCompaction.preserveData;
if (compactionPrep.kind === "fromHook") {
summary = compactionPrep.summary;
shortSummary = compactionPrep.shortSummary;
firstKeptEntryId = compactionPrep.firstKeptEntryId;
tokensBefore = compactionPrep.tokensBefore;
details = compactionPrep.details;
preserveData = compactionPrep.preserveData;
} else {
// Generate compaction result. Only convert known abort-shaped
// rejections (AbortError raised while the abort signal is set,
@@ -4757,8 +4738,8 @@ export class AgentSession {
customInstructions,
compactionAbortController.signal,
{
promptOverride: hookPrompt,
extraContext: hookContext,
promptOverride: compactionPrep.hookPrompt,
extraContext: compactionPrep.hookContext,
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
},
);
@@ -4767,7 +4748,7 @@ export class AgentSession {
firstKeptEntryId = result.firstKeptEntryId;
tokensBefore = result.tokensBefore;
details = result.details;
preserveData = { ...(preserveData ?? {}), ...(result.preserveData ?? {}) };
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(result.preserveData ?? {}) };
} catch (err) {
if (err instanceof CompactionCancelledError) {
throw err;
@@ -5727,6 +5708,64 @@ export class AgentSession {
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.
*/
@@ -5848,8 +5887,6 @@ export class AgentSession {
let hookCompaction: CompactionResult | undefined;
let fromExtension = false;
let hookContext: string[] | undefined;
let hookPrompt: string | undefined;
let preserveData: Record<string, unknown> | undefined;
if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
@@ -5878,23 +5915,7 @@ export class AgentSession {
}
}
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];
}
const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
let summary: string;
let shortSummary: string | undefined;
@@ -5902,14 +5923,13 @@ export class AgentSession {
let tokensBefore: number;
let details: unknown;
if (hookCompaction) {
// Extension provided compaction content
summary = hookCompaction.summary;
shortSummary = hookCompaction.shortSummary;
firstKeptEntryId = hookCompaction.firstKeptEntryId;
tokensBefore = hookCompaction.tokensBefore;
details = hookCompaction.details;
preserveData ??= hookCompaction.preserveData;
if (compactionPrep.kind === "fromHook") {
summary = compactionPrep.summary;
shortSummary = compactionPrep.shortSummary;
firstKeptEntryId = compactionPrep.firstKeptEntryId;
tokensBefore = compactionPrep.tokensBefore;
details = compactionPrep.details;
preserveData = compactionPrep.preserveData;
} else {
const candidates = this.#getCompactionModelCandidates(availableModels);
const retrySettings = this.settings.getGroup("retry");
@@ -5924,8 +5944,8 @@ export class AgentSession {
while (true) {
try {
compactResult = await compact(preparation, candidate, apiKey, undefined, autoCompactionSignal, {
promptOverride: hookPrompt,
extraContext: hookContext,
promptOverride: compactionPrep.hookPrompt,
extraContext: compactionPrep.hookContext,
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
metadata: this.agent.metadataForProvider(candidate.provider),
initiatorOverride: "agent",
@@ -6003,7 +6023,7 @@ export class AgentSession {
firstKeptEntryId = compactResult.firstKeptEntryId;
tokensBefore = compactResult.tokensBefore;
details = compactResult.details;
preserveData = { ...(preserveData ?? {}), ...(compactResult.preserveData ?? {}) };
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(compactResult.preserveData ?? {}) };
}
if (autoCompactionSignal.aborted) {
@@ -364,8 +364,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
attribution: m.attribution ?? "agent",
};
default:
// biome-ignore lint/correctness/noSwitchDeclarations: fine
const _exhaustiveCheck: never = m;
m satisfies never;
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 { RenderResultOptions } from "../extensibility/custom-tools/types";
import { computeLineHash, HL_BODY_SEP } from "../hashline/hash";
import { InternalUrlRouter } from "../internal-urls";
import type { Theme } from "../modes/theme/theme";
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 type { ToolSession } from ".";
import { createFileRecorder, formatResultPath } from "./file-recorder";
import { formatGroupedFiles } from "./grouped-file-output";
import type { OutputMeta } from "./output-meta";
import { resolveToolSearchScope } from "./path-utils";
import {
formatPathRelativeToCwd,
hasGlobPathChars,
normalizePathLikeInput,
parseSearchPath,
partitionExistingPaths,
resolveExplicitSearchPaths,
resolveToCwd,
} from "./path-utils";
import {
appendParseErrorsBulletList,
createCachedComponent,
dedupeParseErrors,
formatCodeFrameLine,
formatCount,
formatEmptyMessage,
formatErrorMessage,
formatParseErrors,
PARSE_ERRORS_LIMIT,
formatParseErrorsCountLabel,
PREVIEW_LIMITS,
splitGroupsByBlankLine,
} from "./render-utils";
import { queueResolveHandler } from "./resolve";
import { ToolError } from "./tool-errors";
@@ -205,63 +199,12 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
const normalizedRewrites = Object.fromEntries(ops);
const maxFiles = $envpos("PI_MAX_AST_FILES", 1000);
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
let searchPath: string;
let scopePath: string;
let globFilter: string | undefined;
let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
const rawPaths = params.paths.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[] = [];
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 scope = await resolveToolSearchScope({
rawPaths: params.paths,
cwd: this.session.cwd,
internalUrlAction: "rewrite",
});
const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
const result = await runAstEditOnce(multiTargets, resolvedSearchPath, globFilter, {
rewrites: normalizedRewrites,
@@ -502,15 +445,7 @@ export const astEditToolRenderer = {
if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
const header = renderStatusLine({ icon: "warning", title: "AST Edit", description, meta }, uiTheme);
const lines = [header, formatEmptyMessage("No replacements made", uiTheme)];
if (details?.parseErrors?.length) {
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`));
}
}
appendParseErrorsBulletList(lines, details?.parseErrors, uiTheme);
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 textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
const rawLines = 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 allGroups = splitGroupsByBlankLine(textContent.split("\n"));
const changeGroups = allGroups.filter(
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"));
}
if (details?.parseErrors?.length) {
const total = details.parseErrors.length;
const label =
total > PARSE_ERRORS_LIMIT
? `${PARSE_ERRORS_LIMIT} / ${total} parse issues`
: `${total} parse issue${total !== 1 ? "s" : ""}`;
extraLines.push(uiTheme.fg("warning", label));
extraLines.push(uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors)));
}
let cached: RenderCache | undefined;
return {
render(width: number): string[] {
const { expanded } = options;
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
return createCachedComponent(
() => options.expanded,
width => {
const changeLines = renderTreeList(
{
items: changeGroups,
expanded,
expanded: options.expanded,
maxCollapsed: changeGroups.length,
maxCollapsedLines: COLLAPSED_CHANGE_LIMIT,
itemType: "change",
@@ -591,14 +497,9 @@ export const astEditToolRenderer = {
},
uiTheme,
);
const rendered = [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
cached = { key, lines: rendered };
return rendered;
return [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
invalidate() {
cached = undefined;
},
};
);
},
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 { type Static, Type } from "@sinclair/typebox";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { InternalUrlRouter } from "../internal-urls";
import type { Theme } from "../modes/theme/theme";
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 type { ToolSession } from ".";
import { createFileRecorder, formatResultPath } from "./file-recorder";
import { formatGroupedFiles } from "./grouped-file-output";
import { formatMatchLine } from "./match-line-format";
import type { OutputMeta } from "./output-meta";
import { resolveToolSearchScope } from "./path-utils";
import {
formatPathRelativeToCwd,
hasGlobPathChars,
normalizePathLikeInput,
parseSearchPath,
partitionExistingPaths,
resolveExplicitSearchPaths,
resolveToCwd,
} from "./path-utils";
import {
appendParseErrorsBulletList,
createCachedComponent,
dedupeParseErrors,
formatCodeFrameLine,
formatCount,
formatEmptyMessage,
formatErrorMessage,
formatParseErrors,
PARSE_ERRORS_LIMIT,
formatParseErrorsCountLabel,
PREVIEW_LIMITS,
splitGroupsByBlankLine,
} from "./render-utils";
import { ToolError } from "./tool-errors";
import { toolResult } from "./tool-result";
@@ -150,64 +144,12 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
if (!Number.isFinite(skip) || skip < 0) {
throw new ToolError("skip must be a non-negative number");
}
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
let searchPath: string;
let scopePath: string;
let globFilter: string | undefined;
let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
const rawPaths = params.paths.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[] = [];
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 scope = await resolveToolSearchScope({
rawPaths: params.paths,
cwd: this.session.cwd,
internalUrlAction: "search",
});
const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
const DEFAULT_AST_LIMIT = 50;
const result = multiTargets
@@ -388,13 +330,7 @@ export const astGrepToolRenderer = {
const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
if (details?.parseErrors?.length) {
lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `paths` before concluding absence"));
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`));
}
appendParseErrorsBulletList(lines, details.parseErrors, uiTheme);
}
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 rawLines = 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 allGroups = splitGroupsByBlankLine(textContent.split("\n"));
const matchGroups = allGroups.filter(
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"));
}
if (details?.parseErrors?.length) {
const total = details.parseErrors.length;
const label =
total > PARSE_ERRORS_LIMIT
? `${PARSE_ERRORS_LIMIT} / ${total} parse issues`
: `${total} parse issue${total !== 1 ? "s" : ""}`;
extraLines.push(uiTheme.fg("warning", label));
extraLines.push(uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors)));
}
let cached: RenderCache | undefined;
return {
render(width: number): string[] {
const { expanded } = options;
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
return createCachedComponent(
() => options.expanded,
width => {
const matchLines = renderTreeList(
{
items: matchGroups,
expanded,
expanded: options.expanded,
maxCollapsed: matchGroups.length,
maxCollapsedLines: COLLAPSED_MATCH_LIMIT,
itemType: "match",
@@ -473,14 +380,9 @@ export const astGrepToolRenderer = {
},
uiTheme,
);
const rendered = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
cached = { key, lines: rendered };
return rendered;
return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
invalidate() {
cached = undefined;
},
};
);
},
mergeCallAndResult: true,
};
@@ -3,7 +3,7 @@ import { Process, ProcessStatus } from "@oh-my-pi/pi-natives";
import type { Browser, Page } from "puppeteer-core";
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;
/**
@@ -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
* malformed.
*/
export function findCdpPortInArgs(args: string[]): number | null {
function findCdpPortInArgs(args: string[]): number | null {
for (const arg of args) {
const m = /^--remote-debugging-port=(\d+)$/.exec(arg);
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. */
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 probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout;
try {
@@ -30,12 +30,12 @@ export const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1
* connection dropped, etc.).
*/
export const BROWSER_PROTOCOL_TIMEOUT_MS = 60_000;
export const STEALTH_IGNORE_DEFAULT_ARGS = [
const STEALTH_IGNORE_DEFAULT_ARGS = [
"--disable-extensions",
"--disable-default-apps",
"--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__";
@@ -82,7 +82,7 @@ export async function loadPuppeteerInWorker(safeDir: string): Promise<typeof Pup
* The browser is cached under ~/.omp/puppeteer (getPuppeteerDir).
*/
let chromiumExecutablePromise: Promise<string | undefined> | undefined;
export async function ensureChromiumExecutable(): Promise<string | undefined> {
async function ensureChromiumExecutable(): Promise<string | undefined> {
const sysChrome = resolveSystemChromium();
if (sysChrome) return sysChrome;
const envPath = process.env.PUPPETEER_EXECUTABLE_PATH;
@@ -138,7 +138,7 @@ export async function ensureChromiumExecutable(): Promise<string | undefined> {
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 {
try {
@@ -209,19 +209,19 @@ function systemChromiumCandidates(): string[] {
return candidates;
}
export function resolveSystemChromium(): string | undefined {
if (_resolvedChromium !== undefined) return _resolvedChromium ?? undefined;
function resolveSystemChromium(): string | undefined {
if (resolvedChromium !== undefined) return resolvedChromium ?? undefined;
const seen = new Set<string>();
for (const candidate of systemChromiumCandidates()) {
if (!candidate || seen.has(candidate)) continue;
seen.add(candidate);
if (isExecutableFile(candidate)) {
_resolvedChromium = candidate;
resolvedChromium = candidate;
logger.debug("Using system Chrome/Chromium", { path: candidate });
return candidate;
}
}
_resolvedChromium = null;
resolvedChromium = null;
return undefined;
}
@@ -26,10 +26,6 @@ export interface BrowserHandle {
const browsers = new Map<string, BrowserHandle>();
export function listBrowsers(): BrowserHandle[] {
return [...browsers.values()];
}
function browserKey(kind: BrowserKind): string {
switch (kind.kind) {
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.browser.connected) {
try {
@@ -89,10 +89,6 @@ export function getTab(name: string): TabSession | undefined {
return tabs.get(name);
}
export function listTabs(): TabSession[] {
return [...tabs.values()];
}
export async function acquireTab(
name: string,
browser: BrowserHandle,
+20 -38
View File
@@ -12,15 +12,7 @@ import { InternalUrlRouter } from "../internal-urls";
import type { Theme } from "../modes/theme/theme";
import findDescription from "../prompts/tools/find.md" with { type: "text" };
import { type TruncationResult, truncateHead } from "../session/streaming-output";
import {
Ellipsis,
Hasher,
type RenderCache,
renderFileList,
renderStatusLine,
renderTreeList,
truncateToWidth,
} from "../tui";
import { Ellipsis, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import type { ToolSession } from ".";
import { applyListLimit } from "./list-limit";
import { formatFullOutputReference, type OutputMeta } from "./output-meta";
@@ -33,7 +25,13 @@ import {
resolveExplicitFindPatterns,
resolveToCwd,
} 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 { toolResult } from "./tool-result";
@@ -401,30 +399,22 @@ export const findToolRenderer = {
},
uiTheme,
);
let cached: RenderCache | undefined;
return {
render(width: number): string[] {
const { expanded } = options;
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
return createCachedComponent(
() => options.expanded,
width => {
const listLines = renderTreeList(
{
items: lines,
expanded,
expanded: options.expanded,
maxCollapsed: COLLAPSED_LIST_LIMIT,
itemType: "file",
renderItem: line => uiTheme.fg("accent", line),
},
uiTheme,
);
const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
cached = { key, lines: result };
return result;
return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
invalidate() {
cached = undefined;
},
};
);
}
const fileCount = details?.fileCount ?? 0;
@@ -467,28 +457,20 @@ export const findToolRenderer = {
}
if (missingNote) extraLines.push(missingNote);
let cached: RenderCache | undefined;
return {
render(width: number): string[] {
const { expanded } = options;
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
return createCachedComponent(
() => options.expanded,
width => {
const fileLines = renderFileList(
{
files: files.map(entry => ({ path: entry, isDirectory: entry.endsWith("/") })),
expanded,
expanded: options.expanded,
maxCollapsed: COLLAPSED_LIST_LIMIT,
},
uiTheme,
);
const result = [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
cached = { key, lines: result };
return result;
return [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
invalidate() {
cached = undefined;
},
};
);
},
mergeCallAndResult: true,
};
@@ -1,7 +1,8 @@
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 { type Static, Type } from "@sinclair/typebox";
import { extractTextContent } from "../commit/utils";
import { expandRoleAlias, resolveModelFromString } from "../config/model-resolver";
import inspectImageDescription from "../prompts/tools/inspect-image.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;
}
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> {
readonly name = "inspect_image";
readonly label = "InspectImage";
@@ -151,7 +144,7 @@ export class InspectImageTool implements AgentTool<typeof inspectImageSchema, In
throw new ToolError("inspect_image request aborted.");
}
const text = extractResponseText(response);
const text = extractTextContent(response);
if (!text) {
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 url from "node:url";
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 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;
}
// =============================================================================
// 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 mimeType = imageMetadata?.mimeType;
const ext = path.extname(absolutePath).toLowerCase();
const _hasEditTool = this.session.hasEditTool ?? true;
const _language = getLanguageFromPath(absolutePath);
const shouldConvertWithMarkit = CONVERTIBLE_EXTENSIONS.has(ext);
// Read the file based on type
let content: Array<TextContent | ImageContent> | undefined;
@@ -9,10 +9,12 @@ import * as os from "node:os";
import * as path from "node:path";
import type { ToolCallContext } from "@oh-my-pi/pi-agent-core";
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 { pluralize } from "@oh-my-pi/pi-utils";
import { settings } from "../config/settings";
import type { Theme } from "../modes/theme/theme";
import { Hasher } from "../tui/utils";
import { formatDimensionNote, type ResizedImage } from "../utils/image-resize";
export { Ellipsis } from "@oh-my-pi/pi-natives";
@@ -642,6 +644,96 @@ export function formatParseErrors(errors: string[]): string[] {
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
// =============================================================================
+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 { getFileReadCache } from "../edit/file-read-cache";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { InternalUrlRouter } from "../internal-urls";
import type { Theme } from "../modes/theme/theme";
import searchDescription from "../prompts/tools/search.md" with { type: "text" };
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 type { ToolSession } from ".";
import { createFileRecorder, formatResultPath } from "./file-recorder";
import { formatGroupedFiles } from "./grouped-file-output";
import { formatMatchLine } from "./match-line-format";
import { formatFullOutputReference, type OutputMeta } from "./output-meta";
import { resolveToolSearchScope } from "./path-utils";
import {
formatPathRelativeToCwd,
hasGlobPathChars,
normalizePathLikeInput,
parseSearchPath,
partitionExistingPaths,
resolveExplicitSearchPaths,
resolveToCwd,
} from "./path-utils";
import {
createCachedComponent,
formatCodeFrameLine,
formatCount,
formatEmptyMessage,
formatErrorMessage,
PREVIEW_LIMITS,
splitGroupsByBlankLine,
} from "./render-utils";
import { ToolError } from "./tool-errors";
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 effectiveMultiline = patternHasNewline;
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
let searchPath: string;
let scopePath: string;
let exactFilePaths: string[] | undefined;
let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
let globFilter: string | undefined;
const rawPaths = paths.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[] = [];
// Absolute filesystem paths whose source is immutable (e.g. artifact://,
// pi://, skill://). Hashline anchors are suppressed for these on a
// per-file basis, leaving editable mixed-in files untouched.
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 search internal URL without a backing file: ${rawPath}`);
}
if (resource.immutable) {
immutableSourcePaths.add(path.resolve(resource.sourcePath));
}
resolvedPathInputs.push(resource.sourcePath);
}
const scope = await resolveToolSearchScope({
rawPaths: paths,
cwd: this.session.cwd,
internalUrlAction: "search",
trackImmutableSources: true,
surfaceExactFilePaths: true,
multipathStatHint: " (`paths` entries must each exist relative to cwd)",
});
const {
searchPath,
scopePath,
isDirectory,
multiTargets,
exactFilePaths,
missingPaths,
immutableSourcePaths,
} = scope;
const { globFilter } = scope;
const baseDisplayMode = resolveFileDisplayMode(this.session);
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;
// 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)] },
uiTheme,
);
let cached: RenderCache | undefined;
return {
render(width: number): string[] {
const { expanded } = options;
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
return createCachedComponent(
() => options.expanded,
width => {
const listLines = renderTreeList(
{
items: lines,
expanded,
expanded: options.expanded,
maxCollapsed: COLLAPSED_TEXT_LIMIT,
maxCollapsedLines: COLLAPSED_TEXT_LIMIT,
itemType: "item",
@@ -548,14 +486,9 @@ export const searchToolRenderer = {
},
uiTheme,
);
const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
cached = { key, lines: result };
return result;
return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
invalidate() {
cached = undefined;
},
};
);
}
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 rawLines = 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 matchGroups = splitGroupsByBlankLine(textContent.split("\n"));
const renderedFileLimit = details?.fileLimitReached;
const renderedPerFileLimit = details?.perFileLimitReached;
@@ -629,17 +541,14 @@ export const searchToolRenderer = {
}
if (missingNote) extraLines.push(missingNote);
let cached: RenderCache | undefined;
return {
render(width: number): string[] {
const { expanded } = options;
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
return createCachedComponent(
() => options.expanded,
width => {
const collapsedMatchLineBudget = Math.max(COLLAPSED_TEXT_LIMIT - extraLines.length, 0);
const matchLines = renderTreeList(
{
items: matchGroups,
expanded,
expanded: options.expanded,
maxCollapsed: matchGroups.length,
maxCollapsedLines: collapsedMatchLineBudget,
itemType: "match",
@@ -652,14 +561,9 @@ export const searchToolRenderer = {
},
uiTheme,
);
const result = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
cached = { key, lines: result };
return result;
return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
invalidate() {
cached = undefined;
},
};
);
},
mergeCallAndResult: true,
};
@@ -10,6 +10,7 @@ import path from "node:path";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { ImageContent } from "@oh-my-pi/pi-ai";
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 { formatHashLines } from "../hashline/hash";
import type { FileMentionMessage } from "../session/messages";
@@ -20,7 +21,6 @@ import {
truncateHeadBytes,
} from "../session/streaming-output";
import { resolveReadPath } from "../tools/path-utils";
import { fuzzyMatch } from "./fuzzy";
import { formatDimensionNote, resizeImage } from "./image-resize";
/** 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;
}
export function tokensToReplay(tokens: readonly VimKeyToken[]): string[] {
return tokens.map(token => token.value);
}
export function replayTokens(values: readonly string[]): VimKeyToken[] {
return values.map((value, index) => ({
value,
@@ -136,16 +132,3 @@ export function replayTokens(values: readonly string[]): VimKeyToken[] {
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_DEFAULT_VIEWPORT_LINES = 10;
export const VIM_TAB_DISPLAY = "→";
const VIM_TAB_DISPLAY = "→";
const VIM_INLINE_CURSOR = "▏";
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 };
}
export function comparePositions(left: Position, right: Position): number {
function comparePositions(left: Position, right: Position): number {
if (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";
import type { Model } from "@oh-my-pi/pi-ai";
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 type { PlanModeState } from "../src/plan-mode/state";
import type { AgentSession, AgentSessionEvent } from "../src/session/agent-session";
@@ -316,7 +316,7 @@ afterEach(async () => {
setAgentDir(fallbackAgentDir);
delete process.env.PI_CODING_AGENT_DIR;
}
_resetSettingsForTest();
resetSettingsForTest();
for (const root of cleanupRoots.splice(0)) {
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 { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
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 { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
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()}`);
fs.mkdirSync(tempDir, { recursive: true });
_resetSettingsForTest();
resetSettingsForTest();
// Fresh isolated Settings rooted in tempDir so we don't pick up the
// developer's real config (snapshots, shell prefix, etc).
await Settings.init({ inMemory: true, cwd: tempDir });
@@ -243,7 +243,7 @@ describe("BashTool through AgentSession runs children in their own session (e2e)
});
afterAll(() => {
_resetSettingsForTest();
resetSettingsForTest();
});
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 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 os from "node:os";
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 { getProjectAgentDir, Snowflake } from "@oh-my-pi/pi-utils";
import { YAML } from "bun";
@@ -13,7 +13,7 @@ describe("autocompleteMaxVisible setting", () => {
let projectDir: string;
beforeEach(() => {
_resetSettingsForTest();
resetSettingsForTest();
testDir = path.join(os.tmpdir(), "test-autocomplete-settings", Snowflake.next());
agentDir = path.join(testDir, "agent");
projectDir = path.join(testDir, "project");
@@ -22,7 +22,7 @@ describe("autocompleteMaxVisible setting", () => {
});
afterEach(() => {
_resetSettingsForTest();
resetSettingsForTest();
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true });
}
@@ -34,7 +34,7 @@ describe("autocompleteMaxVisible setting", () => {
await settings.flush();
// Re-init to verify persistence
_resetSettingsForTest();
resetSettingsForTest();
const settings2 = await Settings.init({ cwd: projectDir, agentDir });
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 os from "node:os";
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 { 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";
@@ -20,12 +20,12 @@ describe("executeBash", () => {
beforeEach(async () => {
tempDir = makeTempDir();
_resetSettingsForTest();
resetSettingsForTest();
await Settings.init({ inMemory: true, cwd: tempDir });
});
afterEach(() => {
_resetSettingsForTest();
resetSettingsForTest();
vi.restoreAllMocks();
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true });
@@ -4,21 +4,21 @@ import * as os from "node:os";
import * as path from "node:path";
import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils";
import { runConfigCommand } from "../src/cli/config-cli";
import { _resetSettingsForTest } from "../src/config/settings";
import { resetSettingsForTest } from "../src/config/settings";
let testAgentDir = "";
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
const fallbackAgentDir = path.join(getConfigRootDir(), "agent");
beforeEach(async () => {
_resetSettingsForTest();
resetSettingsForTest();
testAgentDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-config-cli-"));
setAgentDir(testAgentDir);
});
afterEach(async () => {
vi.restoreAllMocks();
_resetSettingsForTest();
resetSettingsForTest();
if (originalAgentDir) {
setAgentDir(originalAgentDir);
} else {
@@ -2,21 +2,21 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
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";
describe("indentation resolver", () => {
let tempDir = "";
beforeEach(async () => {
_resetSettingsForTest();
resetSettingsForTest();
setDefaultTabWidth(3);
tempDir = path.join(os.tmpdir(), "pi-spacing", Snowflake.next());
await fs.mkdir(tempDir, { recursive: true });
});
afterEach(async () => {
_resetSettingsForTest();
resetSettingsForTest();
setDefaultTabWidth(3);
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 os from "node:os";
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 {
applyHashlineEdits,
buildCompactHashlineDiffPreview,
@@ -27,7 +27,7 @@ import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { Value } from "@sinclair/typebox/value";
beforeAll(async () => {
_resetSettingsForTest();
resetSettingsForTest();
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 path from "node:path";
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";
describe("disabledExtensions runtime filtering", () => {
@@ -12,7 +12,7 @@ describe("disabledExtensions runtime filtering", () => {
let originalHome: string | undefined;
beforeEach(async () => {
_resetSettingsForTest();
resetSettingsForTest();
originalHome = process.env.HOME;
tempHomeDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-disabled-ext-home-"));
process.env.HOME = tempHomeDir;
@@ -32,7 +32,7 @@ describe("disabledExtensions runtime filtering", () => {
});
afterEach(async () => {
_resetSettingsForTest();
resetSettingsForTest();
vi.restoreAllMocks();
if (originalHome === undefined) {
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 { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
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 { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
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
// must initialize it (the per-tool `Settings.isolated(...)` we pass into the
// EditTool isn't what the guard sees).
_resetSettingsForTest();
resetSettingsForTest();
await Settings.init({ inMemory: true, cwd: tempDir, overrides: { "edit.blockAutoGenerated": true } });
try {
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
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 {
DEFAULT_FUZZY_THRESHOLD,
EditTool,
@@ -40,13 +40,13 @@ const noopBeginDeferred = (_p: string) => ({
let tempDir: string;
beforeEach(async () => {
_resetSettingsForTest();
resetSettingsForTest();
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-edit-diff-"));
await Settings.init({ inMemory: true, cwd: tempDir });
});
afterEach(async () => {
_resetSettingsForTest();
resetSettingsForTest();
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 { discoverAndLoadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader";
import {
__test_setExtensionHandlerTimeoutMs,
EXTENSION_HANDLER_TIMEOUT_MS,
ExtensionRunner,
testSetExtensionHandlerTimeoutMs,
} from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
@@ -34,7 +34,7 @@ describe("ExtensionRunner", () => {
});
afterEach(() => {
__test_setExtensionHandlerTimeoutMs(EXTENSION_HANDLER_TIMEOUT_MS);
testSetExtensionHandlerTimeoutMs(EXTENSION_HANDLER_TIMEOUT_MS);
authStorage.close();
tempDir.removeSync();
});
@@ -644,7 +644,7 @@ describe("ExtensionRunner", () => {
runner.onError(err => {
errors.push(err);
});
__test_setExtensionHandlerTimeoutMs(50);
testSetExtensionHandlerTimeoutMs(50);
const startedAt = performance.now();
await runner.emit({ type: "session_start" });
+1 -1
View File
@@ -1,5 +1,5 @@
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", () => {
test("empty query matches everything with score 0", () => {
@@ -9,7 +9,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
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 { HindsightApi } from "@oh-my-pi/pi-coding-agent/hindsight/client";
import type { HindsightSessionState } from "@oh-my-pi/pi-coding-agent/hindsight/state";
@@ -76,7 +76,7 @@ function makeFakeSession(deps: FakeSessionDeps) {
describe("hindsightBackend.start", () => {
beforeEach(() => {
_resetSettingsForTest();
resetSettingsForTest();
});
afterEach(() => {
@@ -235,7 +235,7 @@ describe("hindsightBackend.start", () => {
describe("hindsightBackend.preCompactionContext", () => {
beforeEach(() => {
_resetSettingsForTest();
resetSettingsForTest();
});
afterEach(() => {
@@ -297,7 +297,7 @@ describe("hindsightBackend.preCompactionContext", () => {
describe("hindsightBackend first-turn injection", () => {
beforeEach(() => {
_resetSettingsForTest();
resetSettingsForTest();
});
afterEach(() => {
@@ -480,7 +480,7 @@ describe("hindsightBackend first-turn injection", () => {
describe("hindsightBackend.clear", () => {
beforeEach(() => {
_resetSettingsForTest();
resetSettingsForTest();
});
afterEach(() => {
@@ -8,7 +8,7 @@
*/
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 type { HindsightConfig } from "@oh-my-pi/pi-coding-agent/hindsight/config";
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", () => {
beforeEach(() => {
_resetSettingsForTest();
resetSettingsForTest();
registeredState = undefined;
});
@@ -122,7 +122,7 @@ describe("Hindsight tool factories", () => {
describe("retain.execute", () => {
beforeEach(() => {
_resetSettingsForTest();
resetSettingsForTest();
registeredState = undefined;
});
@@ -210,7 +210,7 @@ describe("retain.execute", () => {
describe("recall.execute", () => {
beforeEach(() => {
_resetSettingsForTest();
resetSettingsForTest();
registeredState = undefined;
});
@@ -278,7 +278,7 @@ describe("recall.execute", () => {
describe("reflect.execute", () => {
beforeEach(() => {
_resetSettingsForTest();
resetSettingsForTest();
registeredState = undefined;
});
@@ -1,7 +1,7 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
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 { TempDir } from "@oh-my-pi/pi-utils";
import { ModelRegistry } from "../src/config/model-registry";
@@ -24,7 +24,7 @@ describe("InteractiveMode.setEditorComponent", () => {
});
beforeEach(async () => {
_resetSettingsForTest();
resetSettingsForTest();
tempDir = TempDir.createSync("@pi-editor-component-");
await Settings.init({ inMemory: true, cwd: tempDir.path() });
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
@@ -56,7 +56,7 @@ describe("InteractiveMode.setEditorComponent", () => {
await session?.dispose();
authStorage?.close();
tempDir?.removeSync();
_resetSettingsForTest();
resetSettingsForTest();
});
it("replaces the editor and rebinds interactive handlers", () => {

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