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:
@@ -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": {}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
return streamOpenAIAnthropicShim(model, context, options, {
|
||||
anthropicBaseUrl: KIMI_ANTHROPIC_BASE_URL,
|
||||
defaultFormat: "anthropic",
|
||||
extraHeaders: getKimiCommonHeaders,
|
||||
});
|
||||
|
||||
for await (const event of innerStream) {
|
||||
stream.push(event);
|
||||
}
|
||||
} else {
|
||||
// OpenAI format - use original model with Kimi headers
|
||||
const reasoningEffort = options?.reasoning;
|
||||
const innerStream = streamOpenAICompletions(model, context, {
|
||||
apiKey: options?.apiKey,
|
||||
temperature: options?.temperature,
|
||||
topP: options?.topP,
|
||||
topK: options?.topK,
|
||||
minP: options?.minP,
|
||||
presencePenalty: options?.presencePenalty,
|
||||
repetitionPenalty: options?.repetitionPenalty,
|
||||
maxTokens: options?.maxTokens ?? model.maxTokens,
|
||||
signal: options?.signal,
|
||||
headers: mergedHeaders,
|
||||
sessionId: options?.sessionId,
|
||||
onPayload: options?.onPayload,
|
||||
onResponse: options?.onResponse,
|
||||
onSseEvent: options?.onSseEvent,
|
||||
reasoning: reasoningEffort,
|
||||
});
|
||||
|
||||
for await (const event of innerStream) {
|
||||
stream.push(event);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
stream.push({
|
||||
type: "error",
|
||||
reason: "error",
|
||||
error: createProviderErrorMessage(model, err),
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is a Kimi Code model.
|
||||
*/
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
return streamOpenAIAnthropicShim(model, context, options, {
|
||||
anthropicBaseUrl: SYNTHETIC_NEW_ANTHROPIC_BASE_URL,
|
||||
openaiBaseUrl: SYNTHETIC_NEW_BASE_URL,
|
||||
defaultFormat: "openai",
|
||||
});
|
||||
|
||||
for await (const event of innerStream) {
|
||||
stream.push(event);
|
||||
}
|
||||
} else {
|
||||
// OpenAI format - use original model with Synthetic headers
|
||||
const syntheticModel: Model<"openai-completions"> = {
|
||||
...model,
|
||||
baseUrl: SYNTHETIC_NEW_BASE_URL,
|
||||
headers: mergedHeaders,
|
||||
};
|
||||
|
||||
const reasoningEffort = options?.reasoning;
|
||||
const innerStream = streamOpenAICompletions(syntheticModel, context, {
|
||||
apiKey: options?.apiKey,
|
||||
temperature: options?.temperature,
|
||||
topP: options?.topP,
|
||||
topK: options?.topK,
|
||||
minP: options?.minP,
|
||||
presencePenalty: options?.presencePenalty,
|
||||
repetitionPenalty: options?.repetitionPenalty,
|
||||
maxTokens: options?.maxTokens ?? model.maxTokens,
|
||||
signal: options?.signal,
|
||||
headers: mergedHeaders,
|
||||
sessionId: options?.sessionId,
|
||||
onPayload: options?.onPayload,
|
||||
onResponse: options?.onResponse,
|
||||
onSseEvent: options?.onSseEvent,
|
||||
reasoning: reasoningEffort,
|
||||
});
|
||||
|
||||
for await (const event of innerStream) {
|
||||
stream.push(event);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
stream.push({
|
||||
type: "error",
|
||||
reason: "error",
|
||||
error: createProviderErrorMessage(model, err),
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is a Synthetic model.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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.addChild(this.#box);
|
||||
}
|
||||
|
||||
this.#box.addChild(
|
||||
new Markdown(text, 0, 0, getMarkdownTheme(), {
|
||||
color: (value: string) => theme.fg("customMessageText", value),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
*/
|
||||
|
||||
import { sanitizeText } from "@oh-my-pi/pi-natives";
|
||||
import { 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");
|
||||
this.addChild(this.#box);
|
||||
}
|
||||
|
||||
// Limit lines when collapsed
|
||||
if (!this.#expanded) {
|
||||
const lines = text.split("\n");
|
||||
if (lines.length > 5) {
|
||||
text = `${lines.slice(0, 5).join("\n")}\n…`;
|
||||
}
|
||||
}
|
||||
|
||||
this.#box.addChild(
|
||||
new Markdown(text, 0, 0, getMarkdownTheme(), {
|
||||
color: (text: string) => theme.fg("customMessageText", text),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Shared rendering for extension/hook custom message frames.
|
||||
*
|
||||
* Both `CustomMessageComponent` and `HookMessageComponent` wrap a
|
||||
* `Spacer(1) + Box` layout, try a user-supplied renderer first, and fall
|
||||
* back to a label + markdown body when the renderer returns nothing or
|
||||
* throws. The only meaningful difference is that hook messages collapse to
|
||||
* the first N lines when not expanded; extension messages render in full.
|
||||
*/
|
||||
|
||||
import type { TextContent } from "@oh-my-pi/pi-ai";
|
||||
import type { Box, Component } from "@oh-my-pi/pi-tui";
|
||||
import { Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
|
||||
import { getMarkdownTheme, type Theme, theme } from "../../modes/theme/theme";
|
||||
|
||||
/** Message shape consumed by the shared frame. */
|
||||
export interface FramedMessage {
|
||||
customType: string;
|
||||
content: string | (TextContent | { type: string })[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Callable signature shared by `MessageRenderer` (extensions) and
|
||||
* `HookMessageRenderer` (hooks). Both narrow `message` to their own type;
|
||||
* this signature is the structural intersection callers can hand off here.
|
||||
*/
|
||||
export type FramedRenderer<M extends FramedMessage> = (
|
||||
message: M,
|
||||
options: { expanded: boolean },
|
||||
theme: Theme,
|
||||
) => Component | undefined;
|
||||
|
||||
export interface RebuildFrameOptions<M extends FramedMessage> {
|
||||
message: M;
|
||||
box: Box;
|
||||
expanded: boolean;
|
||||
/** Collapse the markdown body to this many lines when `expanded` is false. Omit to never collapse. */
|
||||
collapseAfterLines?: number;
|
||||
customRenderer?: FramedRenderer<M>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt the custom renderer; on failure or undefined return, populate
|
||||
* `box` with the default `[customType]` label + markdown body and return
|
||||
* undefined. When the custom renderer succeeds, return its Component so the
|
||||
* caller can mount it and skip the default box.
|
||||
*/
|
||||
export function renderFramedMessage<M extends FramedMessage>(opts: RebuildFrameOptions<M>): Component | undefined {
|
||||
if (opts.customRenderer) {
|
||||
try {
|
||||
const component = opts.customRenderer(opts.message, { expanded: opts.expanded }, theme);
|
||||
if (component) return component;
|
||||
} catch {
|
||||
// Fall through to default rendering
|
||||
}
|
||||
}
|
||||
|
||||
opts.box.clear();
|
||||
|
||||
const label = theme.fg("customMessageLabel", theme.bold(`[${opts.message.customType}]`));
|
||||
opts.box.addChild(new Text(label, 0, 0));
|
||||
opts.box.addChild(new Spacer(1));
|
||||
|
||||
let text: string;
|
||||
if (typeof opts.message.content === "string") {
|
||||
text = opts.message.content;
|
||||
} else {
|
||||
text = opts.message.content
|
||||
.filter((c): c is TextContent => c.type === "text")
|
||||
.map(c => c.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
if (!opts.expanded && opts.collapseAfterLines !== undefined) {
|
||||
const lines = text.split("\n");
|
||||
if (lines.length > opts.collapseAfterLines) {
|
||||
text = `${lines.slice(0, opts.collapseAfterLines).join("\n")}\n…`;
|
||||
}
|
||||
}
|
||||
|
||||
opts.box.addChild(
|
||||
new Markdown(text, 0, 0, getMarkdownTheme(), {
|
||||
color: (value: string) => theme.fg("customMessageText", value),
|
||||
}),
|
||||
);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import { getSupportedEfforts, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
|
||||
import {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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})`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user