@kolisachint/hoocode-agent 0.5.14 → 0.5.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/dist/core/extensions/loader.d.ts +2 -5
- package/dist/core/extensions/loader.d.ts.map +1 -1
- package/dist/core/extensions/loader.js +5 -0
- package/dist/core/extensions/loader.js.map +1 -1
- package/dist/core/extensions/types.d.ts +15 -4
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/learn/extract.d.ts +48 -1
- package/dist/core/learn/extract.d.ts.map +1 -1
- package/dist/core/learn/extract.js +120 -23
- package/dist/core/learn/extract.js.map +1 -1
- package/dist/core/learn/state.d.ts +6 -3
- package/dist/core/learn/state.d.ts.map +1 -1
- package/dist/core/learn/state.js +6 -3
- package/dist/core/learn/state.js.map +1 -1
- package/dist/core/session-manager.d.ts +8 -0
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +12 -2
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/settings-manager.d.ts +11 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +14 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/extensions/core/learn.d.ts.map +1 -1
- package/dist/extensions/core/learn.js +149 -15
- package/dist/extensions/core/learn.js.map +1 -1
- package/dist/extensions/core/mcp-loader.d.ts.map +1 -1
- package/dist/extensions/core/mcp-loader.js +38 -2
- package/dist/extensions/core/mcp-loader.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +8 -4
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/components/settings-selector.d.ts +3 -1
- package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/settings-selector.js +72 -0
- package/dist/modes/interactive/components/settings-selector.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +21 -3
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +54 -15
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/resource-display.d.ts.map +1 -1
- package/dist/modes/interactive/resource-display.js +5 -1
- package/dist/modes/interactive/resource-display.js.map +1 -1
- package/docs/settings.md +4 -0
- package/docs/usage.md +16 -1
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/core/extensions/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AA6iBH;;;;;;GAMG;AACH,MAAM,UAAU,UAAU,CACzB,IAA+C,EACiB;IAChE,OAAO,IAAqE,CAAC;AAAA,CAC7E;AAuZD,kCAAkC;AAClC,MAAM,UAAU,gBAAgB,CAAC,CAAkB,EAA4B;IAC9E,OAAO,CAAC,CAAC,QAAQ,KAAK,MAAM,CAAC;AAAA,CAC7B;AACD,MAAM,UAAU,gBAAgB,CAAC,CAAkB,EAA4B;IAC9E,OAAO,CAAC,CAAC,QAAQ,KAAK,MAAM,CAAC;AAAA,CAC7B;AACD,MAAM,UAAU,gBAAgB,CAAC,CAAkB,EAA4B;IAC9E,OAAO,CAAC,CAAC,QAAQ,KAAK,MAAM,CAAC;AAAA,CAC7B;AACD,MAAM,UAAU,iBAAiB,CAAC,CAAkB,EAA6B;IAChF,OAAO,CAAC,CAAC,QAAQ,KAAK,OAAO,CAAC;AAAA,CAC9B;AACD,MAAM,UAAU,gBAAgB,CAAC,CAAkB,EAA4B;IAC9E,OAAO,CAAC,CAAC,QAAQ,KAAK,MAAM,CAAC;AAAA,CAC7B;AACD,MAAM,UAAU,gBAAgB,CAAC,CAAkB,EAA4B;IAC9E,OAAO,CAAC,CAAC,QAAQ,KAAK,MAAM,CAAC;AAAA,CAC7B;AACD,MAAM,UAAU,cAAc,CAAC,CAAkB,EAA0B;IAC1E,OAAO,CAAC,CAAC,QAAQ,KAAK,IAAI,CAAC;AAAA,CAC3B;AAiCD,MAAM,UAAU,mBAAmB,CAAC,QAAgB,EAAE,KAAoB,EAAW;IACpF,OAAO,KAAK,CAAC,QAAQ,KAAK,QAAQ,CAAC;AAAA,CACnC","sourcesContent":["/**\n * Extension system types.\n *\n * Extensions are TypeScript modules that can:\n * - Subscribe to agent lifecycle events\n * - Register LLM-callable tools\n * - Register commands, keyboard shortcuts, and CLI flags\n * - Interact with the user via UI primitives\n */\n\nimport type {\n\tAgentMessage,\n\tAgentToolCall,\n\tAgentToolResult,\n\tAgentToolUpdateCallback,\n\tCompactionPreparation,\n\tCompactionResult,\n\tCustomMessage,\n\tThinkingLevel,\n\tToolExecutionMode,\n} from \"@kolisachint/hoocode-agent-core\";\nimport type {\n\tApi,\n\tAssistantMessageEvent,\n\tAssistantMessageEventStream,\n\tContext,\n\tImageContent,\n\tModel,\n\tOAuthCredentials,\n\tOAuthLoginCallbacks,\n\tSimpleStreamOptions,\n\tTextContent,\n\tToolResultMessage,\n} from \"@kolisachint/hoocode-ai\";\nimport type {\n\tAutocompleteItem,\n\tAutocompleteProvider,\n\tComponent,\n\tEditorComponent,\n\tEditorTheme,\n\tKeyId,\n\tOverlayHandle,\n\tOverlayOptions,\n\tTUI,\n} from \"@kolisachint/hoocode-tui\";\nimport type { Static, TSchema } from \"typebox\";\nimport type { Theme } from \"../../modes/interactive/theme/theme.js\";\nimport type { BashResult } from \"../bash-executor.js\";\nimport type { EventBus } from \"../event-bus.js\";\nimport type { ExecOptions, ExecResult } from \"../exec.js\";\nimport type { ReadonlyFooterDataProvider } from \"../footer-data-provider.js\";\nimport type { KeybindingsManager } from \"../keybindings.js\";\nimport type { ModelRegistry } from \"../model-registry.js\";\nimport type {\n\tBranchSummaryEntry,\n\tCompactionEntry,\n\tReadonlySessionManager,\n\tSessionEntry,\n\tSessionManager,\n} from \"../session-manager.js\";\nimport type { SlashCommandInfo } from \"../slash-commands.js\";\nimport type { SourceInfo } from \"../source-info.js\";\nimport type { BuildSystemPromptOptions } from \"../system-prompt.js\";\nimport type { BashOperations } from \"../tools/bash.js\";\nimport type { EditToolDetails } from \"../tools/edit.js\";\nimport type {\n\tBashToolDetails,\n\tBashToolInput,\n\tEditToolInput,\n\tFindToolDetails,\n\tFindToolInput,\n\tGrepToolDetails,\n\tGrepToolInput,\n\tLsToolDetails,\n\tLsToolInput,\n\tReadToolDetails,\n\tReadToolInput,\n\tWriteToolInput,\n} from \"../tools/index.js\";\n\nexport type { ExecOptions, ExecResult } from \"../exec.js\";\nexport type { BuildSystemPromptOptions } from \"../system-prompt.js\";\nexport type { AgentToolResult, AgentToolUpdateCallback, ToolExecutionMode };\nexport type { AppKeybinding, KeybindingsManager } from \"../keybindings.js\";\n\n// ============================================================================\n// UI Context\n// ============================================================================\n\n/** Options for extension UI dialogs. */\nexport interface ExtensionUIDialogOptions {\n\t/** AbortSignal to programmatically dismiss the dialog. */\n\tsignal?: AbortSignal;\n\t/** Timeout in milliseconds. Dialog auto-dismisses with live countdown display. */\n\ttimeout?: number;\n}\n\n/** A single selectable option in an {@link AskQuestion}. */\ninterface AskOption {\n\t/** The option text shown to the user and returned when chosen. */\n\tlabel: string;\n\t/** Optional secondary description shown next to the label. */\n\tdescription?: string;\n\t/** When true, the option is marked \"(recommended)\" to help the user choose. */\n\trecommended?: boolean;\n}\n\n/** One decision the agent asks the user to make in the options pane. */\nexport interface AskQuestion {\n\t/** The question shown to the user. */\n\tquestion: string;\n\t/** Short label used in the answered-step breadcrumb. Defaults to `question`. */\n\tshort?: string;\n\t/** Optional sub-text rendered under the question. */\n\tdetail?: string;\n\t/** The selectable options. */\n\toptions: AskOption[];\n\t/** When true, a custom row lets the user type their own answer. */\n\tallowCustom?: boolean;\n}\n\n/** Placement for extension widgets. */\nexport type WidgetPlacement = \"aboveEditor\" | \"belowEditor\";\n\n/** Options for extension widgets. */\nexport interface ExtensionWidgetOptions {\n\t/** Where the widget is rendered. Defaults to \"aboveEditor\". */\n\tplacement?: WidgetPlacement;\n}\n\n/** Raw terminal input listener for extensions. */\nexport type TerminalInputHandler = (data: string) => { consume?: boolean; data?: string } | undefined;\n\n/** Working indicator configuration for the interactive streaming loader. */\nexport interface WorkingIndicatorOptions {\n\t/** Animation frames. Use an empty array to hide the indicator entirely. Custom frames are rendered verbatim. */\n\tframes?: string[];\n\t/** Frame interval in milliseconds for animated indicators. */\n\tintervalMs?: number;\n}\n\n/** Wrap the current autocomplete provider with additional behavior. */\nexport type AutocompleteProviderFactory = (current: AutocompleteProvider) => AutocompleteProvider;\nexport type EditorFactory = (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => EditorComponent;\n\n/**\n * UI context for extensions to request interactive UI.\n * Each mode (interactive, RPC, print) provides its own implementation.\n */\nexport interface ExtensionUIContext {\n\t/** Show a selector and return the user's choice. */\n\tselect(title: string, options: string[], opts?: ExtensionUIDialogOptions): Promise<string | undefined>;\n\n\t/** Show a confirmation dialog. */\n\tconfirm(title: string, message: string, opts?: ExtensionUIDialogOptions): Promise<boolean>;\n\n\t/** Show a text input dialog. */\n\tinput(title: string, placeholder?: string, opts?: ExtensionUIDialogOptions): Promise<string | undefined>;\n\n\t/**\n\t * Show the options pane and ask the user one or more questions, each with a\n\t * set of options (and optionally a free-form custom answer). Returns one\n\t * answer string per question in order, or undefined if the user skipped.\n\t */\n\taskOptions(questions: AskQuestion[], opts?: ExtensionUIDialogOptions): Promise<string[] | undefined>;\n\n\t/** Show a notification to the user. */\n\tnotify(message: string, type?: \"info\" | \"warning\" | \"error\"): void;\n\n\t/**\n\t * Terminal width in columns, when the surface has one.\n\t *\n\t * Undefined outside a terminal (RPC, print, headless), which is the signal to\n\t * emit unwrapped text and let the consumer lay it out — a listing pre-wrapped\n\t * to someone else's terminal is worse than no wrapping at all. Optional so\n\t * third-party implementations of this interface keep compiling.\n\t */\n\treadonly columns?: number;\n\n\t/** Listen to raw terminal input (interactive mode only). Returns an unsubscribe function. */\n\tonTerminalInput(handler: TerminalInputHandler): () => void;\n\n\t/** Set status text in the footer/status bar. Pass undefined to clear. */\n\tsetStatus(key: string, text: string | undefined): void;\n\n\t/**\n\t * Set the active mode displayed in the footer.\n\t * Format: mode (e.g., 'ask', 'plan', 'build', 'debug')\n\t */\n\tsetMode(mode: string): void;\n\n\t/** Set the working/loading message shown during streaming. Call with no argument to restore default. */\n\tsetWorkingMessage(message?: string): void;\n\n\t/** Show or hide the built-in interactive working loader row during streaming. */\n\tsetWorkingVisible(visible: boolean): void;\n\n\t/**\n\t * Configure the interactive working indicator shown during streaming.\n\t *\n\t * - Omit the argument to restore the default animated spinner.\n\t * - Use `frames: [\"●\"]` for a static indicator.\n\t * - Use `frames: []` to hide the indicator entirely.\n\t * - Custom frames are rendered as provided, so extensions must add their own colors.\n\t */\n\tsetWorkingIndicator(options?: WorkingIndicatorOptions): void;\n\n\t/** Set the label shown for hidden thinking blocks. Call with no argument to restore default. */\n\tsetHiddenThinkingLabel(label?: string): void;\n\n\t/** Set a widget to display above or below the editor. Accepts string array or component factory. */\n\tsetWidget(key: string, content: string[] | undefined, options?: ExtensionWidgetOptions): void;\n\tsetWidget(\n\t\tkey: string,\n\t\tcontent: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined,\n\t\toptions?: ExtensionWidgetOptions,\n\t): void;\n\n\t/** Set a custom footer component, or undefined to restore the built-in footer.\n\t *\n\t * The factory receives a FooterDataProvider for data not otherwise accessible:\n\t * git branch and extension statuses from setStatus(). Token stats, model info,\n\t * etc. are available via ctx.sessionManager and ctx.model.\n\t */\n\tsetFooter(\n\t\tfactory:\n\t\t\t| ((tui: TUI, theme: Theme, footerData: ReadonlyFooterDataProvider) => Component & { dispose?(): void })\n\t\t\t| undefined,\n\t): void;\n\n\t/** Set a custom header component (shown at startup, above chat), or undefined to restore the built-in header. */\n\tsetHeader(factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;\n\n\t/** Set the terminal window/tab title. */\n\tsetTitle(title: string): void;\n\n\t/** Show a custom component with keyboard focus. */\n\tcustom<T>(\n\t\tfactory: (\n\t\t\ttui: TUI,\n\t\t\ttheme: Theme,\n\t\t\tkeybindings: KeybindingsManager,\n\t\t\tdone: (result: T) => void,\n\t\t) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,\n\t\toptions?: {\n\t\t\toverlay?: boolean;\n\t\t\t/** Overlay positioning/sizing options. Can be static or a function for dynamic updates. */\n\t\t\toverlayOptions?: OverlayOptions | (() => OverlayOptions);\n\t\t\t/** Called with the overlay handle after the overlay is shown. Use to control visibility. */\n\t\t\tonHandle?: (handle: OverlayHandle) => void;\n\t\t},\n\t): Promise<T>;\n\n\t/** Paste text into the editor, triggering paste handling (collapse for large content). */\n\tpasteToEditor(text: string): void;\n\n\t/** Set the text in the core input editor. */\n\tsetEditorText(text: string): void;\n\n\t/** Get the current text from the core input editor. */\n\tgetEditorText(): string;\n\n\t/** Show a multi-line editor for text editing. */\n\teditor(title: string, prefill?: string): Promise<string | undefined>;\n\n\t/** Stack additional autocomplete behavior on top of the built-in provider. */\n\taddAutocompleteProvider(factory: AutocompleteProviderFactory): void;\n\n\t/**\n\t * Set a custom editor component via factory function.\n\t * Pass undefined to restore the default editor.\n\t *\n\t * The factory receives:\n\t * - `theme`: EditorTheme for styling borders and autocomplete\n\t * - `keybindings`: KeybindingsManager for app-level keybindings\n\t *\n\t * For full app keybinding support (escape, ctrl+d, model switching, etc.),\n\t * extend `CustomEditor` from `@kolisachint/hoocode-agent` and call\n\t * `super.handleInput(data)` for keys you don't handle.\n\t *\n\t * @example\n\t * ```ts\n\t * import { CustomEditor } from \"@kolisachint/hoocode-agent\";\n\t *\n\t * class VimEditor extends CustomEditor {\n\t * private mode: \"normal\" | \"insert\" = \"insert\";\n\t *\n\t * handleInput(data: string): void {\n\t * if (this.mode === \"normal\") {\n\t * // Handle vim normal mode keys...\n\t * if (data === \"i\") { this.mode = \"insert\"; return; }\n\t * }\n\t * super.handleInput(data); // App keybindings + text editing\n\t * }\n\t * }\n\t *\n\t * ctx.ui.setEditorComponent((tui, theme, keybindings) =>\n\t * new VimEditor(tui, theme, keybindings)\n\t * );\n\t * ```\n\t */\n\tsetEditorComponent(factory: EditorFactory | undefined): void;\n\n\t/** Get the currently configured custom editor factory, or undefined when using the default editor. */\n\tgetEditorComponent(): EditorFactory | undefined;\n\n\t/** Get the current theme for styling. */\n\treadonly theme: Theme;\n\n\t/** Get all available themes with their names and file paths. */\n\tgetAllThemes(): { name: string; path: string | undefined }[];\n\n\t/** Load a theme by name without switching to it. Returns undefined if not found. */\n\tgetTheme(name: string): Theme | undefined;\n\n\t/** Set the current theme by name or Theme object. */\n\tsetTheme(theme: string | Theme): { success: boolean; error?: string };\n\n\t/** Get current tool output expansion state. */\n\tgetToolsExpanded(): boolean;\n\n\t/** Set tool output expansion state. */\n\tsetToolsExpanded(expanded: boolean): void;\n}\n\n// ============================================================================\n// Extension Context\n// ============================================================================\n\nexport interface ContextUsage {\n\t/** Estimated context tokens, or null if unknown (e.g. right after compaction, before next LLM response). */\n\ttokens: number | null;\n\tcontextWindow: number;\n\t/** Context usage as percentage of context window, or null if tokens is unknown. */\n\tpercent: number | null;\n}\n\nexport interface CompactOptions {\n\tcustomInstructions?: string;\n\tonComplete?: (result: CompactionResult) => void;\n\tonError?: (error: Error) => void;\n}\n\n/** A capability surfaced by a live plugin activation. */\nexport interface ActivatedCapability {\n\tname: string;\n\tdescription?: string;\n}\n\n/**\n * Result of activating a plugin in the live session (no reload).\n * See AgentSession.activatePlugin.\n */\nexport type PluginActivationResult =\n\t| { activated: false; message: string }\n\t| {\n\t\t\tactivated: true;\n\t\t\tpluginId: string;\n\t\t\tskills: ActivatedCapability[];\n\t\t\tcommands: ActivatedCapability[];\n\t\t\tagents: ActivatedCapability[];\n\t\t\t/** True when hooks/MCP servers/providers were found and a full reload was scheduled for idle. */\n\t\t\tpendingReloadForExecutables: boolean;\n\t\t\tmessage: string;\n\t };\n\n/**\n * Context passed to extension event handlers.\n */\nexport interface ExtensionContext {\n\t/** UI methods for user interaction */\n\tui: ExtensionUIContext;\n\t/** Whether UI is available (false in print/RPC mode) */\n\thasUI: boolean;\n\t/** Current working directory */\n\tcwd: string;\n\t/** Session manager (read-only) */\n\tsessionManager: ReadonlySessionManager;\n\t/** Model registry for API key resolution */\n\tmodelRegistry: ModelRegistry;\n\t/** Current model (may be undefined) */\n\tmodel: Model<any> | undefined;\n\t/** Whether the agent is idle (not streaming) */\n\tisIdle(): boolean;\n\t/** The current abort signal, or undefined when the agent is not streaming. */\n\tsignal: AbortSignal | undefined;\n\t/** Abort the current agent operation */\n\tabort(): void;\n\t/** Whether there are queued messages waiting */\n\thasPendingMessages(): boolean;\n\t/** Gracefully shutdown pi and exit. Available in all contexts. */\n\tshutdown(): void;\n\t/** Get current context usage for the active model. */\n\tgetContextUsage(): ContextUsage | undefined;\n\t/** Trigger compaction without awaiting completion. */\n\tcompact(options?: CompactOptions): void;\n\t/** Get the current effective system prompt. */\n\tgetSystemPrompt(): string;\n\t/**\n\t * Activate a just-installed/authored plugin in the LIVE session. Passive\n\t * capabilities (skills, commands, subagents, themes) become usable on the\n\t * model's next request — mid-turn included; executable capabilities (hooks,\n\t * MCP servers) trigger an automatic reload once the session is idle.\n\t */\n\tactivatePlugin(pluginDir: string): PluginActivationResult;\n\t/** Schedule a full reload for when the session next goes idle (immediate when already idle). */\n\trequestReloadWhenIdle(): void;\n}\n\n/**\n * Extended context for command handlers.\n * Includes session control methods only safe in user-initiated commands.\n */\nexport interface ExtensionCommandContext extends ExtensionContext {\n\t/** Wait for the agent to finish streaming */\n\twaitForIdle(): Promise<void>;\n\n\t/** Start a new session, optionally with initialization. */\n\tnewSession(options?: {\n\t\tparentSession?: string;\n\t\tsetup?: (sessionManager: SessionManager) => Promise<void>;\n\t\twithSession?: (ctx: ReplacedSessionContext) => Promise<void>;\n\t}): Promise<{ cancelled: boolean }>;\n\n\t/** Fork from a specific entry, creating a new session file. */\n\tfork(\n\t\tentryId: string,\n\t\toptions?: { position?: \"before\" | \"at\"; withSession?: (ctx: ReplacedSessionContext) => Promise<void> },\n\t): Promise<{ cancelled: boolean }>;\n\n\t/** Navigate to a different point in the session tree. */\n\tnavigateTree(\n\t\ttargetId: string,\n\t\toptions?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string },\n\t): Promise<{ cancelled: boolean }>;\n\n\t/** Switch to a different session file. */\n\tswitchSession(\n\t\tsessionPath: string,\n\t\toptions?: { withSession?: (ctx: ReplacedSessionContext) => Promise<void> },\n\t): Promise<{ cancelled: boolean }>;\n\n\t/** Reload extensions, skills, prompts, and themes. */\n\treload(): Promise<void>;\n}\n\n/**\n * Fresh command-capable context bound to the replacement session after a session switch.\n *\n * This is passed to `withSession()` callbacks on `newSession()`, `fork()`, and `switchSession()`.\n */\nexport interface ReplacedSessionContext extends ExtensionCommandContext {\n\tsendMessage<T = unknown>(\n\t\tmessage: Pick<CustomMessage<T>, \"customType\" | \"content\" | \"display\" | \"details\">,\n\t\toptions?: { triggerTurn?: boolean; deliverAs?: \"steer\" | \"followUp\" | \"nextTurn\" },\n\t): Promise<void>;\n\n\tsendUserMessage(\n\t\tcontent: string | (TextContent | ImageContent)[],\n\t\toptions?: { deliverAs?: \"steer\" | \"followUp\" },\n\t): Promise<void>;\n}\n\n// ============================================================================\n// Tool Types\n// ============================================================================\n\n/** Rendering options for tool results */\nexport interface ToolRenderResultOptions {\n\t/** Whether the result view is expanded */\n\texpanded: boolean;\n\t/** Whether this is a partial/streaming result */\n\tisPartial: boolean;\n}\n\n/** Context passed to tool renderers. */\nexport interface ToolRenderContext<TState = any, TArgs = any> {\n\t/** Current tool call arguments. Shared across call/result renders for the same tool call. */\n\targs: TArgs;\n\t/** Unique id for this tool execution. Stable across call/result renders for the same tool call. */\n\ttoolCallId: string;\n\t/** Invalidate just this tool execution component for redraw. */\n\tinvalidate: () => void;\n\t/** Previously returned component for this render slot, if any. */\n\tlastComponent: Component | undefined;\n\t/** Shared renderer state for this tool row. Initialized by tool-execution.ts. */\n\tstate: TState;\n\t/** Working directory for this tool execution. */\n\tcwd: string;\n\t/** Whether the tool execution has started. */\n\texecutionStarted: boolean;\n\t/** Whether the tool call arguments are complete. */\n\targsComplete: boolean;\n\t/** Whether the tool result is partial/streaming. */\n\tisPartial: boolean;\n\t/** Whether the result view is expanded. */\n\texpanded: boolean;\n\t/** Whether inline images are currently shown in the TUI. */\n\tshowImages: boolean;\n\t/** Whether the current result is an error. */\n\tisError: boolean;\n}\n\n/**\n * Tool definition for registerTool().\n */\nexport interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = unknown, TState = any> {\n\t/** Tool name (used in LLM tool calls) */\n\tname: string;\n\t/** Human-readable label for UI */\n\tlabel: string;\n\t/** Description for LLM */\n\tdescription: string;\n\t/** Optional one-line snippet for the Available tools section in the default system prompt. Custom tools are omitted from that section when this is not provided. */\n\tpromptSnippet?: string;\n\t/** Optional guideline bullets appended to the default system prompt Guidelines section when this tool is active. */\n\tpromptGuidelines?: string[];\n\t/** Parameter schema (TypeBox) */\n\tparameters: TParams;\n\t/** Controls whether ToolExecutionComponent renders the standard colored shell or the tool renders its own framing. */\n\trenderShell?: \"default\" | \"self\";\n\n\t/** Optional compatibility shim to prepare raw tool call arguments before schema validation. Must return an object conforming to TParams. */\n\tprepareArguments?: (args: unknown) => Static<TParams>;\n\n\t/**\n\t * Per-tool execution mode override.\n\t * - \"sequential\": this tool must execute one at a time with other tool calls.\n\t * - \"parallel\": this tool can execute concurrently with other tool calls.\n\t *\n\t * If omitted, the default execution mode applies.\n\t */\n\texecutionMode?: ToolExecutionMode;\n\n\t/**\n\t * Run this tool non-blocking. The agent loop answers the tool call with a\n\t * placeholder immediately and keeps reasoning while `execute` runs detached;\n\t * the real result is injected later as a follow-up message. May be a predicate\n\t * evaluated per tool call when background-ness depends on the arguments.\n\t */\n\tbackground?: boolean | ((toolCall: AgentToolCall) => boolean);\n\n\t/** Execute the tool. */\n\texecute(\n\t\ttoolCallId: string,\n\t\tparams: Static<TParams>,\n\t\tsignal: AbortSignal | undefined,\n\t\tonUpdate: AgentToolUpdateCallback<TDetails> | undefined,\n\t\tctx: ExtensionContext,\n\t): Promise<AgentToolResult<TDetails>>;\n\n\t/** Custom rendering for tool call display */\n\trenderCall?: (args: Static<TParams>, theme: Theme, context: ToolRenderContext<TState, Static<TParams>>) => Component;\n\n\t/** Custom rendering for tool result display */\n\trenderResult?: (\n\t\tresult: AgentToolResult<TDetails>,\n\t\toptions: ToolRenderResultOptions,\n\t\ttheme: Theme,\n\t\tcontext: ToolRenderContext<TState, Static<TParams>>,\n\t) => Component;\n}\n\ntype AnyToolDefinition = ToolDefinition<any, any, any>;\n\n/**\n * Preserve parameter inference for standalone tool definitions.\n *\n * Use this when assigning a tool to a variable or passing it through arrays such\n * as `customTools`, where contextual typing would otherwise widen params to\n * `unknown`.\n */\nexport function defineTool<TParams extends TSchema, TDetails = unknown, TState = any>(\n\ttool: ToolDefinition<TParams, TDetails, TState>,\n): ToolDefinition<TParams, TDetails, TState> & AnyToolDefinition {\n\treturn tool as ToolDefinition<TParams, TDetails, TState> & AnyToolDefinition;\n}\n\n// ============================================================================\n// Resource Events\n// ============================================================================\n\n/** Fired after session_start to allow extensions to provide additional resource paths. */\nexport interface ResourcesDiscoverEvent {\n\ttype: \"resources_discover\";\n\tcwd: string;\n\treason: \"startup\" | \"reload\";\n}\n\n/** Result from resources_discover event handler */\nexport interface ResourcesDiscoverResult {\n\tskillPaths?: string[];\n\tpromptPaths?: string[];\n\tthemePaths?: string[];\n\t/** Slash-command directories or `.md` files (the `.agents/commands` surface). */\n\tslashCommandPaths?: string[];\n\t/** Subagent definition directories or `.md` files (the `.agents/agents` surface). */\n\tagentPaths?: string[];\n}\n\n// ============================================================================\n// Session Events\n// ============================================================================\n\n/** Fired when a session is started, loaded, or reloaded */\nexport interface SessionStartEvent {\n\ttype: \"session_start\";\n\t/** Why this session start happened. */\n\treason: \"startup\" | \"reload\" | \"new\" | \"resume\" | \"fork\";\n\t/** Previously active session file. Present for \"new\", \"resume\", and \"fork\". */\n\tpreviousSessionFile?: string;\n}\n\n/** Fired before switching to another session (can be cancelled) */\nexport interface SessionBeforeSwitchEvent {\n\ttype: \"session_before_switch\";\n\treason: \"new\" | \"resume\";\n\ttargetSessionFile?: string;\n}\n\n/** Fired before forking a session (can be cancelled) */\nexport interface SessionBeforeForkEvent {\n\ttype: \"session_before_fork\";\n\tentryId: string;\n\tposition: \"before\" | \"at\";\n}\n\n/** Fired before context compaction (can be cancelled or customized) */\nexport interface SessionBeforeCompactEvent {\n\ttype: \"session_before_compact\";\n\tpreparation: CompactionPreparation;\n\tbranchEntries: SessionEntry[];\n\tcustomInstructions?: string;\n\tsignal: AbortSignal;\n}\n\n/** Fired after context compaction */\nexport interface SessionCompactEvent {\n\ttype: \"session_compact\";\n\tcompactionEntry: CompactionEntry;\n\tfromExtension: boolean;\n}\n\n/** Fired before an extension runtime is torn down due to quit, reload, or session replacement. */\nexport interface SessionShutdownEvent {\n\ttype: \"session_shutdown\";\n\treason: \"quit\" | \"reload\" | \"new\" | \"resume\" | \"fork\";\n\t/** Destination session file when shutting down due to session replacement. */\n\ttargetSessionFile?: string;\n}\n\n/** Preparation data for tree navigation */\nexport interface TreePreparation {\n\ttargetId: string;\n\toldLeafId: string | null;\n\tcommonAncestorId: string | null;\n\tentriesToSummarize: SessionEntry[];\n\tuserWantsSummary: boolean;\n\t/** Custom instructions for summarization */\n\tcustomInstructions?: string;\n\t/** If true, customInstructions replaces the default prompt instead of being appended */\n\treplaceInstructions?: boolean;\n\t/** Label to attach to the branch summary entry */\n\tlabel?: string;\n}\n\n/** Fired before navigating in the session tree (can be cancelled) */\nexport interface SessionBeforeTreeEvent {\n\ttype: \"session_before_tree\";\n\tpreparation: TreePreparation;\n\tsignal: AbortSignal;\n}\n\n/** Fired after navigating in the session tree */\nexport interface SessionTreeEvent {\n\ttype: \"session_tree\";\n\tnewLeafId: string | null;\n\toldLeafId: string | null;\n\tsummaryEntry?: BranchSummaryEntry;\n\tfromExtension?: boolean;\n}\n\nexport type SessionEvent =\n\t| SessionStartEvent\n\t| SessionBeforeSwitchEvent\n\t| SessionBeforeForkEvent\n\t| SessionBeforeCompactEvent\n\t| SessionCompactEvent\n\t| SessionShutdownEvent\n\t| SessionBeforeTreeEvent\n\t| SessionTreeEvent;\n\n// ============================================================================\n// Agent Events\n// ============================================================================\n\n/** Fired before each LLM call. Can modify messages. */\nexport interface ContextEvent {\n\ttype: \"context\";\n\tmessages: AgentMessage[];\n}\n\n/** Fired before a provider request is sent. Can replace the payload. */\nexport interface BeforeProviderRequestEvent {\n\ttype: \"before_provider_request\";\n\tpayload: unknown;\n}\n\n/** Fired after a provider response is received and before the response stream is consumed. */\ninterface AfterProviderResponseEvent {\n\ttype: \"after_provider_response\";\n\tstatus: number;\n\theaders: Record<string, string>;\n}\n\n/** Fired after user submits prompt but before agent loop. */\nexport interface BeforeAgentStartEvent {\n\ttype: \"before_agent_start\";\n\t/** The raw user prompt text (after expansion). */\n\tprompt: string;\n\t/** Images attached to the user prompt, if any. */\n\timages?: ImageContent[];\n\t/** The fully assembled system prompt string. */\n\tsystemPrompt: string;\n\t/** Structured options used to build the system prompt. Extensions can inspect this to understand what Pi loaded without re-discovering resources. */\n\tsystemPromptOptions: BuildSystemPromptOptions;\n}\n\n/** Fired when an agent loop starts */\nexport interface AgentStartEvent {\n\ttype: \"agent_start\";\n}\n\n/** Fired when an agent loop ends */\nexport interface AgentEndEvent {\n\ttype: \"agent_end\";\n\tmessages: AgentMessage[];\n}\n\n/** Fired at the start of each turn */\nexport interface TurnStartEvent {\n\ttype: \"turn_start\";\n\tturnIndex: number;\n\ttimestamp: number;\n}\n\n/** Fired at the end of each turn */\nexport interface TurnEndEvent {\n\ttype: \"turn_end\";\n\tturnIndex: number;\n\tmessage: AgentMessage;\n\ttoolResults: ToolResultMessage[];\n}\n\n/** Fired when a message starts (user, assistant, or toolResult) */\nexport interface MessageStartEvent {\n\ttype: \"message_start\";\n\tmessage: AgentMessage;\n}\n\n/** Fired during assistant message streaming with token-by-token updates */\nexport interface MessageUpdateEvent {\n\ttype: \"message_update\";\n\tmessage: AgentMessage;\n\tassistantMessageEvent: AssistantMessageEvent;\n}\n\n/** Fired when a message ends */\nexport interface MessageEndEvent {\n\ttype: \"message_end\";\n\tmessage: AgentMessage;\n}\n\n/** Fired when a tool starts executing */\nexport interface ToolExecutionStartEvent {\n\ttype: \"tool_execution_start\";\n\ttoolCallId: string;\n\ttoolName: string;\n\targs: any;\n}\n\n/** Fired during tool execution with partial/streaming output */\nexport interface ToolExecutionUpdateEvent {\n\ttype: \"tool_execution_update\";\n\ttoolCallId: string;\n\ttoolName: string;\n\targs: any;\n\tpartialResult: any;\n}\n\n/** Fired when a tool finishes executing */\nexport interface ToolExecutionEndEvent {\n\ttype: \"tool_execution_end\";\n\ttoolCallId: string;\n\ttoolName: string;\n\tresult: any;\n\tisError: boolean;\n}\n\n// ============================================================================\n// Model Events\n// ============================================================================\n\ntype ModelSelectSource = \"set\" | \"cycle\" | \"restore\";\n\n/** Fired when a new model is selected */\ninterface ModelSelectEvent {\n\ttype: \"model_select\";\n\tmodel: Model<any>;\n\tpreviousModel: Model<any> | undefined;\n\tsource: ModelSelectSource;\n}\n\n/** Fired when a new thinking level is selected */\ninterface ThinkingLevelSelectEvent {\n\ttype: \"thinking_level_select\";\n\tlevel: ThinkingLevel;\n\tpreviousLevel: ThinkingLevel;\n}\n\n// ============================================================================\n// User Bash Events\n// ============================================================================\n\n/** Fired when user executes a bash command via ! or !! prefix */\nexport interface UserBashEvent {\n\ttype: \"user_bash\";\n\t/** The command to execute */\n\tcommand: string;\n\t/** True if !! prefix was used (excluded from LLM context) */\n\texcludeFromContext: boolean;\n\t/** Current working directory */\n\tcwd: string;\n}\n\n// ============================================================================\n// Input Events\n// ============================================================================\n\n/** Source of user input */\nexport type InputSource = \"interactive\" | \"rpc\" | \"extension\";\n\n/** Fired when user input is received, before agent processing */\nexport interface InputEvent {\n\ttype: \"input\";\n\t/** The input text */\n\ttext: string;\n\t/** Attached images, if any */\n\timages?: ImageContent[];\n\t/** Where the input came from */\n\tsource: InputSource;\n}\n\n/** Result from input event handler */\nexport type InputEventResult =\n\t| { action: \"continue\" }\n\t| { action: \"transform\"; text: string; images?: ImageContent[] }\n\t| { action: \"handled\" };\n\n// ============================================================================\n// Tool Events\n// ============================================================================\n\ninterface ToolCallEventBase {\n\ttype: \"tool_call\";\n\ttoolCallId: string;\n}\n\nexport interface BashToolCallEvent extends ToolCallEventBase {\n\ttoolName: \"bash\";\n\tinput: BashToolInput;\n}\n\nexport interface ReadToolCallEvent extends ToolCallEventBase {\n\ttoolName: \"read\";\n\tinput: ReadToolInput;\n}\n\nexport interface EditToolCallEvent extends ToolCallEventBase {\n\ttoolName: \"edit\";\n\tinput: EditToolInput;\n}\n\nexport interface WriteToolCallEvent extends ToolCallEventBase {\n\ttoolName: \"write\";\n\tinput: WriteToolInput;\n}\n\nexport interface GrepToolCallEvent extends ToolCallEventBase {\n\ttoolName: \"grep\";\n\tinput: GrepToolInput;\n}\n\nexport interface FindToolCallEvent extends ToolCallEventBase {\n\ttoolName: \"find\";\n\tinput: FindToolInput;\n}\n\nexport interface LsToolCallEvent extends ToolCallEventBase {\n\ttoolName: \"ls\";\n\tinput: LsToolInput;\n}\n\nexport interface CustomToolCallEvent extends ToolCallEventBase {\n\ttoolName: string;\n\tinput: Record<string, unknown>;\n}\n\n/**\n * Fired before a tool executes. Can block.\n *\n * `event.input` is mutable. Mutate it in place to patch tool arguments before execution.\n * Later `tool_call` handlers see earlier mutations. No re-validation is performed after mutation.\n */\nexport type ToolCallEvent =\n\t| BashToolCallEvent\n\t| ReadToolCallEvent\n\t| EditToolCallEvent\n\t| WriteToolCallEvent\n\t| GrepToolCallEvent\n\t| FindToolCallEvent\n\t| LsToolCallEvent\n\t| CustomToolCallEvent;\n\ninterface ToolResultEventBase {\n\ttype: \"tool_result\";\n\ttoolCallId: string;\n\tinput: Record<string, unknown>;\n\tcontent: (TextContent | ImageContent)[];\n\tisError: boolean;\n}\n\ninterface BashToolResultEvent extends ToolResultEventBase {\n\ttoolName: \"bash\";\n\tdetails: BashToolDetails | undefined;\n}\n\ninterface ReadToolResultEvent extends ToolResultEventBase {\n\ttoolName: \"read\";\n\tdetails: ReadToolDetails | undefined;\n}\n\ninterface EditToolResultEvent extends ToolResultEventBase {\n\ttoolName: \"edit\";\n\tdetails: EditToolDetails | undefined;\n}\n\ninterface WriteToolResultEvent extends ToolResultEventBase {\n\ttoolName: \"write\";\n\tdetails: undefined;\n}\n\ninterface GrepToolResultEvent extends ToolResultEventBase {\n\ttoolName: \"grep\";\n\tdetails: GrepToolDetails | undefined;\n}\n\ninterface FindToolResultEvent extends ToolResultEventBase {\n\ttoolName: \"find\";\n\tdetails: FindToolDetails | undefined;\n}\n\ninterface LsToolResultEvent extends ToolResultEventBase {\n\ttoolName: \"ls\";\n\tdetails: LsToolDetails | undefined;\n}\n\ninterface CustomToolResultEvent extends ToolResultEventBase {\n\ttoolName: string;\n\tdetails: unknown;\n}\n\n/** Fired after a tool executes. Can modify result. */\nexport type ToolResultEvent =\n\t| BashToolResultEvent\n\t| ReadToolResultEvent\n\t| EditToolResultEvent\n\t| WriteToolResultEvent\n\t| GrepToolResultEvent\n\t| FindToolResultEvent\n\t| LsToolResultEvent\n\t| CustomToolResultEvent;\n\n// Type guards for ToolResultEvent\nexport function isBashToolResult(e: ToolResultEvent): e is BashToolResultEvent {\n\treturn e.toolName === \"bash\";\n}\nexport function isReadToolResult(e: ToolResultEvent): e is ReadToolResultEvent {\n\treturn e.toolName === \"read\";\n}\nexport function isEditToolResult(e: ToolResultEvent): e is EditToolResultEvent {\n\treturn e.toolName === \"edit\";\n}\nexport function isWriteToolResult(e: ToolResultEvent): e is WriteToolResultEvent {\n\treturn e.toolName === \"write\";\n}\nexport function isGrepToolResult(e: ToolResultEvent): e is GrepToolResultEvent {\n\treturn e.toolName === \"grep\";\n}\nexport function isFindToolResult(e: ToolResultEvent): e is FindToolResultEvent {\n\treturn e.toolName === \"find\";\n}\nexport function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent {\n\treturn e.toolName === \"ls\";\n}\n\n/**\n * Type guard for narrowing ToolCallEvent by tool name.\n *\n * Built-in tools narrow automatically (no type params needed):\n * ```ts\n * if (isToolCallEventType(\"bash\", event)) {\n * event.input.command; // string\n * }\n * ```\n *\n * Custom tools require explicit type parameters:\n * ```ts\n * if (isToolCallEventType<\"my_tool\", MyToolInput>(\"my_tool\", event)) {\n * event.input.action; // typed\n * }\n * ```\n *\n * Note: Direct narrowing via `event.toolName === \"bash\"` doesn't work because\n * CustomToolCallEvent.toolName is `string` which overlaps with all literals.\n */\nexport function isToolCallEventType(toolName: \"bash\", event: ToolCallEvent): event is BashToolCallEvent;\nexport function isToolCallEventType(toolName: \"read\", event: ToolCallEvent): event is ReadToolCallEvent;\nexport function isToolCallEventType(toolName: \"edit\", event: ToolCallEvent): event is EditToolCallEvent;\nexport function isToolCallEventType(toolName: \"write\", event: ToolCallEvent): event is WriteToolCallEvent;\nexport function isToolCallEventType(toolName: \"grep\", event: ToolCallEvent): event is GrepToolCallEvent;\nexport function isToolCallEventType(toolName: \"find\", event: ToolCallEvent): event is FindToolCallEvent;\nexport function isToolCallEventType(toolName: \"ls\", event: ToolCallEvent): event is LsToolCallEvent;\nexport function isToolCallEventType<TName extends string, TInput extends Record<string, unknown>>(\n\ttoolName: TName,\n\tevent: ToolCallEvent,\n): event is ToolCallEvent & { toolName: TName; input: TInput };\nexport function isToolCallEventType(toolName: string, event: ToolCallEvent): boolean {\n\treturn event.toolName === toolName;\n}\n\n/** Union of all event types */\nexport type ExtensionEvent =\n\t| ResourcesDiscoverEvent\n\t| SessionEvent\n\t| ContextEvent\n\t| BeforeProviderRequestEvent\n\t| AfterProviderResponseEvent\n\t| BeforeAgentStartEvent\n\t| AgentStartEvent\n\t| AgentEndEvent\n\t| TurnStartEvent\n\t| TurnEndEvent\n\t| MessageStartEvent\n\t| MessageUpdateEvent\n\t| MessageEndEvent\n\t| ToolExecutionStartEvent\n\t| ToolExecutionUpdateEvent\n\t| ToolExecutionEndEvent\n\t| ModelSelectEvent\n\t| ThinkingLevelSelectEvent\n\t| UserBashEvent\n\t| InputEvent\n\t| ToolCallEvent\n\t| ToolResultEvent;\n\n// ============================================================================\n// Event Results\n// ============================================================================\n\nexport interface ContextEventResult {\n\tmessages?: AgentMessage[];\n}\n\nexport type BeforeProviderRequestEventResult = unknown;\n\nexport interface ToolCallEventResult {\n\t/** Block tool execution. To modify arguments, mutate `event.input` in place instead. */\n\tblock?: boolean;\n\treason?: string;\n}\n\n/** Result from user_bash event handler */\nexport interface UserBashEventResult {\n\t/** Custom operations to use for execution */\n\toperations?: BashOperations;\n\t/** Full replacement: extension handled execution, use this result */\n\tresult?: BashResult;\n}\n\nexport interface ToolResultEventResult {\n\tcontent?: (TextContent | ImageContent)[];\n\tdetails?: unknown;\n\tisError?: boolean;\n}\n\nexport interface MessageEndEventResult {\n\t/** Replace the finalized message. The replacement must keep the original message role. */\n\tmessage?: AgentMessage;\n}\n\nexport interface BeforeAgentStartEventResult {\n\tmessage?: Pick<CustomMessage, \"customType\" | \"content\" | \"display\" | \"details\">;\n\t/** Replace the system prompt for this turn. If multiple extensions return this, they are chained. */\n\tsystemPrompt?: string;\n}\n\nexport interface SessionBeforeSwitchResult {\n\tcancel?: boolean;\n}\n\nexport interface SessionBeforeForkResult {\n\tcancel?: boolean;\n\tskipConversationRestore?: boolean;\n}\n\nexport interface SessionBeforeCompactResult {\n\tcancel?: boolean;\n\tcompaction?: CompactionResult;\n}\n\nexport interface SessionBeforeTreeResult {\n\tcancel?: boolean;\n\tsummary?: {\n\t\tsummary: string;\n\t\tdetails?: unknown;\n\t};\n\t/** Override custom instructions for summarization */\n\tcustomInstructions?: string;\n\t/** Override whether customInstructions replaces the default prompt */\n\treplaceInstructions?: boolean;\n\t/** Override label to attach to the branch summary entry */\n\tlabel?: string;\n}\n\n// ============================================================================\n// Message Rendering\n// ============================================================================\n\nexport interface MessageRenderOptions {\n\texpanded: boolean;\n}\n\nexport type MessageRenderer<T = unknown> = (\n\tmessage: CustomMessage<T>,\n\toptions: MessageRenderOptions,\n\ttheme: Theme,\n) => Component | undefined;\n\n// ============================================================================\n// Command Registration\n// ============================================================================\n\nexport interface RegisteredCommand {\n\tname: string;\n\tsourceInfo: SourceInfo;\n\tdescription?: string;\n\tgetArgumentCompletions?: (argumentPrefix: string) => AutocompleteItem[] | null | Promise<AutocompleteItem[] | null>;\n\thandler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;\n}\n\nexport interface ResolvedCommand extends RegisteredCommand {\n\tinvocationName: string;\n}\n\n// ============================================================================\n// Extension API\n// ============================================================================\n\n/** Handler function type for events */\n// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements\nexport type ExtensionHandler<E, R = undefined> = (event: E, ctx: ExtensionContext) => Promise<R | void> | R | void;\n\n/**\n * ExtensionAPI passed to extension factory functions.\n */\nexport interface ExtensionAPI {\n\t// =========================================================================\n\t// Event Subscription\n\t// =========================================================================\n\n\ton(event: \"resources_discover\", handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>): void;\n\ton(event: \"session_start\", handler: ExtensionHandler<SessionStartEvent>): void;\n\ton(\n\t\tevent: \"session_before_switch\",\n\t\thandler: ExtensionHandler<SessionBeforeSwitchEvent, SessionBeforeSwitchResult>,\n\t): void;\n\ton(event: \"session_before_fork\", handler: ExtensionHandler<SessionBeforeForkEvent, SessionBeforeForkResult>): void;\n\ton(\n\t\tevent: \"session_before_compact\",\n\t\thandler: ExtensionHandler<SessionBeforeCompactEvent, SessionBeforeCompactResult>,\n\t): void;\n\ton(event: \"session_compact\", handler: ExtensionHandler<SessionCompactEvent>): void;\n\ton(event: \"session_shutdown\", handler: ExtensionHandler<SessionShutdownEvent>): void;\n\ton(event: \"session_before_tree\", handler: ExtensionHandler<SessionBeforeTreeEvent, SessionBeforeTreeResult>): void;\n\ton(event: \"session_tree\", handler: ExtensionHandler<SessionTreeEvent>): void;\n\ton(event: \"context\", handler: ExtensionHandler<ContextEvent, ContextEventResult>): void;\n\ton(\n\t\tevent: \"before_provider_request\",\n\t\thandler: ExtensionHandler<BeforeProviderRequestEvent, BeforeProviderRequestEventResult>,\n\t): void;\n\ton(event: \"after_provider_response\", handler: ExtensionHandler<AfterProviderResponseEvent>): void;\n\ton(event: \"before_agent_start\", handler: ExtensionHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult>): void;\n\ton(event: \"agent_start\", handler: ExtensionHandler<AgentStartEvent>): void;\n\ton(event: \"agent_end\", handler: ExtensionHandler<AgentEndEvent>): void;\n\ton(event: \"turn_start\", handler: ExtensionHandler<TurnStartEvent>): void;\n\ton(event: \"turn_end\", handler: ExtensionHandler<TurnEndEvent>): void;\n\ton(event: \"message_start\", handler: ExtensionHandler<MessageStartEvent>): void;\n\ton(event: \"message_update\", handler: ExtensionHandler<MessageUpdateEvent>): void;\n\ton(event: \"message_end\", handler: ExtensionHandler<MessageEndEvent, MessageEndEventResult>): void;\n\ton(event: \"tool_execution_start\", handler: ExtensionHandler<ToolExecutionStartEvent>): void;\n\ton(event: \"tool_execution_update\", handler: ExtensionHandler<ToolExecutionUpdateEvent>): void;\n\ton(event: \"tool_execution_end\", handler: ExtensionHandler<ToolExecutionEndEvent>): void;\n\ton(event: \"model_select\", handler: ExtensionHandler<ModelSelectEvent>): void;\n\ton(event: \"thinking_level_select\", handler: ExtensionHandler<ThinkingLevelSelectEvent>): void;\n\ton(event: \"tool_call\", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;\n\ton(event: \"tool_result\", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;\n\ton(event: \"user_bash\", handler: ExtensionHandler<UserBashEvent, UserBashEventResult>): void;\n\ton(event: \"input\", handler: ExtensionHandler<InputEvent, InputEventResult>): void;\n\n\t// =========================================================================\n\t// Tool Registration\n\t// =========================================================================\n\n\t/** Register a tool that the LLM can call. */\n\tregisterTool<TParams extends TSchema = TSchema, TDetails = unknown, TState = any>(\n\t\ttool: ToolDefinition<TParams, TDetails, TState>,\n\t): void;\n\n\t// =========================================================================\n\t// Command, Shortcut, Flag Registration\n\t// =========================================================================\n\n\t/** Register a custom command. */\n\tregisterCommand(name: string, options: Omit<RegisteredCommand, \"name\" | \"sourceInfo\">): void;\n\n\t/** Register a keyboard shortcut. */\n\tregisterShortcut(\n\t\tshortcut: KeyId,\n\t\toptions: {\n\t\t\tdescription?: string;\n\t\t\thandler: (ctx: ExtensionContext) => Promise<void> | void;\n\t\t},\n\t): void;\n\n\t/** Register a CLI flag. */\n\tregisterFlag(\n\t\tname: string,\n\t\toptions: {\n\t\t\tdescription?: string;\n\t\t\ttype: \"boolean\" | \"string\";\n\t\t\tdefault?: boolean | string;\n\t\t},\n\t): void;\n\n\t/** Get the value of a registered CLI flag. */\n\tgetFlag(name: string): boolean | string | undefined;\n\n\t/**\n\t * Add a directory to the mode-file search path.\n\t *\n\t * Hoo-core looks up `{dir}/{modeName}/system.md` when assembling the mode\n\t * system prompt, after the project (`./.hoocode/modes/`) and user\n\t * (`~/.hoocode/modes/`) directories. Multiple calls accumulate in\n\t * registration order; CLI flags and config-declared `mode_paths` flow\n\t * through the same list.\n\t */\n\taddModeSearchPath(dirPath: string): void;\n\n\t/** Get all registered mode search paths in declared order. */\n\tgetModeSearchPaths(): string[];\n\n\t// =========================================================================\n\t// Message Rendering\n\t// =========================================================================\n\n\t/** Register a custom renderer for CustomMessageEntry. */\n\tregisterMessageRenderer<T = unknown>(customType: string, renderer: MessageRenderer<T>): void;\n\n\t// =========================================================================\n\t// Actions\n\t// =========================================================================\n\n\t/** Send a custom message to the session. */\n\tsendMessage<T = unknown>(\n\t\tmessage: Pick<CustomMessage<T>, \"customType\" | \"content\" | \"display\" | \"details\">,\n\t\toptions?: { triggerTurn?: boolean; deliverAs?: \"steer\" | \"followUp\" | \"nextTurn\" },\n\t): void;\n\n\t/**\n\t * Send a user message to the agent. Always triggers a turn.\n\t * When the agent is streaming, use deliverAs to specify how to queue the message.\n\t */\n\tsendUserMessage(\n\t\tcontent: string | (TextContent | ImageContent)[],\n\t\toptions?: { deliverAs?: \"steer\" | \"followUp\" },\n\t): void;\n\n\t/** Append a custom entry to the session for state persistence (not sent to LLM). */\n\tappendEntry<T = unknown>(customType: string, data?: T): void;\n\n\t// =========================================================================\n\t// Session Metadata\n\t// =========================================================================\n\n\t/** Set the session display name (shown in session selector). */\n\tsetSessionName(name: string): void;\n\n\t/** Get the current session name, if set. */\n\tgetSessionName(): string | undefined;\n\n\t/** Set or clear a label on an entry. Labels are user-defined markers for bookmarking/navigation. */\n\tsetLabel(entryId: string, label: string | undefined): void;\n\n\t/** Execute a shell command. */\n\texec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;\n\n\t/** Get the list of currently active tool names. */\n\tgetActiveTools(): string[];\n\n\t/** Get all configured tools with parameter schema and source metadata. */\n\tgetAllTools(): ToolInfo[];\n\n\t/** Set the active tools by name. */\n\tsetActiveTools(toolNames: string[]): void;\n\n\t/** Get available slash commands in the current session. */\n\tgetCommands(): SlashCommandInfo[];\n\n\t// =========================================================================\n\t// Model and Thinking Level\n\t// =========================================================================\n\n\t/** Set the current model. Returns false if no API key available. */\n\tsetModel(model: Model<any>): Promise<boolean>;\n\n\t/** Get current thinking level. */\n\tgetThinkingLevel(): ThinkingLevel;\n\n\t/** Set thinking level (clamped to model capabilities). */\n\tsetThinkingLevel(level: ThinkingLevel): void;\n\n\t// =========================================================================\n\t// Provider Registration\n\t// =========================================================================\n\n\t/**\n\t * Register or override a model provider.\n\t *\n\t * If `models` is provided: replaces all existing models for this provider.\n\t * If only `baseUrl` is provided: overrides the URL for existing models.\n\t * If `oauth` is provided: registers OAuth provider for /login support.\n\t * If `streamSimple` is provided: registers a custom API stream handler.\n\t *\n\t * During initial extension load this call is queued and applied once the\n\t * runner has bound its context. After that it takes effect immediately, so\n\t * it is safe to call from command handlers or event callbacks without\n\t * requiring a `/reload`.\n\t *\n\t * @example\n\t * // Register a new provider with custom models\n\t * pi.registerProvider(\"my-proxy\", {\n\t * baseUrl: \"https://proxy.example.com\",\n\t * apiKey: \"PROXY_API_KEY\",\n\t * api: \"anthropic-messages\",\n\t * models: [\n\t * {\n\t * id: \"claude-sonnet-4-20250514\",\n\t * name: \"Claude 4 Sonnet (proxy)\",\n\t * reasoning: false,\n\t * input: [\"text\", \"image\"],\n\t * cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t * contextWindow: 200000,\n\t * maxTokens: 16384\n\t * }\n\t * ]\n\t * });\n\t *\n\t * @example\n\t * // Override baseUrl for an existing provider\n\t * pi.registerProvider(\"anthropic\", {\n\t * baseUrl: \"https://proxy.example.com\"\n\t * });\n\t *\n\t * @example\n\t * // Register provider with OAuth support\n\t * pi.registerProvider(\"corporate-ai\", {\n\t * baseUrl: \"https://ai.corp.com\",\n\t * api: \"openai-responses\",\n\t * models: [...],\n\t * oauth: {\n\t * name: \"Corporate AI (SSO)\",\n\t * async login(callbacks) { ... },\n\t * async refreshToken(credentials) { ... },\n\t * getApiKey(credentials) { return credentials.access; }\n\t * }\n\t * });\n\t */\n\tregisterProvider(name: string, config: ProviderConfig): void;\n\n\t/**\n\t * Unregister a previously registered provider.\n\t *\n\t * Removes all models belonging to the named provider and restores any\n\t * built-in models that were overridden by it. Has no effect if the provider\n\t * is not currently registered.\n\t *\n\t * Like `registerProvider`, this takes effect immediately when called after\n\t * the initial load phase.\n\t *\n\t * @example\n\t * pi.unregisterProvider(\"my-proxy\");\n\t */\n\tunregisterProvider(name: string): void;\n\n\t/** Shared event bus for extension communication. */\n\tevents: EventBus;\n}\n\n// ============================================================================\n// Provider Registration Types\n// ============================================================================\n\n/** Configuration for registering a provider via pi.registerProvider(). */\nexport interface ProviderConfig {\n\t/** Display name for the provider in UI. */\n\tname?: string;\n\t/** Base URL for the API endpoint. Required when defining models. */\n\tbaseUrl?: string;\n\t/** API key or environment variable name. Required when defining models (unless oauth provided). */\n\tapiKey?: string;\n\t/** API type. Required at provider or model level when defining models. */\n\tapi?: Api;\n\t/** Optional streamSimple handler for custom APIs. */\n\tstreamSimple?: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;\n\t/** Custom headers to include in requests. */\n\theaders?: Record<string, string>;\n\t/** If true, adds Authorization: Bearer header with the resolved API key. */\n\tauthHeader?: boolean;\n\t/** Models to register. If provided, replaces all existing models for this provider. */\n\tmodels?: ProviderModelConfig[];\n\t/** OAuth provider for /login support. The `id` is set automatically from the provider name. */\n\toauth?: {\n\t\t/** Display name for the provider in login UI. */\n\t\tname: string;\n\t\t/** Run the login flow, return credentials to persist. */\n\t\tlogin(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;\n\t\t/** Refresh expired credentials, return updated credentials to persist. */\n\t\trefreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>;\n\t\t/** Convert credentials to API key string for the provider. */\n\t\tgetApiKey(credentials: OAuthCredentials): string;\n\t\t/** Optional: modify models for this provider (e.g., update baseUrl based on credentials). */\n\t\tmodifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];\n\t};\n}\n\n/** Configuration for a model within a provider. */\nexport interface ProviderModelConfig {\n\t/** Model ID (e.g., \"claude-sonnet-4-20250514\"). */\n\tid: string;\n\t/** Display name (e.g., \"Claude 4 Sonnet\"). */\n\tname: string;\n\t/** API type override for this model. */\n\tapi?: Api;\n\t/** API endpoint URL override for this model. */\n\tbaseUrl?: string;\n\t/** Whether the model supports extended thinking. */\n\treasoning: boolean;\n\t/** Maps pi thinking levels to provider/model-specific values; null marks a level unsupported. */\n\tthinkingLevelMap?: Model<Api>[\"thinkingLevelMap\"];\n\t/** Supported input types. */\n\tinput: (\"text\" | \"image\")[];\n\t/** Cost per token (for tracking, can be 0). */\n\tcost: { input: number; output: number; cacheRead: number; cacheWrite: number };\n\t/** Maximum context window size in tokens. */\n\tcontextWindow: number;\n\t/** Maximum output tokens. */\n\tmaxTokens: number;\n\t/** Custom headers for this model. */\n\theaders?: Record<string, string>;\n\t/** OpenAI compatibility settings. */\n\tcompat?: Model<Api>[\"compat\"];\n}\n\n/** Extension factory function type. Supports both sync and async initialization. */\nexport type ExtensionFactory = ((pi: ExtensionAPI) => void | Promise<void>) & {\n\tdisplayName?: string;\n\t/** Internal plumbing factory; excluded from the loaded resources listing. */\n\tinternal?: boolean;\n};\n\n// ============================================================================\n// Loaded Extension Types\n// ============================================================================\n\nexport interface RegisteredTool {\n\tdefinition: ToolDefinition;\n\tsourceInfo: SourceInfo;\n}\n\nexport interface ExtensionFlag {\n\tname: string;\n\tdescription?: string;\n\ttype: \"boolean\" | \"string\";\n\tdefault?: boolean | string;\n\textensionPath: string;\n}\n\nexport interface ExtensionShortcut {\n\tshortcut: KeyId;\n\tdescription?: string;\n\thandler: (ctx: ExtensionContext) => Promise<void> | void;\n\textensionPath: string;\n}\n\ntype HandlerFn = (...args: unknown[]) => Promise<unknown>;\n\ntype SendMessageHandler = <T = unknown>(\n\tmessage: Pick<CustomMessage<T>, \"customType\" | \"content\" | \"display\" | \"details\">,\n\toptions?: { triggerTurn?: boolean; deliverAs?: \"steer\" | \"followUp\" | \"nextTurn\" },\n) => void;\n\ntype SendUserMessageHandler = (\n\tcontent: string | (TextContent | ImageContent)[],\n\toptions?: { deliverAs?: \"steer\" | \"followUp\" },\n) => void;\n\ntype AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;\n\ntype SetSessionNameHandler = (name: string) => void;\n\ntype GetSessionNameHandler = () => string | undefined;\n\ntype GetActiveToolsHandler = () => string[];\n\n/** Tool info with name, description, parameter schema, and source metadata */\nexport type ToolInfo = Pick<ToolDefinition, \"name\" | \"description\" | \"parameters\"> & {\n\tsourceInfo: SourceInfo;\n};\n\ntype GetAllToolsHandler = () => ToolInfo[];\n\ntype GetCommandsHandler = () => SlashCommandInfo[];\n\ntype SetActiveToolsHandler = (toolNames: string[]) => void;\n\ntype RefreshToolsHandler = () => void;\n\ntype SetModelHandler = (model: Model<any>) => Promise<boolean>;\n\ntype GetThinkingLevelHandler = () => ThinkingLevel;\n\ntype SetThinkingLevelHandler = (level: ThinkingLevel) => void;\n\ntype SetLabelHandler = (entryId: string, label: string | undefined) => void;\n\n/**\n * Shared state created by loader, used during registration and runtime.\n * Contains flag values (defaults set during registration, CLI values set after).\n */\ninterface ExtensionRuntimeState {\n\tflagValues: Map<string, boolean | string>;\n\t/** Provider registrations queued during extension loading, processed when runner binds */\n\tpendingProviderRegistrations: Array<{ name: string; config: ProviderConfig; extensionPath: string }>;\n\t/** Mode search dirs registered via pi.addModeSearchPath, in declared order. */\n\tmodeSearchPaths: string[];\n\t/** Throws when this extension instance is stale after runtime replacement. */\n\tassertActive: () => void;\n\t/** Marks this extension instance as stale after runtime replacement or reload. */\n\tinvalidate: (message?: string) => void;\n\t/**\n\t * Register or unregister a provider.\n\t *\n\t * Before bindCore(): queues registrations / removes from queue.\n\t * After bindCore(): calls ModelRegistry directly for immediate effect.\n\t */\n\tregisterProvider: (name: string, config: ProviderConfig, extensionPath?: string) => void;\n\tunregisterProvider: (name: string, extensionPath?: string) => void;\n}\n\n/**\n * Action implementations for pi.* API methods.\n * Provided to runner.initialize(), copied into the shared runtime.\n */\nexport interface ExtensionActions {\n\tsendMessage: SendMessageHandler;\n\tsendUserMessage: SendUserMessageHandler;\n\tappendEntry: AppendEntryHandler;\n\tsetSessionName: SetSessionNameHandler;\n\tgetSessionName: GetSessionNameHandler;\n\tsetLabel: SetLabelHandler;\n\tgetActiveTools: GetActiveToolsHandler;\n\tgetAllTools: GetAllToolsHandler;\n\tsetActiveTools: SetActiveToolsHandler;\n\trefreshTools: RefreshToolsHandler;\n\tgetCommands: GetCommandsHandler;\n\tsetModel: SetModelHandler;\n\tgetThinkingLevel: GetThinkingLevelHandler;\n\tsetThinkingLevel: SetThinkingLevelHandler;\n}\n\n/**\n * Actions for ExtensionContext (ctx.* in event handlers).\n * Required by all modes.\n */\nexport interface ExtensionContextActions {\n\tgetModel: () => Model<any> | undefined;\n\tisIdle: () => boolean;\n\tgetSignal: () => AbortSignal | undefined;\n\tabort: () => void;\n\thasPendingMessages: () => boolean;\n\tshutdown: () => void;\n\tgetContextUsage: () => ContextUsage | undefined;\n\tcompact: (options?: CompactOptions) => void;\n\tgetSystemPrompt: () => string;\n\tactivatePlugin: (pluginDir: string) => PluginActivationResult;\n\trequestReloadWhenIdle: () => void;\n}\n\n/**\n * Actions for ExtensionCommandContext (ctx.* in command handlers).\n * Only needed for interactive mode where extension commands are invokable.\n */\nexport interface ExtensionCommandContextActions {\n\twaitForIdle: () => Promise<void>;\n\tnewSession: (options?: {\n\t\tparentSession?: string;\n\t\tsetup?: (sessionManager: SessionManager) => Promise<void>;\n\t\twithSession?: (ctx: ReplacedSessionContext) => Promise<void>;\n\t}) => Promise<{ cancelled: boolean }>;\n\tfork: (\n\t\tentryId: string,\n\t\toptions?: { position?: \"before\" | \"at\"; withSession?: (ctx: ReplacedSessionContext) => Promise<void> },\n\t) => Promise<{ cancelled: boolean }>;\n\tnavigateTree: (\n\t\ttargetId: string,\n\t\toptions?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string },\n\t) => Promise<{ cancelled: boolean }>;\n\tswitchSession: (\n\t\tsessionPath: string,\n\t\toptions?: { withSession?: (ctx: ReplacedSessionContext) => Promise<void> },\n\t) => Promise<{ cancelled: boolean }>;\n\treload: () => Promise<void>;\n}\n\n/**\n * Full runtime = state + actions.\n * Created by loader with throwing action stubs, completed by runner.initialize().\n */\nexport interface ExtensionRuntime extends ExtensionRuntimeState, ExtensionActions {}\n\n/** Loaded extension with all registered items. */\nexport interface Extension {\n\tpath: string;\n\tresolvedPath: string;\n\tdisplayName?: string;\n\t/** Internal plumbing extension; excluded from the loaded resources listing. */\n\tinternal?: boolean;\n\tsourceInfo: SourceInfo;\n\thandlers: Map<string, HandlerFn[]>;\n\ttools: Map<string, RegisteredTool>;\n\tmessageRenderers: Map<string, MessageRenderer>;\n\tcommands: Map<string, RegisteredCommand>;\n\tflags: Map<string, ExtensionFlag>;\n\tshortcuts: Map<KeyId, ExtensionShortcut>;\n}\n\n/** Result of loading extensions. */\nexport interface LoadExtensionsResult {\n\textensions: Extension[];\n\terrors: Array<{ path: string; error: string }>;\n\t/** Shared runtime - actions are throwing stubs until runner.initialize() */\n\truntime: ExtensionRuntime;\n}\n\n// ============================================================================\n// Extension Error\n// ============================================================================\n\nexport interface ExtensionError {\n\textensionPath: string;\n\tevent: string;\n\terror: string;\n\tstack?: string;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/core/extensions/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AA6iBH;;;;;;GAMG;AACH,MAAM,UAAU,UAAU,CACzB,IAA+C,EACiB;IAChE,OAAO,IAAqE,CAAC;AAAA,CAC7E;AAuZD,kCAAkC;AAClC,MAAM,UAAU,gBAAgB,CAAC,CAAkB,EAA4B;IAC9E,OAAO,CAAC,CAAC,QAAQ,KAAK,MAAM,CAAC;AAAA,CAC7B;AACD,MAAM,UAAU,gBAAgB,CAAC,CAAkB,EAA4B;IAC9E,OAAO,CAAC,CAAC,QAAQ,KAAK,MAAM,CAAC;AAAA,CAC7B;AACD,MAAM,UAAU,gBAAgB,CAAC,CAAkB,EAA4B;IAC9E,OAAO,CAAC,CAAC,QAAQ,KAAK,MAAM,CAAC;AAAA,CAC7B;AACD,MAAM,UAAU,iBAAiB,CAAC,CAAkB,EAA6B;IAChF,OAAO,CAAC,CAAC,QAAQ,KAAK,OAAO,CAAC;AAAA,CAC9B;AACD,MAAM,UAAU,gBAAgB,CAAC,CAAkB,EAA4B;IAC9E,OAAO,CAAC,CAAC,QAAQ,KAAK,MAAM,CAAC;AAAA,CAC7B;AACD,MAAM,UAAU,gBAAgB,CAAC,CAAkB,EAA4B;IAC9E,OAAO,CAAC,CAAC,QAAQ,KAAK,MAAM,CAAC;AAAA,CAC7B;AACD,MAAM,UAAU,cAAc,CAAC,CAAkB,EAA0B;IAC1E,OAAO,CAAC,CAAC,QAAQ,KAAK,IAAI,CAAC;AAAA,CAC3B;AAiCD,MAAM,UAAU,mBAAmB,CAAC,QAAgB,EAAE,KAAoB,EAAW;IACpF,OAAO,KAAK,CAAC,QAAQ,KAAK,QAAQ,CAAC;AAAA,CACnC","sourcesContent":["/**\n * Extension system types.\n *\n * Extensions are TypeScript modules that can:\n * - Subscribe to agent lifecycle events\n * - Register LLM-callable tools\n * - Register commands, keyboard shortcuts, and CLI flags\n * - Interact with the user via UI primitives\n */\n\nimport type {\n\tAgentMessage,\n\tAgentToolCall,\n\tAgentToolResult,\n\tAgentToolUpdateCallback,\n\tCompactionPreparation,\n\tCompactionResult,\n\tCustomMessage,\n\tThinkingLevel,\n\tToolExecutionMode,\n} from \"@kolisachint/hoocode-agent-core\";\nimport type {\n\tApi,\n\tAssistantMessageEvent,\n\tAssistantMessageEventStream,\n\tContext,\n\tImageContent,\n\tModel,\n\tOAuthCredentials,\n\tOAuthLoginCallbacks,\n\tSimpleStreamOptions,\n\tTextContent,\n\tToolResultMessage,\n} from \"@kolisachint/hoocode-ai\";\nimport type {\n\tAutocompleteItem,\n\tAutocompleteProvider,\n\tComponent,\n\tEditorComponent,\n\tEditorTheme,\n\tKeyId,\n\tOverlayHandle,\n\tOverlayOptions,\n\tTUI,\n} from \"@kolisachint/hoocode-tui\";\nimport type { Static, TSchema } from \"typebox\";\nimport type { Theme } from \"../../modes/interactive/theme/theme.js\";\nimport type { BashResult } from \"../bash-executor.js\";\nimport type { EventBus } from \"../event-bus.js\";\nimport type { ExecOptions, ExecResult } from \"../exec.js\";\nimport type { ReadonlyFooterDataProvider } from \"../footer-data-provider.js\";\nimport type { KeybindingsManager } from \"../keybindings.js\";\nimport type { ModelRegistry } from \"../model-registry.js\";\nimport type {\n\tBranchSummaryEntry,\n\tCompactionEntry,\n\tReadonlySessionManager,\n\tSessionEntry,\n\tSessionManager,\n} from \"../session-manager.js\";\nimport type { SlashCommandInfo } from \"../slash-commands.js\";\nimport type { SourceInfo } from \"../source-info.js\";\nimport type { BuildSystemPromptOptions } from \"../system-prompt.js\";\nimport type { BashOperations } from \"../tools/bash.js\";\nimport type { EditToolDetails } from \"../tools/edit.js\";\nimport type {\n\tBashToolDetails,\n\tBashToolInput,\n\tEditToolInput,\n\tFindToolDetails,\n\tFindToolInput,\n\tGrepToolDetails,\n\tGrepToolInput,\n\tLsToolDetails,\n\tLsToolInput,\n\tReadToolDetails,\n\tReadToolInput,\n\tWriteToolInput,\n} from \"../tools/index.js\";\n\nexport type { ExecOptions, ExecResult } from \"../exec.js\";\nexport type { BuildSystemPromptOptions } from \"../system-prompt.js\";\nexport type { AgentToolResult, AgentToolUpdateCallback, ToolExecutionMode };\nexport type { AppKeybinding, KeybindingsManager } from \"../keybindings.js\";\n\n// ============================================================================\n// UI Context\n// ============================================================================\n\n/** Options for extension UI dialogs. */\nexport interface ExtensionUIDialogOptions {\n\t/** AbortSignal to programmatically dismiss the dialog. */\n\tsignal?: AbortSignal;\n\t/** Timeout in milliseconds. Dialog auto-dismisses with live countdown display. */\n\ttimeout?: number;\n}\n\n/** A single selectable option in an {@link AskQuestion}. */\ninterface AskOption {\n\t/** The option text shown to the user and returned when chosen. */\n\tlabel: string;\n\t/** Optional secondary description shown next to the label. */\n\tdescription?: string;\n\t/** When true, the option is marked \"(recommended)\" to help the user choose. */\n\trecommended?: boolean;\n}\n\n/** One decision the agent asks the user to make in the options pane. */\nexport interface AskQuestion {\n\t/** The question shown to the user. */\n\tquestion: string;\n\t/** Short label used in the answered-step breadcrumb. Defaults to `question`. */\n\tshort?: string;\n\t/** Optional sub-text rendered under the question. */\n\tdetail?: string;\n\t/** The selectable options. */\n\toptions: AskOption[];\n\t/** When true, a custom row lets the user type their own answer. */\n\tallowCustom?: boolean;\n}\n\n/** Placement for extension widgets. */\nexport type WidgetPlacement = \"aboveEditor\" | \"belowEditor\";\n\n/** Options for extension widgets. */\nexport interface ExtensionWidgetOptions {\n\t/** Where the widget is rendered. Defaults to \"aboveEditor\". */\n\tplacement?: WidgetPlacement;\n}\n\n/** Raw terminal input listener for extensions. */\nexport type TerminalInputHandler = (data: string) => { consume?: boolean; data?: string } | undefined;\n\n/** Working indicator configuration for the interactive streaming loader. */\nexport interface WorkingIndicatorOptions {\n\t/** Animation frames. Use an empty array to hide the indicator entirely. Custom frames are rendered verbatim. */\n\tframes?: string[];\n\t/** Frame interval in milliseconds for animated indicators. */\n\tintervalMs?: number;\n}\n\n/** Wrap the current autocomplete provider with additional behavior. */\nexport type AutocompleteProviderFactory = (current: AutocompleteProvider) => AutocompleteProvider;\nexport type EditorFactory = (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => EditorComponent;\n\n/**\n * UI context for extensions to request interactive UI.\n * Each mode (interactive, RPC, print) provides its own implementation.\n */\nexport interface ExtensionUIContext {\n\t/** Show a selector and return the user's choice. */\n\tselect(title: string, options: string[], opts?: ExtensionUIDialogOptions): Promise<string | undefined>;\n\n\t/** Show a confirmation dialog. */\n\tconfirm(title: string, message: string, opts?: ExtensionUIDialogOptions): Promise<boolean>;\n\n\t/** Show a text input dialog. */\n\tinput(title: string, placeholder?: string, opts?: ExtensionUIDialogOptions): Promise<string | undefined>;\n\n\t/**\n\t * Show the options pane and ask the user one or more questions, each with a\n\t * set of options (and optionally a free-form custom answer). Returns one\n\t * answer string per question in order, or undefined if the user skipped.\n\t */\n\taskOptions(questions: AskQuestion[], opts?: ExtensionUIDialogOptions): Promise<string[] | undefined>;\n\n\t/** Show a notification to the user. */\n\tnotify(message: string, type?: \"info\" | \"warning\" | \"error\"): void;\n\n\t/**\n\t * Terminal width in columns, when the surface has one.\n\t *\n\t * Undefined outside a terminal (RPC, print, headless), which is the signal to\n\t * emit unwrapped text and let the consumer lay it out — a listing pre-wrapped\n\t * to someone else's terminal is worse than no wrapping at all. Optional so\n\t * third-party implementations of this interface keep compiling.\n\t */\n\treadonly columns?: number;\n\n\t/** Listen to raw terminal input (interactive mode only). Returns an unsubscribe function. */\n\tonTerminalInput(handler: TerminalInputHandler): () => void;\n\n\t/** Set status text in the footer/status bar. Pass undefined to clear. */\n\tsetStatus(key: string, text: string | undefined): void;\n\n\t/**\n\t * Set the active mode displayed in the footer.\n\t * Format: mode (e.g., 'ask', 'plan', 'build', 'debug')\n\t */\n\tsetMode(mode: string): void;\n\n\t/** Set the working/loading message shown during streaming. Call with no argument to restore default. */\n\tsetWorkingMessage(message?: string): void;\n\n\t/** Show or hide the built-in interactive working loader row during streaming. */\n\tsetWorkingVisible(visible: boolean): void;\n\n\t/**\n\t * Configure the interactive working indicator shown during streaming.\n\t *\n\t * - Omit the argument to restore the default animated spinner.\n\t * - Use `frames: [\"●\"]` for a static indicator.\n\t * - Use `frames: []` to hide the indicator entirely.\n\t * - Custom frames are rendered as provided, so extensions must add their own colors.\n\t */\n\tsetWorkingIndicator(options?: WorkingIndicatorOptions): void;\n\n\t/** Set the label shown for hidden thinking blocks. Call with no argument to restore default. */\n\tsetHiddenThinkingLabel(label?: string): void;\n\n\t/** Set a widget to display above or below the editor. Accepts string array or component factory. */\n\tsetWidget(key: string, content: string[] | undefined, options?: ExtensionWidgetOptions): void;\n\tsetWidget(\n\t\tkey: string,\n\t\tcontent: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined,\n\t\toptions?: ExtensionWidgetOptions,\n\t): void;\n\n\t/** Set a custom footer component, or undefined to restore the built-in footer.\n\t *\n\t * The factory receives a FooterDataProvider for data not otherwise accessible:\n\t * git branch and extension statuses from setStatus(). Token stats, model info,\n\t * etc. are available via ctx.sessionManager and ctx.model.\n\t */\n\tsetFooter(\n\t\tfactory:\n\t\t\t| ((tui: TUI, theme: Theme, footerData: ReadonlyFooterDataProvider) => Component & { dispose?(): void })\n\t\t\t| undefined,\n\t): void;\n\n\t/** Set a custom header component (shown at startup, above chat), or undefined to restore the built-in header. */\n\tsetHeader(factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;\n\n\t/** Set the terminal window/tab title. */\n\tsetTitle(title: string): void;\n\n\t/** Show a custom component with keyboard focus. */\n\tcustom<T>(\n\t\tfactory: (\n\t\t\ttui: TUI,\n\t\t\ttheme: Theme,\n\t\t\tkeybindings: KeybindingsManager,\n\t\t\tdone: (result: T) => void,\n\t\t) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,\n\t\toptions?: {\n\t\t\toverlay?: boolean;\n\t\t\t/** Overlay positioning/sizing options. Can be static or a function for dynamic updates. */\n\t\t\toverlayOptions?: OverlayOptions | (() => OverlayOptions);\n\t\t\t/** Called with the overlay handle after the overlay is shown. Use to control visibility. */\n\t\t\tonHandle?: (handle: OverlayHandle) => void;\n\t\t},\n\t): Promise<T>;\n\n\t/** Paste text into the editor, triggering paste handling (collapse for large content). */\n\tpasteToEditor(text: string): void;\n\n\t/** Set the text in the core input editor. */\n\tsetEditorText(text: string): void;\n\n\t/** Get the current text from the core input editor. */\n\tgetEditorText(): string;\n\n\t/** Show a multi-line editor for text editing. */\n\teditor(title: string, prefill?: string): Promise<string | undefined>;\n\n\t/** Stack additional autocomplete behavior on top of the built-in provider. */\n\taddAutocompleteProvider(factory: AutocompleteProviderFactory): void;\n\n\t/**\n\t * Set a custom editor component via factory function.\n\t * Pass undefined to restore the default editor.\n\t *\n\t * The factory receives:\n\t * - `theme`: EditorTheme for styling borders and autocomplete\n\t * - `keybindings`: KeybindingsManager for app-level keybindings\n\t *\n\t * For full app keybinding support (escape, ctrl+d, model switching, etc.),\n\t * extend `CustomEditor` from `@kolisachint/hoocode-agent` and call\n\t * `super.handleInput(data)` for keys you don't handle.\n\t *\n\t * @example\n\t * ```ts\n\t * import { CustomEditor } from \"@kolisachint/hoocode-agent\";\n\t *\n\t * class VimEditor extends CustomEditor {\n\t * private mode: \"normal\" | \"insert\" = \"insert\";\n\t *\n\t * handleInput(data: string): void {\n\t * if (this.mode === \"normal\") {\n\t * // Handle vim normal mode keys...\n\t * if (data === \"i\") { this.mode = \"insert\"; return; }\n\t * }\n\t * super.handleInput(data); // App keybindings + text editing\n\t * }\n\t * }\n\t *\n\t * ctx.ui.setEditorComponent((tui, theme, keybindings) =>\n\t * new VimEditor(tui, theme, keybindings)\n\t * );\n\t * ```\n\t */\n\tsetEditorComponent(factory: EditorFactory | undefined): void;\n\n\t/** Get the currently configured custom editor factory, or undefined when using the default editor. */\n\tgetEditorComponent(): EditorFactory | undefined;\n\n\t/** Get the current theme for styling. */\n\treadonly theme: Theme;\n\n\t/** Get all available themes with their names and file paths. */\n\tgetAllThemes(): { name: string; path: string | undefined }[];\n\n\t/** Load a theme by name without switching to it. Returns undefined if not found. */\n\tgetTheme(name: string): Theme | undefined;\n\n\t/** Set the current theme by name or Theme object. */\n\tsetTheme(theme: string | Theme): { success: boolean; error?: string };\n\n\t/** Get current tool output expansion state. */\n\tgetToolsExpanded(): boolean;\n\n\t/** Set tool output expansion state. */\n\tsetToolsExpanded(expanded: boolean): void;\n}\n\n// ============================================================================\n// Extension Context\n// ============================================================================\n\nexport interface ContextUsage {\n\t/** Estimated context tokens, or null if unknown (e.g. right after compaction, before next LLM response). */\n\ttokens: number | null;\n\tcontextWindow: number;\n\t/** Context usage as percentage of context window, or null if tokens is unknown. */\n\tpercent: number | null;\n}\n\nexport interface CompactOptions {\n\tcustomInstructions?: string;\n\tonComplete?: (result: CompactionResult) => void;\n\tonError?: (error: Error) => void;\n}\n\n/** A capability surfaced by a live plugin activation. */\nexport interface ActivatedCapability {\n\tname: string;\n\tdescription?: string;\n}\n\n/**\n * Result of activating a plugin in the live session (no reload).\n * See AgentSession.activatePlugin.\n */\nexport type PluginActivationResult =\n\t| { activated: false; message: string }\n\t| {\n\t\t\tactivated: true;\n\t\t\tpluginId: string;\n\t\t\tskills: ActivatedCapability[];\n\t\t\tcommands: ActivatedCapability[];\n\t\t\tagents: ActivatedCapability[];\n\t\t\t/** True when hooks/MCP servers/providers were found and a full reload was scheduled for idle. */\n\t\t\tpendingReloadForExecutables: boolean;\n\t\t\tmessage: string;\n\t };\n\n/**\n * Context passed to extension event handlers.\n */\nexport interface ExtensionContext {\n\t/** UI methods for user interaction */\n\tui: ExtensionUIContext;\n\t/** Whether UI is available (false in print/RPC mode) */\n\thasUI: boolean;\n\t/** Current working directory */\n\tcwd: string;\n\t/** Session manager (read-only) */\n\tsessionManager: ReadonlySessionManager;\n\t/** Model registry for API key resolution */\n\tmodelRegistry: ModelRegistry;\n\t/** Current model (may be undefined) */\n\tmodel: Model<any> | undefined;\n\t/** Whether the agent is idle (not streaming) */\n\tisIdle(): boolean;\n\t/** The current abort signal, or undefined when the agent is not streaming. */\n\tsignal: AbortSignal | undefined;\n\t/** Abort the current agent operation */\n\tabort(): void;\n\t/** Whether there are queued messages waiting */\n\thasPendingMessages(): boolean;\n\t/** Gracefully shutdown pi and exit. Available in all contexts. */\n\tshutdown(): void;\n\t/** Get current context usage for the active model. */\n\tgetContextUsage(): ContextUsage | undefined;\n\t/** Trigger compaction without awaiting completion. */\n\tcompact(options?: CompactOptions): void;\n\t/** Get the current effective system prompt. */\n\tgetSystemPrompt(): string;\n\t/**\n\t * Activate a just-installed/authored plugin in the LIVE session. Passive\n\t * capabilities (skills, commands, subagents, themes) become usable on the\n\t * model's next request — mid-turn included; executable capabilities (hooks,\n\t * MCP servers) trigger an automatic reload once the session is idle.\n\t */\n\tactivatePlugin(pluginDir: string): PluginActivationResult;\n\t/** Schedule a full reload for when the session next goes idle (immediate when already idle). */\n\trequestReloadWhenIdle(): void;\n}\n\n/**\n * Extended context for command handlers.\n * Includes session control methods only safe in user-initiated commands.\n */\nexport interface ExtensionCommandContext extends ExtensionContext {\n\t/** Wait for the agent to finish streaming */\n\twaitForIdle(): Promise<void>;\n\n\t/** Start a new session, optionally with initialization. */\n\tnewSession(options?: {\n\t\tparentSession?: string;\n\t\tsetup?: (sessionManager: SessionManager) => Promise<void>;\n\t\twithSession?: (ctx: ReplacedSessionContext) => Promise<void>;\n\t}): Promise<{ cancelled: boolean }>;\n\n\t/** Fork from a specific entry, creating a new session file. */\n\tfork(\n\t\tentryId: string,\n\t\toptions?: { position?: \"before\" | \"at\"; withSession?: (ctx: ReplacedSessionContext) => Promise<void> },\n\t): Promise<{ cancelled: boolean }>;\n\n\t/** Navigate to a different point in the session tree. */\n\tnavigateTree(\n\t\ttargetId: string,\n\t\toptions?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string },\n\t): Promise<{ cancelled: boolean }>;\n\n\t/** Switch to a different session file. */\n\tswitchSession(\n\t\tsessionPath: string,\n\t\toptions?: { withSession?: (ctx: ReplacedSessionContext) => Promise<void> },\n\t): Promise<{ cancelled: boolean }>;\n\n\t/** Reload extensions, skills, prompts, and themes. */\n\treload(): Promise<void>;\n}\n\n/**\n * Fresh command-capable context bound to the replacement session after a session switch.\n *\n * This is passed to `withSession()` callbacks on `newSession()`, `fork()`, and `switchSession()`.\n */\nexport interface ReplacedSessionContext extends ExtensionCommandContext {\n\tsendMessage<T = unknown>(\n\t\tmessage: Pick<CustomMessage<T>, \"customType\" | \"content\" | \"display\" | \"details\">,\n\t\toptions?: { triggerTurn?: boolean; deliverAs?: \"steer\" | \"followUp\" | \"nextTurn\" },\n\t): Promise<void>;\n\n\tsendUserMessage(\n\t\tcontent: string | (TextContent | ImageContent)[],\n\t\toptions?: { deliverAs?: \"steer\" | \"followUp\" },\n\t): Promise<void>;\n}\n\n// ============================================================================\n// Tool Types\n// ============================================================================\n\n/** Rendering options for tool results */\nexport interface ToolRenderResultOptions {\n\t/** Whether the result view is expanded */\n\texpanded: boolean;\n\t/** Whether this is a partial/streaming result */\n\tisPartial: boolean;\n}\n\n/** Context passed to tool renderers. */\nexport interface ToolRenderContext<TState = any, TArgs = any> {\n\t/** Current tool call arguments. Shared across call/result renders for the same tool call. */\n\targs: TArgs;\n\t/** Unique id for this tool execution. Stable across call/result renders for the same tool call. */\n\ttoolCallId: string;\n\t/** Invalidate just this tool execution component for redraw. */\n\tinvalidate: () => void;\n\t/** Previously returned component for this render slot, if any. */\n\tlastComponent: Component | undefined;\n\t/** Shared renderer state for this tool row. Initialized by tool-execution.ts. */\n\tstate: TState;\n\t/** Working directory for this tool execution. */\n\tcwd: string;\n\t/** Whether the tool execution has started. */\n\texecutionStarted: boolean;\n\t/** Whether the tool call arguments are complete. */\n\targsComplete: boolean;\n\t/** Whether the tool result is partial/streaming. */\n\tisPartial: boolean;\n\t/** Whether the result view is expanded. */\n\texpanded: boolean;\n\t/** Whether inline images are currently shown in the TUI. */\n\tshowImages: boolean;\n\t/** Whether the current result is an error. */\n\tisError: boolean;\n}\n\n/**\n * Tool definition for registerTool().\n */\nexport interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = unknown, TState = any> {\n\t/** Tool name (used in LLM tool calls) */\n\tname: string;\n\t/** Human-readable label for UI */\n\tlabel: string;\n\t/** Description for LLM */\n\tdescription: string;\n\t/** Optional one-line snippet for the Available tools section in the default system prompt. Custom tools are omitted from that section when this is not provided. */\n\tpromptSnippet?: string;\n\t/** Optional guideline bullets appended to the default system prompt Guidelines section when this tool is active. */\n\tpromptGuidelines?: string[];\n\t/** Parameter schema (TypeBox) */\n\tparameters: TParams;\n\t/** Controls whether ToolExecutionComponent renders the standard colored shell or the tool renders its own framing. */\n\trenderShell?: \"default\" | \"self\";\n\n\t/** Optional compatibility shim to prepare raw tool call arguments before schema validation. Must return an object conforming to TParams. */\n\tprepareArguments?: (args: unknown) => Static<TParams>;\n\n\t/**\n\t * Per-tool execution mode override.\n\t * - \"sequential\": this tool must execute one at a time with other tool calls.\n\t * - \"parallel\": this tool can execute concurrently with other tool calls.\n\t *\n\t * If omitted, the default execution mode applies.\n\t */\n\texecutionMode?: ToolExecutionMode;\n\n\t/**\n\t * Run this tool non-blocking. The agent loop answers the tool call with a\n\t * placeholder immediately and keeps reasoning while `execute` runs detached;\n\t * the real result is injected later as a follow-up message. May be a predicate\n\t * evaluated per tool call when background-ness depends on the arguments.\n\t */\n\tbackground?: boolean | ((toolCall: AgentToolCall) => boolean);\n\n\t/** Execute the tool. */\n\texecute(\n\t\ttoolCallId: string,\n\t\tparams: Static<TParams>,\n\t\tsignal: AbortSignal | undefined,\n\t\tonUpdate: AgentToolUpdateCallback<TDetails> | undefined,\n\t\tctx: ExtensionContext,\n\t): Promise<AgentToolResult<TDetails>>;\n\n\t/** Custom rendering for tool call display */\n\trenderCall?: (args: Static<TParams>, theme: Theme, context: ToolRenderContext<TState, Static<TParams>>) => Component;\n\n\t/** Custom rendering for tool result display */\n\trenderResult?: (\n\t\tresult: AgentToolResult<TDetails>,\n\t\toptions: ToolRenderResultOptions,\n\t\ttheme: Theme,\n\t\tcontext: ToolRenderContext<TState, Static<TParams>>,\n\t) => Component;\n}\n\ntype AnyToolDefinition = ToolDefinition<any, any, any>;\n\n/**\n * Preserve parameter inference for standalone tool definitions.\n *\n * Use this when assigning a tool to a variable or passing it through arrays such\n * as `customTools`, where contextual typing would otherwise widen params to\n * `unknown`.\n */\nexport function defineTool<TParams extends TSchema, TDetails = unknown, TState = any>(\n\ttool: ToolDefinition<TParams, TDetails, TState>,\n): ToolDefinition<TParams, TDetails, TState> & AnyToolDefinition {\n\treturn tool as ToolDefinition<TParams, TDetails, TState> & AnyToolDefinition;\n}\n\n// ============================================================================\n// Resource Events\n// ============================================================================\n\n/** Fired after session_start to allow extensions to provide additional resource paths. */\nexport interface ResourcesDiscoverEvent {\n\ttype: \"resources_discover\";\n\tcwd: string;\n\treason: \"startup\" | \"reload\";\n}\n\n/** Result from resources_discover event handler */\nexport interface ResourcesDiscoverResult {\n\tskillPaths?: string[];\n\tpromptPaths?: string[];\n\tthemePaths?: string[];\n\t/** Slash-command directories or `.md` files (the `.agents/commands` surface). */\n\tslashCommandPaths?: string[];\n\t/** Subagent definition directories or `.md` files (the `.agents/agents` surface). */\n\tagentPaths?: string[];\n}\n\n// ============================================================================\n// Session Events\n// ============================================================================\n\n/** Fired when a session is started, loaded, or reloaded */\nexport interface SessionStartEvent {\n\ttype: \"session_start\";\n\t/** Why this session start happened. */\n\treason: \"startup\" | \"reload\" | \"new\" | \"resume\" | \"fork\";\n\t/** Previously active session file. Present for \"new\", \"resume\", and \"fork\". */\n\tpreviousSessionFile?: string;\n}\n\n/** Fired before switching to another session (can be cancelled) */\nexport interface SessionBeforeSwitchEvent {\n\ttype: \"session_before_switch\";\n\treason: \"new\" | \"resume\";\n\ttargetSessionFile?: string;\n}\n\n/** Fired before forking a session (can be cancelled) */\nexport interface SessionBeforeForkEvent {\n\ttype: \"session_before_fork\";\n\tentryId: string;\n\tposition: \"before\" | \"at\";\n}\n\n/** Fired before context compaction (can be cancelled or customized) */\nexport interface SessionBeforeCompactEvent {\n\ttype: \"session_before_compact\";\n\tpreparation: CompactionPreparation;\n\tbranchEntries: SessionEntry[];\n\tcustomInstructions?: string;\n\tsignal: AbortSignal;\n}\n\n/** Fired after context compaction */\nexport interface SessionCompactEvent {\n\ttype: \"session_compact\";\n\tcompactionEntry: CompactionEntry;\n\tfromExtension: boolean;\n}\n\n/** Fired before an extension runtime is torn down due to quit, reload, or session replacement. */\nexport interface SessionShutdownEvent {\n\ttype: \"session_shutdown\";\n\treason: \"quit\" | \"reload\" | \"new\" | \"resume\" | \"fork\";\n\t/** Destination session file when shutting down due to session replacement. */\n\ttargetSessionFile?: string;\n}\n\n/** Preparation data for tree navigation */\nexport interface TreePreparation {\n\ttargetId: string;\n\toldLeafId: string | null;\n\tcommonAncestorId: string | null;\n\tentriesToSummarize: SessionEntry[];\n\tuserWantsSummary: boolean;\n\t/** Custom instructions for summarization */\n\tcustomInstructions?: string;\n\t/** If true, customInstructions replaces the default prompt instead of being appended */\n\treplaceInstructions?: boolean;\n\t/** Label to attach to the branch summary entry */\n\tlabel?: string;\n}\n\n/** Fired before navigating in the session tree (can be cancelled) */\nexport interface SessionBeforeTreeEvent {\n\ttype: \"session_before_tree\";\n\tpreparation: TreePreparation;\n\tsignal: AbortSignal;\n}\n\n/** Fired after navigating in the session tree */\nexport interface SessionTreeEvent {\n\ttype: \"session_tree\";\n\tnewLeafId: string | null;\n\toldLeafId: string | null;\n\tsummaryEntry?: BranchSummaryEntry;\n\tfromExtension?: boolean;\n}\n\nexport type SessionEvent =\n\t| SessionStartEvent\n\t| SessionBeforeSwitchEvent\n\t| SessionBeforeForkEvent\n\t| SessionBeforeCompactEvent\n\t| SessionCompactEvent\n\t| SessionShutdownEvent\n\t| SessionBeforeTreeEvent\n\t| SessionTreeEvent;\n\n// ============================================================================\n// Agent Events\n// ============================================================================\n\n/** Fired before each LLM call. Can modify messages. */\nexport interface ContextEvent {\n\ttype: \"context\";\n\tmessages: AgentMessage[];\n}\n\n/** Fired before a provider request is sent. Can replace the payload. */\nexport interface BeforeProviderRequestEvent {\n\ttype: \"before_provider_request\";\n\tpayload: unknown;\n}\n\n/** Fired after a provider response is received and before the response stream is consumed. */\ninterface AfterProviderResponseEvent {\n\ttype: \"after_provider_response\";\n\tstatus: number;\n\theaders: Record<string, string>;\n}\n\n/** Fired after user submits prompt but before agent loop. */\nexport interface BeforeAgentStartEvent {\n\ttype: \"before_agent_start\";\n\t/** The raw user prompt text (after expansion). */\n\tprompt: string;\n\t/** Images attached to the user prompt, if any. */\n\timages?: ImageContent[];\n\t/** The fully assembled system prompt string. */\n\tsystemPrompt: string;\n\t/** Structured options used to build the system prompt. Extensions can inspect this to understand what Pi loaded without re-discovering resources. */\n\tsystemPromptOptions: BuildSystemPromptOptions;\n}\n\n/** Fired when an agent loop starts */\nexport interface AgentStartEvent {\n\ttype: \"agent_start\";\n}\n\n/** Fired when an agent loop ends */\nexport interface AgentEndEvent {\n\ttype: \"agent_end\";\n\tmessages: AgentMessage[];\n}\n\n/** Fired at the start of each turn */\nexport interface TurnStartEvent {\n\ttype: \"turn_start\";\n\tturnIndex: number;\n\ttimestamp: number;\n}\n\n/** Fired at the end of each turn */\nexport interface TurnEndEvent {\n\ttype: \"turn_end\";\n\tturnIndex: number;\n\tmessage: AgentMessage;\n\ttoolResults: ToolResultMessage[];\n}\n\n/** Fired when a message starts (user, assistant, or toolResult) */\nexport interface MessageStartEvent {\n\ttype: \"message_start\";\n\tmessage: AgentMessage;\n}\n\n/** Fired during assistant message streaming with token-by-token updates */\nexport interface MessageUpdateEvent {\n\ttype: \"message_update\";\n\tmessage: AgentMessage;\n\tassistantMessageEvent: AssistantMessageEvent;\n}\n\n/** Fired when a message ends */\nexport interface MessageEndEvent {\n\ttype: \"message_end\";\n\tmessage: AgentMessage;\n}\n\n/** Fired when a tool starts executing */\nexport interface ToolExecutionStartEvent {\n\ttype: \"tool_execution_start\";\n\ttoolCallId: string;\n\ttoolName: string;\n\targs: any;\n}\n\n/** Fired during tool execution with partial/streaming output */\nexport interface ToolExecutionUpdateEvent {\n\ttype: \"tool_execution_update\";\n\ttoolCallId: string;\n\ttoolName: string;\n\targs: any;\n\tpartialResult: any;\n}\n\n/** Fired when a tool finishes executing */\nexport interface ToolExecutionEndEvent {\n\ttype: \"tool_execution_end\";\n\ttoolCallId: string;\n\ttoolName: string;\n\tresult: any;\n\tisError: boolean;\n}\n\n// ============================================================================\n// Model Events\n// ============================================================================\n\ntype ModelSelectSource = \"set\" | \"cycle\" | \"restore\";\n\n/** Fired when a new model is selected */\ninterface ModelSelectEvent {\n\ttype: \"model_select\";\n\tmodel: Model<any>;\n\tpreviousModel: Model<any> | undefined;\n\tsource: ModelSelectSource;\n}\n\n/** Fired when a new thinking level is selected */\ninterface ThinkingLevelSelectEvent {\n\ttype: \"thinking_level_select\";\n\tlevel: ThinkingLevel;\n\tpreviousLevel: ThinkingLevel;\n}\n\n// ============================================================================\n// User Bash Events\n// ============================================================================\n\n/** Fired when user executes a bash command via ! or !! prefix */\nexport interface UserBashEvent {\n\ttype: \"user_bash\";\n\t/** The command to execute */\n\tcommand: string;\n\t/** True if !! prefix was used (excluded from LLM context) */\n\texcludeFromContext: boolean;\n\t/** Current working directory */\n\tcwd: string;\n}\n\n// ============================================================================\n// Input Events\n// ============================================================================\n\n/** Source of user input */\nexport type InputSource = \"interactive\" | \"rpc\" | \"extension\";\n\n/** Fired when user input is received, before agent processing */\nexport interface InputEvent {\n\ttype: \"input\";\n\t/** The input text */\n\ttext: string;\n\t/** Attached images, if any */\n\timages?: ImageContent[];\n\t/** Where the input came from */\n\tsource: InputSource;\n}\n\n/** Result from input event handler */\nexport type InputEventResult =\n\t| { action: \"continue\" }\n\t| { action: \"transform\"; text: string; images?: ImageContent[] }\n\t| { action: \"handled\" };\n\n// ============================================================================\n// Tool Events\n// ============================================================================\n\ninterface ToolCallEventBase {\n\ttype: \"tool_call\";\n\ttoolCallId: string;\n}\n\nexport interface BashToolCallEvent extends ToolCallEventBase {\n\ttoolName: \"bash\";\n\tinput: BashToolInput;\n}\n\nexport interface ReadToolCallEvent extends ToolCallEventBase {\n\ttoolName: \"read\";\n\tinput: ReadToolInput;\n}\n\nexport interface EditToolCallEvent extends ToolCallEventBase {\n\ttoolName: \"edit\";\n\tinput: EditToolInput;\n}\n\nexport interface WriteToolCallEvent extends ToolCallEventBase {\n\ttoolName: \"write\";\n\tinput: WriteToolInput;\n}\n\nexport interface GrepToolCallEvent extends ToolCallEventBase {\n\ttoolName: \"grep\";\n\tinput: GrepToolInput;\n}\n\nexport interface FindToolCallEvent extends ToolCallEventBase {\n\ttoolName: \"find\";\n\tinput: FindToolInput;\n}\n\nexport interface LsToolCallEvent extends ToolCallEventBase {\n\ttoolName: \"ls\";\n\tinput: LsToolInput;\n}\n\nexport interface CustomToolCallEvent extends ToolCallEventBase {\n\ttoolName: string;\n\tinput: Record<string, unknown>;\n}\n\n/**\n * Fired before a tool executes. Can block.\n *\n * `event.input` is mutable. Mutate it in place to patch tool arguments before execution.\n * Later `tool_call` handlers see earlier mutations. No re-validation is performed after mutation.\n */\nexport type ToolCallEvent =\n\t| BashToolCallEvent\n\t| ReadToolCallEvent\n\t| EditToolCallEvent\n\t| WriteToolCallEvent\n\t| GrepToolCallEvent\n\t| FindToolCallEvent\n\t| LsToolCallEvent\n\t| CustomToolCallEvent;\n\ninterface ToolResultEventBase {\n\ttype: \"tool_result\";\n\ttoolCallId: string;\n\tinput: Record<string, unknown>;\n\tcontent: (TextContent | ImageContent)[];\n\tisError: boolean;\n}\n\ninterface BashToolResultEvent extends ToolResultEventBase {\n\ttoolName: \"bash\";\n\tdetails: BashToolDetails | undefined;\n}\n\ninterface ReadToolResultEvent extends ToolResultEventBase {\n\ttoolName: \"read\";\n\tdetails: ReadToolDetails | undefined;\n}\n\ninterface EditToolResultEvent extends ToolResultEventBase {\n\ttoolName: \"edit\";\n\tdetails: EditToolDetails | undefined;\n}\n\ninterface WriteToolResultEvent extends ToolResultEventBase {\n\ttoolName: \"write\";\n\tdetails: undefined;\n}\n\ninterface GrepToolResultEvent extends ToolResultEventBase {\n\ttoolName: \"grep\";\n\tdetails: GrepToolDetails | undefined;\n}\n\ninterface FindToolResultEvent extends ToolResultEventBase {\n\ttoolName: \"find\";\n\tdetails: FindToolDetails | undefined;\n}\n\ninterface LsToolResultEvent extends ToolResultEventBase {\n\ttoolName: \"ls\";\n\tdetails: LsToolDetails | undefined;\n}\n\ninterface CustomToolResultEvent extends ToolResultEventBase {\n\ttoolName: string;\n\tdetails: unknown;\n}\n\n/** Fired after a tool executes. Can modify result. */\nexport type ToolResultEvent =\n\t| BashToolResultEvent\n\t| ReadToolResultEvent\n\t| EditToolResultEvent\n\t| WriteToolResultEvent\n\t| GrepToolResultEvent\n\t| FindToolResultEvent\n\t| LsToolResultEvent\n\t| CustomToolResultEvent;\n\n// Type guards for ToolResultEvent\nexport function isBashToolResult(e: ToolResultEvent): e is BashToolResultEvent {\n\treturn e.toolName === \"bash\";\n}\nexport function isReadToolResult(e: ToolResultEvent): e is ReadToolResultEvent {\n\treturn e.toolName === \"read\";\n}\nexport function isEditToolResult(e: ToolResultEvent): e is EditToolResultEvent {\n\treturn e.toolName === \"edit\";\n}\nexport function isWriteToolResult(e: ToolResultEvent): e is WriteToolResultEvent {\n\treturn e.toolName === \"write\";\n}\nexport function isGrepToolResult(e: ToolResultEvent): e is GrepToolResultEvent {\n\treturn e.toolName === \"grep\";\n}\nexport function isFindToolResult(e: ToolResultEvent): e is FindToolResultEvent {\n\treturn e.toolName === \"find\";\n}\nexport function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent {\n\treturn e.toolName === \"ls\";\n}\n\n/**\n * Type guard for narrowing ToolCallEvent by tool name.\n *\n * Built-in tools narrow automatically (no type params needed):\n * ```ts\n * if (isToolCallEventType(\"bash\", event)) {\n * event.input.command; // string\n * }\n * ```\n *\n * Custom tools require explicit type parameters:\n * ```ts\n * if (isToolCallEventType<\"my_tool\", MyToolInput>(\"my_tool\", event)) {\n * event.input.action; // typed\n * }\n * ```\n *\n * Note: Direct narrowing via `event.toolName === \"bash\"` doesn't work because\n * CustomToolCallEvent.toolName is `string` which overlaps with all literals.\n */\nexport function isToolCallEventType(toolName: \"bash\", event: ToolCallEvent): event is BashToolCallEvent;\nexport function isToolCallEventType(toolName: \"read\", event: ToolCallEvent): event is ReadToolCallEvent;\nexport function isToolCallEventType(toolName: \"edit\", event: ToolCallEvent): event is EditToolCallEvent;\nexport function isToolCallEventType(toolName: \"write\", event: ToolCallEvent): event is WriteToolCallEvent;\nexport function isToolCallEventType(toolName: \"grep\", event: ToolCallEvent): event is GrepToolCallEvent;\nexport function isToolCallEventType(toolName: \"find\", event: ToolCallEvent): event is FindToolCallEvent;\nexport function isToolCallEventType(toolName: \"ls\", event: ToolCallEvent): event is LsToolCallEvent;\nexport function isToolCallEventType<TName extends string, TInput extends Record<string, unknown>>(\n\ttoolName: TName,\n\tevent: ToolCallEvent,\n): event is ToolCallEvent & { toolName: TName; input: TInput };\nexport function isToolCallEventType(toolName: string, event: ToolCallEvent): boolean {\n\treturn event.toolName === toolName;\n}\n\n/** Union of all event types */\nexport type ExtensionEvent =\n\t| ResourcesDiscoverEvent\n\t| SessionEvent\n\t| ContextEvent\n\t| BeforeProviderRequestEvent\n\t| AfterProviderResponseEvent\n\t| BeforeAgentStartEvent\n\t| AgentStartEvent\n\t| AgentEndEvent\n\t| TurnStartEvent\n\t| TurnEndEvent\n\t| MessageStartEvent\n\t| MessageUpdateEvent\n\t| MessageEndEvent\n\t| ToolExecutionStartEvent\n\t| ToolExecutionUpdateEvent\n\t| ToolExecutionEndEvent\n\t| ModelSelectEvent\n\t| ThinkingLevelSelectEvent\n\t| UserBashEvent\n\t| InputEvent\n\t| ToolCallEvent\n\t| ToolResultEvent;\n\n// ============================================================================\n// Event Results\n// ============================================================================\n\nexport interface ContextEventResult {\n\tmessages?: AgentMessage[];\n}\n\nexport type BeforeProviderRequestEventResult = unknown;\n\nexport interface ToolCallEventResult {\n\t/** Block tool execution. To modify arguments, mutate `event.input` in place instead. */\n\tblock?: boolean;\n\treason?: string;\n}\n\n/** Result from user_bash event handler */\nexport interface UserBashEventResult {\n\t/** Custom operations to use for execution */\n\toperations?: BashOperations;\n\t/** Full replacement: extension handled execution, use this result */\n\tresult?: BashResult;\n}\n\nexport interface ToolResultEventResult {\n\tcontent?: (TextContent | ImageContent)[];\n\tdetails?: unknown;\n\tisError?: boolean;\n}\n\nexport interface MessageEndEventResult {\n\t/** Replace the finalized message. The replacement must keep the original message role. */\n\tmessage?: AgentMessage;\n}\n\nexport interface BeforeAgentStartEventResult {\n\tmessage?: Pick<CustomMessage, \"customType\" | \"content\" | \"display\" | \"details\">;\n\t/** Replace the system prompt for this turn. If multiple extensions return this, they are chained. */\n\tsystemPrompt?: string;\n}\n\nexport interface SessionBeforeSwitchResult {\n\tcancel?: boolean;\n}\n\nexport interface SessionBeforeForkResult {\n\tcancel?: boolean;\n\tskipConversationRestore?: boolean;\n}\n\nexport interface SessionBeforeCompactResult {\n\tcancel?: boolean;\n\tcompaction?: CompactionResult;\n}\n\nexport interface SessionBeforeTreeResult {\n\tcancel?: boolean;\n\tsummary?: {\n\t\tsummary: string;\n\t\tdetails?: unknown;\n\t};\n\t/** Override custom instructions for summarization */\n\tcustomInstructions?: string;\n\t/** Override whether customInstructions replaces the default prompt */\n\treplaceInstructions?: boolean;\n\t/** Override label to attach to the branch summary entry */\n\tlabel?: string;\n}\n\n// ============================================================================\n// Message Rendering\n// ============================================================================\n\nexport interface MessageRenderOptions {\n\texpanded: boolean;\n}\n\nexport type MessageRenderer<T = unknown> = (\n\tmessage: CustomMessage<T>,\n\toptions: MessageRenderOptions,\n\ttheme: Theme,\n) => Component | undefined;\n\n// ============================================================================\n// Command Registration\n// ============================================================================\n\nexport interface RegisteredCommand {\n\tname: string;\n\tsourceInfo: SourceInfo;\n\tdescription?: string;\n\tgetArgumentCompletions?: (argumentPrefix: string) => AutocompleteItem[] | null | Promise<AutocompleteItem[] | null>;\n\thandler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;\n}\n\nexport interface ResolvedCommand extends RegisteredCommand {\n\tinvocationName: string;\n}\n\n// ============================================================================\n// Extension API\n// ============================================================================\n\n/** Handler function type for events */\n// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements\nexport type ExtensionHandler<E, R = undefined> = (event: E, ctx: ExtensionContext) => Promise<R | void> | R | void;\n\n/**\n * ExtensionAPI passed to extension factory functions.\n */\nexport interface ExtensionAPI {\n\t// =========================================================================\n\t// Event Subscription\n\t// =========================================================================\n\n\ton(event: \"resources_discover\", handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>): void;\n\ton(event: \"session_start\", handler: ExtensionHandler<SessionStartEvent>): void;\n\ton(\n\t\tevent: \"session_before_switch\",\n\t\thandler: ExtensionHandler<SessionBeforeSwitchEvent, SessionBeforeSwitchResult>,\n\t): void;\n\ton(event: \"session_before_fork\", handler: ExtensionHandler<SessionBeforeForkEvent, SessionBeforeForkResult>): void;\n\ton(\n\t\tevent: \"session_before_compact\",\n\t\thandler: ExtensionHandler<SessionBeforeCompactEvent, SessionBeforeCompactResult>,\n\t): void;\n\ton(event: \"session_compact\", handler: ExtensionHandler<SessionCompactEvent>): void;\n\ton(event: \"session_shutdown\", handler: ExtensionHandler<SessionShutdownEvent>): void;\n\ton(event: \"session_before_tree\", handler: ExtensionHandler<SessionBeforeTreeEvent, SessionBeforeTreeResult>): void;\n\ton(event: \"session_tree\", handler: ExtensionHandler<SessionTreeEvent>): void;\n\ton(event: \"context\", handler: ExtensionHandler<ContextEvent, ContextEventResult>): void;\n\ton(\n\t\tevent: \"before_provider_request\",\n\t\thandler: ExtensionHandler<BeforeProviderRequestEvent, BeforeProviderRequestEventResult>,\n\t): void;\n\ton(event: \"after_provider_response\", handler: ExtensionHandler<AfterProviderResponseEvent>): void;\n\ton(event: \"before_agent_start\", handler: ExtensionHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult>): void;\n\ton(event: \"agent_start\", handler: ExtensionHandler<AgentStartEvent>): void;\n\ton(event: \"agent_end\", handler: ExtensionHandler<AgentEndEvent>): void;\n\ton(event: \"turn_start\", handler: ExtensionHandler<TurnStartEvent>): void;\n\ton(event: \"turn_end\", handler: ExtensionHandler<TurnEndEvent>): void;\n\ton(event: \"message_start\", handler: ExtensionHandler<MessageStartEvent>): void;\n\ton(event: \"message_update\", handler: ExtensionHandler<MessageUpdateEvent>): void;\n\ton(event: \"message_end\", handler: ExtensionHandler<MessageEndEvent, MessageEndEventResult>): void;\n\ton(event: \"tool_execution_start\", handler: ExtensionHandler<ToolExecutionStartEvent>): void;\n\ton(event: \"tool_execution_update\", handler: ExtensionHandler<ToolExecutionUpdateEvent>): void;\n\ton(event: \"tool_execution_end\", handler: ExtensionHandler<ToolExecutionEndEvent>): void;\n\ton(event: \"model_select\", handler: ExtensionHandler<ModelSelectEvent>): void;\n\ton(event: \"thinking_level_select\", handler: ExtensionHandler<ThinkingLevelSelectEvent>): void;\n\ton(event: \"tool_call\", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;\n\ton(event: \"tool_result\", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;\n\ton(event: \"user_bash\", handler: ExtensionHandler<UserBashEvent, UserBashEventResult>): void;\n\ton(event: \"input\", handler: ExtensionHandler<InputEvent, InputEventResult>): void;\n\n\t// =========================================================================\n\t// Tool Registration\n\t// =========================================================================\n\n\t/** Register a tool that the LLM can call. */\n\tregisterTool<TParams extends TSchema = TSchema, TDetails = unknown, TState = any>(\n\t\ttool: ToolDefinition<TParams, TDetails, TState>,\n\t): void;\n\n\t// =========================================================================\n\t// Command, Shortcut, Flag Registration\n\t// =========================================================================\n\n\t/** Register a custom command. */\n\tregisterCommand(name: string, options: Omit<RegisteredCommand, \"name\" | \"sourceInfo\">): void;\n\n\t/** Register a keyboard shortcut. */\n\tregisterShortcut(\n\t\tshortcut: KeyId,\n\t\toptions: {\n\t\t\tdescription?: string;\n\t\t\thandler: (ctx: ExtensionContext) => Promise<void> | void;\n\t\t},\n\t): void;\n\n\t/** Register a CLI flag. */\n\tregisterFlag(\n\t\tname: string,\n\t\toptions: {\n\t\t\tdescription?: string;\n\t\t\ttype: \"boolean\" | \"string\";\n\t\t\tdefault?: boolean | string;\n\t\t},\n\t): void;\n\n\t/** Get the value of a registered CLI flag. */\n\tgetFlag(name: string): boolean | string | undefined;\n\n\t/**\n\t * Add a directory to the mode-file search path.\n\t *\n\t * Hoo-core looks up `{dir}/{modeName}/system.md` when assembling the mode\n\t * system prompt, after the project (`./.hoocode/modes/`) and user\n\t * (`~/.hoocode/modes/`) directories. Multiple calls accumulate in\n\t * registration order; CLI flags and config-declared `mode_paths` flow\n\t * through the same list.\n\t */\n\taddModeSearchPath(dirPath: string): void;\n\n\t/** Get all registered mode search paths in declared order. */\n\tgetModeSearchPaths(): string[];\n\n\t// =========================================================================\n\t// Message Rendering\n\t// =========================================================================\n\n\t/** Register a custom renderer for CustomMessageEntry. */\n\tregisterMessageRenderer<T = unknown>(customType: string, renderer: MessageRenderer<T>): void;\n\n\t// =========================================================================\n\t// Actions\n\t// =========================================================================\n\n\t/** Send a custom message to the session. */\n\tsendMessage<T = unknown>(\n\t\tmessage: Pick<CustomMessage<T>, \"customType\" | \"content\" | \"display\" | \"details\">,\n\t\toptions?: { triggerTurn?: boolean; deliverAs?: \"steer\" | \"followUp\" | \"nextTurn\" },\n\t): void;\n\n\t/**\n\t * Send a user message to the agent. Always triggers a turn.\n\t * When the agent is streaming, use deliverAs to specify how to queue the message.\n\t */\n\tsendUserMessage(\n\t\tcontent: string | (TextContent | ImageContent)[],\n\t\toptions?: { deliverAs?: \"steer\" | \"followUp\" },\n\t): void;\n\n\t/** Append a custom entry to the session for state persistence (not sent to LLM). */\n\tappendEntry<T = unknown>(customType: string, data?: T): void;\n\n\t// =========================================================================\n\t// Session Metadata\n\t// =========================================================================\n\n\t/** Set the session display name (shown in session selector). */\n\tsetSessionName(name: string): void;\n\n\t/** Get the current session name, if set. */\n\tgetSessionName(): string | undefined;\n\n\t/** Set or clear a label on an entry. Labels are user-defined markers for bookmarking/navigation. */\n\tsetLabel(entryId: string, label: string | undefined): void;\n\n\t/** Execute a shell command. */\n\texec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;\n\n\t/** Get the list of currently active tool names. */\n\tgetActiveTools(): string[];\n\n\t/** Get all configured tools with parameter schema and source metadata. */\n\tgetAllTools(): ToolInfo[];\n\n\t/** Set the active tools by name. */\n\tsetActiveTools(toolNames: string[]): void;\n\n\t/** Get available slash commands in the current session. */\n\tgetCommands(): SlashCommandInfo[];\n\n\t// =========================================================================\n\t// Model and Thinking Level\n\t// =========================================================================\n\n\t/** Set the current model. Returns false if no API key available. */\n\tsetModel(model: Model<any>): Promise<boolean>;\n\n\t/** Get current thinking level. */\n\tgetThinkingLevel(): ThinkingLevel;\n\n\t/** Set thinking level (clamped to model capabilities). */\n\tsetThinkingLevel(level: ThinkingLevel): void;\n\n\t// =========================================================================\n\t// Provider Registration\n\t// =========================================================================\n\n\t/**\n\t * Register or override a model provider.\n\t *\n\t * If `models` is provided: replaces all existing models for this provider.\n\t * If only `baseUrl` is provided: overrides the URL for existing models.\n\t * If `oauth` is provided: registers OAuth provider for /login support.\n\t * If `streamSimple` is provided: registers a custom API stream handler.\n\t *\n\t * During initial extension load this call is queued and applied once the\n\t * runner has bound its context. After that it takes effect immediately, so\n\t * it is safe to call from command handlers or event callbacks without\n\t * requiring a `/reload`.\n\t *\n\t * @example\n\t * // Register a new provider with custom models\n\t * pi.registerProvider(\"my-proxy\", {\n\t * baseUrl: \"https://proxy.example.com\",\n\t * apiKey: \"PROXY_API_KEY\",\n\t * api: \"anthropic-messages\",\n\t * models: [\n\t * {\n\t * id: \"claude-sonnet-4-20250514\",\n\t * name: \"Claude 4 Sonnet (proxy)\",\n\t * reasoning: false,\n\t * input: [\"text\", \"image\"],\n\t * cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t * contextWindow: 200000,\n\t * maxTokens: 16384\n\t * }\n\t * ]\n\t * });\n\t *\n\t * @example\n\t * // Override baseUrl for an existing provider\n\t * pi.registerProvider(\"anthropic\", {\n\t * baseUrl: \"https://proxy.example.com\"\n\t * });\n\t *\n\t * @example\n\t * // Register provider with OAuth support\n\t * pi.registerProvider(\"corporate-ai\", {\n\t * baseUrl: \"https://ai.corp.com\",\n\t * api: \"openai-responses\",\n\t * models: [...],\n\t * oauth: {\n\t * name: \"Corporate AI (SSO)\",\n\t * async login(callbacks) { ... },\n\t * async refreshToken(credentials) { ... },\n\t * getApiKey(credentials) { return credentials.access; }\n\t * }\n\t * });\n\t */\n\tregisterProvider(name: string, config: ProviderConfig): void;\n\n\t/**\n\t * Unregister a previously registered provider.\n\t *\n\t * Removes all models belonging to the named provider and restores any\n\t * built-in models that were overridden by it. Has no effect if the provider\n\t * is not currently registered.\n\t *\n\t * Like `registerProvider`, this takes effect immediately when called after\n\t * the initial load phase.\n\t *\n\t * @example\n\t * pi.unregisterProvider(\"my-proxy\");\n\t */\n\tunregisterProvider(name: string): void;\n\n\t/** Shared event bus for extension communication. */\n\tevents: EventBus;\n}\n\n// ============================================================================\n// Provider Registration Types\n// ============================================================================\n\n/** Configuration for registering a provider via pi.registerProvider(). */\nexport interface ProviderConfig {\n\t/** Display name for the provider in UI. */\n\tname?: string;\n\t/** Base URL for the API endpoint. Required when defining models. */\n\tbaseUrl?: string;\n\t/** API key or environment variable name. Required when defining models (unless oauth provided). */\n\tapiKey?: string;\n\t/** API type. Required at provider or model level when defining models. */\n\tapi?: Api;\n\t/** Optional streamSimple handler for custom APIs. */\n\tstreamSimple?: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;\n\t/** Custom headers to include in requests. */\n\theaders?: Record<string, string>;\n\t/** If true, adds Authorization: Bearer header with the resolved API key. */\n\tauthHeader?: boolean;\n\t/** Models to register. If provided, replaces all existing models for this provider. */\n\tmodels?: ProviderModelConfig[];\n\t/** OAuth provider for /login support. The `id` is set automatically from the provider name. */\n\toauth?: {\n\t\t/** Display name for the provider in login UI. */\n\t\tname: string;\n\t\t/** Run the login flow, return credentials to persist. */\n\t\tlogin(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;\n\t\t/** Refresh expired credentials, return updated credentials to persist. */\n\t\trefreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>;\n\t\t/** Convert credentials to API key string for the provider. */\n\t\tgetApiKey(credentials: OAuthCredentials): string;\n\t\t/** Optional: modify models for this provider (e.g., update baseUrl based on credentials). */\n\t\tmodifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];\n\t};\n}\n\n/** Configuration for a model within a provider. */\nexport interface ProviderModelConfig {\n\t/** Model ID (e.g., \"claude-sonnet-4-20250514\"). */\n\tid: string;\n\t/** Display name (e.g., \"Claude 4 Sonnet\"). */\n\tname: string;\n\t/** API type override for this model. */\n\tapi?: Api;\n\t/** API endpoint URL override for this model. */\n\tbaseUrl?: string;\n\t/** Whether the model supports extended thinking. */\n\treasoning: boolean;\n\t/** Maps pi thinking levels to provider/model-specific values; null marks a level unsupported. */\n\tthinkingLevelMap?: Model<Api>[\"thinkingLevelMap\"];\n\t/** Supported input types. */\n\tinput: (\"text\" | \"image\")[];\n\t/** Cost per token (for tracking, can be 0). */\n\tcost: { input: number; output: number; cacheRead: number; cacheWrite: number };\n\t/** Maximum context window size in tokens. */\n\tcontextWindow: number;\n\t/** Maximum output tokens. */\n\tmaxTokens: number;\n\t/** Custom headers for this model. */\n\theaders?: Record<string, string>;\n\t/** OpenAI compatibility settings. */\n\tcompat?: Model<Api>[\"compat\"];\n}\n\n/** Extension factory function type. Supports both sync and async initialization. */\nexport type ExtensionFactory = ((pi: ExtensionAPI) => void | Promise<void>) & {\n\tdisplayName?: string;\n\t/** Internal plumbing factory; excluded from the loaded resources listing. */\n\tinternal?: boolean;\n};\n\n// ============================================================================\n// Loaded Extension Types\n// ============================================================================\n\nexport interface RegisteredTool {\n\tdefinition: ToolDefinition;\n\tsourceInfo: SourceInfo;\n}\n\nexport interface ExtensionFlag {\n\tname: string;\n\tdescription?: string;\n\ttype: \"boolean\" | \"string\";\n\tdefault?: boolean | string;\n\textensionPath: string;\n}\n\nexport interface ExtensionShortcut {\n\tshortcut: KeyId;\n\tdescription?: string;\n\thandler: (ctx: ExtensionContext) => Promise<void> | void;\n\textensionPath: string;\n}\n\ntype HandlerFn = (...args: unknown[]) => Promise<unknown>;\n\ntype SendMessageHandler = <T = unknown>(\n\tmessage: Pick<CustomMessage<T>, \"customType\" | \"content\" | \"display\" | \"details\">,\n\toptions?: { triggerTurn?: boolean; deliverAs?: \"steer\" | \"followUp\" | \"nextTurn\" },\n) => void;\n\ntype SendUserMessageHandler = (\n\tcontent: string | (TextContent | ImageContent)[],\n\toptions?: { deliverAs?: \"steer\" | \"followUp\" },\n) => void;\n\ntype AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;\n\ntype SetSessionNameHandler = (name: string) => void;\n\ntype GetSessionNameHandler = () => string | undefined;\n\ntype GetActiveToolsHandler = () => string[];\n\n/** Tool info with name, description, parameter schema, and source metadata */\nexport type ToolInfo = Pick<ToolDefinition, \"name\" | \"description\" | \"parameters\"> & {\n\tsourceInfo: SourceInfo;\n};\n\ntype GetAllToolsHandler = () => ToolInfo[];\n\ntype GetCommandsHandler = () => SlashCommandInfo[];\n\ntype SetActiveToolsHandler = (toolNames: string[]) => void;\n\ntype RefreshToolsHandler = () => void;\n\ntype SetModelHandler = (model: Model<any>) => Promise<boolean>;\n\ntype GetThinkingLevelHandler = () => ThinkingLevel;\n\ntype SetThinkingLevelHandler = (level: ThinkingLevel) => void;\n\ntype SetLabelHandler = (entryId: string, label: string | undefined) => void;\n\n/**\n * Shared state created by loader, used during registration and runtime.\n * Contains flag values (defaults set during registration, CLI values set after).\n */\ninterface ExtensionRuntimeState {\n\tflagValues: Map<string, boolean | string>;\n\t/** Provider registrations queued during extension loading, processed when runner binds */\n\tpendingProviderRegistrations: Array<{ name: string; config: ProviderConfig; extensionPath: string }>;\n\t/** Mode search dirs registered via pi.addModeSearchPath, in declared order. */\n\tmodeSearchPaths: string[];\n\t/** Throws when this extension instance is stale after runtime replacement. */\n\tassertActive: () => void;\n\t/** Marks this extension instance as stale after runtime replacement or reload. */\n\tinvalidate: (message?: string) => void;\n\t/**\n\t * Register or unregister a provider.\n\t *\n\t * Before bindCore(): queues registrations / removes from queue.\n\t * After bindCore(): calls ModelRegistry directly for immediate effect.\n\t */\n\tregisterProvider: (name: string, config: ProviderConfig, extensionPath?: string) => void;\n\tunregisterProvider: (name: string, extensionPath?: string) => void;\n}\n\n/**\n * Action implementations for pi.* API methods.\n * Provided to runner.initialize(), copied into the shared runtime.\n */\nexport interface ExtensionActions {\n\tsendMessage: SendMessageHandler;\n\tsendUserMessage: SendUserMessageHandler;\n\tappendEntry: AppendEntryHandler;\n\tsetSessionName: SetSessionNameHandler;\n\tgetSessionName: GetSessionNameHandler;\n\tsetLabel: SetLabelHandler;\n\tgetActiveTools: GetActiveToolsHandler;\n\tgetAllTools: GetAllToolsHandler;\n\tsetActiveTools: SetActiveToolsHandler;\n\trefreshTools: RefreshToolsHandler;\n\tgetCommands: GetCommandsHandler;\n\tsetModel: SetModelHandler;\n\tgetThinkingLevel: GetThinkingLevelHandler;\n\tsetThinkingLevel: SetThinkingLevelHandler;\n}\n\n/**\n * Actions for ExtensionContext (ctx.* in event handlers).\n * Required by all modes.\n */\nexport interface ExtensionContextActions {\n\tgetModel: () => Model<any> | undefined;\n\tisIdle: () => boolean;\n\tgetSignal: () => AbortSignal | undefined;\n\tabort: () => void;\n\thasPendingMessages: () => boolean;\n\tshutdown: () => void;\n\tgetContextUsage: () => ContextUsage | undefined;\n\tcompact: (options?: CompactOptions) => void;\n\tgetSystemPrompt: () => string;\n\tactivatePlugin: (pluginDir: string) => PluginActivationResult;\n\trequestReloadWhenIdle: () => void;\n}\n\n/**\n * Actions for ExtensionCommandContext (ctx.* in command handlers).\n * Only needed for interactive mode where extension commands are invokable.\n */\nexport interface ExtensionCommandContextActions {\n\twaitForIdle: () => Promise<void>;\n\tnewSession: (options?: {\n\t\tparentSession?: string;\n\t\tsetup?: (sessionManager: SessionManager) => Promise<void>;\n\t\twithSession?: (ctx: ReplacedSessionContext) => Promise<void>;\n\t}) => Promise<{ cancelled: boolean }>;\n\tfork: (\n\t\tentryId: string,\n\t\toptions?: { position?: \"before\" | \"at\"; withSession?: (ctx: ReplacedSessionContext) => Promise<void> },\n\t) => Promise<{ cancelled: boolean }>;\n\tnavigateTree: (\n\t\ttargetId: string,\n\t\toptions?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string },\n\t) => Promise<{ cancelled: boolean }>;\n\tswitchSession: (\n\t\tsessionPath: string,\n\t\toptions?: { withSession?: (ctx: ReplacedSessionContext) => Promise<void> },\n\t) => Promise<{ cancelled: boolean }>;\n\treload: () => Promise<void>;\n}\n\n/**\n * Full runtime = state + actions.\n * Created by loader with throwing action stubs, completed by runner.initialize().\n */\nexport interface ExtensionRuntime extends ExtensionRuntimeState, ExtensionActions {}\n\n/** Loaded extension with all registered items. */\nexport interface Extension {\n\tpath: string;\n\tresolvedPath: string;\n\tdisplayName?: string;\n\t/** Internal plumbing extension; excluded from the loaded resources listing. */\n\tinternal?: boolean;\n\tsourceInfo: SourceInfo;\n\thandlers: Map<string, HandlerFn[]>;\n\ttools: Map<string, RegisteredTool>;\n\tmessageRenderers: Map<string, MessageRenderer>;\n\tcommands: Map<string, RegisteredCommand>;\n\tflags: Map<string, ExtensionFlag>;\n\tshortcuts: Map<KeyId, ExtensionShortcut>;\n}\n\n/**\n * One issue raised while loading an extension or plugin.\n *\n * `severity` separates \"this did not load\" from \"this loaded, with something\n * held back\". Startup aborts on the first kind and continues past the second —\n * a distinction the workspace-trust notice depends on, since the way to resolve\n * it (`/plugin trust`) only exists inside a session that started.\n */\nexport interface ExtensionLoadIssue {\n\tpath: string;\n\terror: string;\n\t/** Defaults to \"error\" when absent. */\n\tseverity?: \"error\" | \"warning\";\n}\n\n/** Result of loading extensions. */\nexport interface LoadExtensionsResult {\n\textensions: Extension[];\n\terrors: ExtensionLoadIssue[];\n\t/** Shared runtime - actions are throwing stubs until runner.initialize() */\n\truntime: ExtensionRuntime;\n}\n\n// ============================================================================\n// Extension Error\n// ============================================================================\n\nexport interface ExtensionError {\n\textensionPath: string;\n\tevent: string;\n\terror: string;\n\tstack?: string;\n}\n"]}
|
|
@@ -84,9 +84,35 @@ export interface WorkflowCandidate extends Proposable {
|
|
|
84
84
|
count: number;
|
|
85
85
|
sessions: number;
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Why a session file on disk did not make it into the digest.
|
|
89
|
+
*
|
|
90
|
+
* "No recent sessions" is the one outcome a user cannot act on without this:
|
|
91
|
+
* an empty session directory, a directory full of month-old sessions, and a
|
|
92
|
+
* directory full of sessions belonging to another checkout all produce the same
|
|
93
|
+
* sentence, and the fix differs in each case.
|
|
94
|
+
*/
|
|
95
|
+
export interface SessionScanReport {
|
|
96
|
+
/** Directories actually searched, in order. */
|
|
97
|
+
dirs: string[];
|
|
98
|
+
/** Directories that do not exist on disk. */
|
|
99
|
+
missingDirs: string[];
|
|
100
|
+
/** `.jsonl` files found across all searched directories. */
|
|
101
|
+
files: number;
|
|
102
|
+
/** Skipped for being older than the age window. */
|
|
103
|
+
tooOld: number;
|
|
104
|
+
/** Skipped because the session header records a different working directory. */
|
|
105
|
+
otherCwd: number;
|
|
106
|
+
/** Skipped for being beyond `maxSessions`. */
|
|
107
|
+
overLimit: number;
|
|
108
|
+
/** Skipped for being unreadable, unparseable, or empty. */
|
|
109
|
+
unreadable: number;
|
|
110
|
+
}
|
|
87
111
|
export interface LearnDigest {
|
|
88
112
|
scannedSessions: number;
|
|
89
113
|
skippedSessions: number;
|
|
114
|
+
/** Where the sessions came from, and what was passed over. */
|
|
115
|
+
scan: SessionScanReport;
|
|
90
116
|
oldestSession?: string;
|
|
91
117
|
newestSession?: string;
|
|
92
118
|
agentsFilePath?: string;
|
|
@@ -106,7 +132,11 @@ export interface LearnDigest {
|
|
|
106
132
|
export interface ExtractOptions {
|
|
107
133
|
cwd: string;
|
|
108
134
|
agentDir: string;
|
|
109
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* An extra directory to scan, normally the live session manager's. The
|
|
137
|
+
* per-cwd default directory is always scanned as well, so a session manager
|
|
138
|
+
* pointing somewhere unusual cannot hide this directory's history.
|
|
139
|
+
*/
|
|
110
140
|
sessionDir?: string;
|
|
111
141
|
maxSessions?: number;
|
|
112
142
|
maxAgeDays?: number;
|
|
@@ -134,6 +164,23 @@ export interface ExtractOptions {
|
|
|
134
164
|
/** Injectable clock, for tests. */
|
|
135
165
|
now?: Date;
|
|
136
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Every directory this cwd's sessions could be sitting in.
|
|
169
|
+
*
|
|
170
|
+
* The caller passes the live session manager's directory, which is the right
|
|
171
|
+
* answer almost always — but not quite always, and each exception silently
|
|
172
|
+
* emptied the digest. An in-memory session (`--no-session`) reports `""`; an
|
|
173
|
+
* explicit `--session <path>` reports wherever that file lives; a custom
|
|
174
|
+
* `sessionDir` setting points at one shared directory. In every one of those
|
|
175
|
+
* cases the per-cwd default directory still holds the history worth mining, so
|
|
176
|
+
* search both and let the header check sort out what belongs to this cwd.
|
|
177
|
+
*/
|
|
178
|
+
export declare function candidateSessionDirs(options: Pick<ExtractOptions, "cwd" | "agentDir" | "sessionDir">): string[];
|
|
179
|
+
/**
|
|
180
|
+
* Where this cwd's sessions were found and what was passed over, without
|
|
181
|
+
* ranking anything. `/learn stats` reports on the window without re-mining it.
|
|
182
|
+
*/
|
|
183
|
+
export declare function scanSessions(options: ExtractOptions): SessionScanReport;
|
|
137
184
|
/**
|
|
138
185
|
* Everything a proposal could already have been written into.
|
|
139
186
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"extract.d.ts","sourceRoot":"","sources":["../../../src/core/learn/extract.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAqBH,OAAO,EAAS,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAEpD;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,mBAAmB,CAAC;AAoCpD;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,eAAe,GAAG,KAAK,GAAG,UAAU,GAAG,WAAW,CAAC;AAE/D,oFAAoF;AACpF,UAAU,UAAU;IACnB,qEAAmE;IACnE,GAAG,EAAE,MAAM,CAAC;IACZ,4CAA4C;IAC5C,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,gBAAiB,SAAQ,UAAU;IACnD,gEAAgE;IAChE,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,wBAAwB;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,2EAAyE;IACzE,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,eAAe,CAAC;IACxB,iEAAiE;IACjE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,sEAAsE;IACtE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,kBAAkB,EAAE,OAAO,CAAC;CAC5B;AAED,MAAM,WAAW,YAAa,SAAQ,UAAU;IAC/C,kCAAkC;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,YAAY,EAAE,MAAM,CAAC;IACrB,qDAAqD;IACrD,mBAAmB,EAAE,MAAM,EAAE,CAAC;IAC9B,qDAAqD;IACrD,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,sEAAsE;IACtE,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAkB,SAAQ,UAAU;IACpD,qCAAqC;IACrC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,eAAe,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,EAAE,gBAAgB,EAAE,CAAC;IAC/B,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,SAAS,EAAE,iBAAiB,EAAE,CAAC;IAC/B,mFAAmF;IACnF,UAAU,EAAE,MAAM,CAAC;IACnB,oEAAoE;IACpE,QAAQ,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACrE;AAED,MAAM,WAAW,cAAc;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,2EAA2E;IAC3E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sFAAsF;IACtF,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,sCAAsC;IACtC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,kFAAkF;IAClF,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;OAGG;IACH,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtD,mCAAmC;IACnC,GAAG,CAAC,EAAE,IAAI,CAAC;CACX;AA2jBD;;;;;;;GAOG;AACH,MAAM,WAAW,aAAa;IAC7B,4EAA4E;IAC5E,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACrD;AAED,MAAM,WAAW,aAAa;IAC7B,sDAAsD;IACtD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yEAAyE;IACzE,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,GAAG,aAAa,CA2B/E;AAED,mDAAmD;AACnD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACtD,GAAG,aAAa,CAShB;AA4CD,0EAA0E;AAC1E,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,cAAc,GAAG,WAAW,CA+DvE","sourcesContent":["/**\n * Session mining for `/learn`.\n *\n * Reads session `.jsonl` files straight off disk rather than the live context.\n * That is the whole point: the on-disk transcript is complete even when the\n * in-context one has been compacted away, and it spans every past session\n * instead of only this one. Cross-session repetition is the signal that decides\n * whether something is a durable rule or a one-off, and it is the one thing a\n * prompt reading its own context cannot see.\n *\n * The split of labour is deliberate. This module is entirely deterministic: it\n * parses, filters, normalizes, counts and ranks. Judgement — is this a rule, how\n * should it be phrased, which scope owns it — belongs to the model reading the\n * digest, which is why the output carries evidence (counts, sessions, dates)\n * rather than conclusions.\n */\n\nimport { existsSync, readdirSync, readFileSync, statSync } from \"node:fs\";\nimport { dirname, join, resolve } from \"node:path\";\nimport type { AgentMessage } from \"@kolisachint/hoocode-agent-core\";\nimport type { TextContent, ToolCall } from \"@kolisachint/hoocode-ai\";\nimport { getUserAgentsDir } from \"../../config.js\";\nimport { getDefaultSessionDir } from \"../session-manager.js\";\nimport { loadSkills } from \"../skills.js\";\nimport {\n\tcommandHead,\n\tcontentWords,\n\textractErrorRegion,\n\tisBenignFailure,\n\tisRuleShapedDirective,\n\tisUninformativeFailure,\n\tnormalizeCommand,\n\tnormalizeDirective,\n\tnormalizeErrorSignature,\n\twordOverlap,\n} from \"./normalize.js\";\nimport { judge, type LearnState } from \"./state.js\";\n\n/**\n * Prefix on the message `/learn` injects. The digest is persisted like any user\n * turn, so without this marker the next `/learn` would mine its own output and\n * every proposal would compound its own count.\n */\nexport const LEARN_DIGEST_MARKER = \"[learn-digest]\";\n\n/** Sessions considered, newest first. */\nconst DEFAULT_MAX_SESSIONS = 20;\n/** Sessions older than this are ignored — a pattern that stopped is not a rule. */\nconst DEFAULT_MAX_AGE_DAYS = 30;\n/** Entries parsed per session file, as a guard against pathological transcripts. */\nconst MAX_ENTRIES_PER_SESSION = 8000;\n/** Tool calls per session fed to the workflow detector. */\nconst MAX_TOOL_CALLS_PER_SESSION = 400;\n/** How far forward the fix extractor looks for the same command succeeding. */\nconst FIX_LOOKAHEAD = 40;\n/** Word overlap against an existing rule above which a directive counts as covered. */\nconst COVERED_OVERLAP = 0.6;\n/**\n * The same bar for skills, set higher on purpose.\n *\n * A rule is one line, so overlap against it is a sharp signal. A skill is a name\n * plus a description written to attract matches, which is a far larger haystack\n * — a short directive's words turn up in it by chance much more readily. The\n * higher bar and the truncation below keep \"you already have a skill for this\"\n * from being said on a coincidence.\n */\nconst SKILL_COVERED_OVERLAP = 0.75;\n/** Description characters considered. The opening says what a skill does; the rest is trigger bait. */\nconst SKILL_DESCRIPTION_CHARS = 300;\n/** Directives must reach this many occurrences to be reported at all. */\nconst DEFAULT_MIN_DIRECTIVE_COUNT = 2;\n/** Tool sequence lengths considered as workflow candidates. */\nconst WORKFLOW_MIN_LEN = 3;\nconst WORKFLOW_MAX_LEN = 5;\n/** Repeats before a tool sequence is worth proposing as a skill. */\nconst DEFAULT_MIN_WORKFLOW_COUNT = 3;\n/** Cap on each list in the digest, so the model's budget goes to the top signals. */\nconst DEFAULT_MAX_PER_CATEGORY = 8;\n\n/**\n * Where a repeated directive already lives, if anywhere.\n *\n * A directive covered by a rule and said only once is simply dropped — the rule\n * exists and is working. What survives is one of three cases, and they want\n * different responses:\n *\n * - `new` — not written down anywhere. Propose it.\n * - `restated` — a context-file rule covers it and you said it anyway, so the\n * rule is not working. Rewrite it; do not add a second one.\n * - `has-skill` — a *skill* covers it and you asked by hand anyway, which\n * usually means the skill's `description` is not triggering. Sharpen the\n * description rather than writing a rule that duplicates the skill.\n */\nexport type DirectiveStatus = \"new\" | \"restated\" | \"has-skill\";\n\n/** Fields every proposable item shares, so suppression can be applied uniformly. */\ninterface Proposable {\n\t/** Stable identity across runs — what the state file remembers. */\n\tkey: string;\n\t/** Newest occurrence in the window, ISO. */\n\tlastSeen: string;\n}\n\nexport interface DirectiveCluster extends Proposable {\n\t/** Representative raw text, the longest seen in the cluster. */\n\ttext: string;\n\tnormalized: string;\n\t/** Total times said. */\n\tcount: number;\n\t/** Distinct sessions it was said in — the stronger of the two counts. */\n\tsessions: number;\n\tstatus: DirectiveStatus;\n\t/** The existing rule line matched, when status is `restated`. */\n\texistingRule?: string;\n\t/** The skill that already covers this, when status is `has-skill`. */\n\texistingSkill?: string;\n\t/**\n\t * Shown before and still not written down anywhere — neither as a rule nor as\n\t * a skill — so you saw this proposal and passed on it. Only meaningful for\n\t * directives, which are the only items with a real coverage signal.\n\t */\n\tpreviouslyDeclined: boolean;\n}\n\nexport interface FixCandidate extends Proposable {\n\t/** Normalized failing command. */\n\tcommand: string;\n\t/** Normalized error signature, the dedupe key. */\n\tsignature: string;\n\t/** Short raw excerpt, so the model sees the real error text. */\n\terrorExcerpt: string;\n\t/** Commands run between the failure and the pass. */\n\tinterveningCommands: string[];\n\t/** Files edited between the failure and the pass. */\n\teditedFiles: string[];\n\t/** Times this signature failed and was resolved across the window. */\n\tcount: number;\n\tsessions: number;\n}\n\nexport interface WorkflowCandidate extends Proposable {\n\t/** Tool-call signatures in order. */\n\tsteps: string[];\n\tcount: number;\n\tsessions: number;\n}\n\nexport interface LearnDigest {\n\tscannedSessions: number;\n\tskippedSessions: number;\n\toldestSession?: string;\n\tnewestSession?: string;\n\tagentsFilePath?: string;\n\tagentsFileTokens?: number;\n\tdirectives: DirectiveCluster[];\n\tfixes: FixCandidate[];\n\tworkflows: WorkflowCandidate[];\n\t/** Items held back because nothing new has happened since they were last shown. */\n\tsuppressed: number;\n\t/** Everything this run put on screen, for the caller to persist. */\n\tsurfaced: Array<{ key: string; lastSeen: string; covered: boolean }>;\n}\n\nexport interface ExtractOptions {\n\tcwd: string;\n\tagentDir: string;\n\t/** Override the directory scanned. Defaults to the per-cwd session dir. */\n\tsessionDir?: string;\n\tmaxSessions?: number;\n\tmaxAgeDays?: number;\n\t/** Occurrences a directive needs before it is proposed. The signal/noise dial. */\n\tminRepeats?: number;\n\t/** Non-overlapping repeats a tool sequence needs before it is proposed as a skill. */\n\tminWorkflowRepeats?: number;\n\t/** Cap on each list in the digest. */\n\tmaxProposals?: number;\n\t/**\n\t * What previous runs already showed. Items with no new occurrences since are\n\t * held back. Omit (or pass `ignoreState`) to propose everything in the window.\n\t */\n\tstate?: LearnState;\n\t/** Re-propose everything, ignoring what previous runs surfaced (`/learn all`). */\n\tignoreState?: boolean;\n\t/**\n\t * Skills a directive can already be covered by. Defaults to the ones loaded\n\t * from disk; injectable so tests do not read the developer's real skills.\n\t */\n\tskills?: Array<{ name: string; description: string }>;\n\t/** Injectable clock, for tests. */\n\tnow?: Date;\n}\n\ninterface SessionHeaderLike {\n\ttype: \"session\";\n\tid?: string;\n\ttimestamp?: string;\n\tcwd?: string;\n}\n\ninterface EntryLike {\n\ttype: string;\n\tid?: string;\n\tparentId?: string | null;\n\ttimestamp?: string;\n\tmessage?: AgentMessage;\n}\n\n/** One session, reduced to the branch that was actually taken. */\ninterface ParsedSession {\n\tfile: string;\n\tid: string;\n\ttimestamp: string;\n\tentries: EntryLike[];\n}\n\nfunction textOf(content: unknown): string {\n\tif (typeof content === \"string\") return content;\n\tif (!Array.isArray(content)) return \"\";\n\treturn content\n\t\t.map((block) =>\n\t\t\tblock && typeof block === \"object\" && (block as TextContent).type === \"text\"\n\t\t\t\t? ((block as TextContent).text ?? \"\")\n\t\t\t\t: \"\",\n\t\t)\n\t\t.join(\"\\n\")\n\t\t.trim();\n}\n\nfunction isToolCall(block: unknown): block is ToolCall {\n\treturn !!block && typeof block === \"object\" && (block as ToolCall).type === \"toolCall\";\n}\n\n/**\n * Reduce a session's raw entries to the branch that was actually taken.\n *\n * Session files are trees — forks and clones append entries that were never\n * part of the same conversation. Walking parent links back from the last entry\n * keeps the extractor from stitching a \"fix\" out of two turns that never\n * happened in sequence. Sessions written before entry ids existed are flat, and\n * for those file order *is* the branch.\n */\nfunction activeBranch(entries: EntryLike[]): EntryLike[] {\n\tconst withIds = entries.filter((e) => typeof e.id === \"string\");\n\tif (withIds.length === 0) return entries;\n\n\tconst byId = new Map<string, EntryLike>();\n\tfor (const entry of withIds) byId.set(entry.id as string, entry);\n\n\tconst branch: EntryLike[] = [];\n\tconst seen = new Set<string>();\n\tlet cursor: EntryLike | undefined = withIds[withIds.length - 1];\n\twhile (cursor?.id && !seen.has(cursor.id)) {\n\t\tseen.add(cursor.id);\n\t\tbranch.push(cursor);\n\t\tcursor = cursor.parentId ? byId.get(cursor.parentId) : undefined;\n\t}\n\treturn branch.reverse();\n}\n\nfunction parseSessionFile(file: string, cwd: string): ParsedSession | undefined {\n\tlet raw: string;\n\ttry {\n\t\traw = readFileSync(file, \"utf-8\");\n\t} catch {\n\t\treturn undefined;\n\t}\n\n\tconst lines = raw.split(\"\\n\");\n\tlet header: SessionHeaderLike | undefined;\n\tconst entries: EntryLike[] = [];\n\tfor (const line of lines) {\n\t\tif (!line.trim()) continue;\n\t\tif (entries.length >= MAX_ENTRIES_PER_SESSION) break;\n\t\tlet parsed: EntryLike | SessionHeaderLike;\n\t\ttry {\n\t\t\tparsed = JSON.parse(line);\n\t\t} catch {\n\t\t\t// A partially-flushed final line is normal for a live session.\n\t\t\tcontinue;\n\t\t}\n\t\tif (parsed.type === \"session\") {\n\t\t\theader ??= parsed as SessionHeaderLike;\n\t\t\tcontinue;\n\t\t}\n\t\tentries.push(parsed as EntryLike);\n\t}\n\n\t// An explicit `--session` path can put a session for another directory in\n\t// this directory, so trust the header over the file's location.\n\tif (header?.cwd && resolve(header.cwd) !== resolve(cwd)) return undefined;\n\tif (entries.length === 0) return undefined;\n\n\treturn {\n\t\tfile,\n\t\tid: header?.id ?? file,\n\t\ttimestamp: header?.timestamp ?? statSync(file).mtime.toISOString(),\n\t\tentries: activeBranch(entries),\n\t};\n}\n\nfunction listSessions(options: ExtractOptions): { sessions: ParsedSession[]; skipped: number } {\n\tconst dir = options.sessionDir ?? getDefaultSessionDir(options.cwd, options.agentDir);\n\tif (!existsSync(dir)) return { sessions: [], skipped: 0 };\n\n\tconst maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;\n\tconst maxAgeDays = options.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS;\n\tconst now = options.now ?? new Date();\n\tconst cutoff = now.getTime() - maxAgeDays * 24 * 60 * 60 * 1000;\n\n\tlet files: string[];\n\ttry {\n\t\tfiles = readdirSync(dir)\n\t\t\t.filter((f) => f.endsWith(\".jsonl\"))\n\t\t\t.map((f) => join(dir, f));\n\t} catch {\n\t\treturn { sessions: [], skipped: 0 };\n\t}\n\n\tconst dated = files\n\t\t.map((file) => {\n\t\t\ttry {\n\t\t\t\treturn { file, mtime: statSync(file).mtime.getTime() };\n\t\t\t} catch {\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t})\n\t\t.filter((f): f is { file: string; mtime: number } => !!f)\n\t\t.sort((a, b) => b.mtime - a.mtime);\n\n\tconst sessions: ParsedSession[] = [];\n\tlet skipped = 0;\n\tfor (const { file, mtime } of dated) {\n\t\tif (sessions.length >= maxSessions) {\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\tif (mtime < cutoff) {\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\tconst parsed = parseSessionFile(file, options.cwd);\n\t\tif (parsed) sessions.push(parsed);\n\t\telse skipped++;\n\t}\n\treturn { sessions, skipped };\n}\n\n/**\n * Hold back items already shown that have not recurred since, then cap the rest.\n *\n * Order matters: suppression runs *before* the cap, or an item you already\n * decided on would occupy one of the few slots the digest has and push a live\n * signal off the list.\n */\nfunction applySuppression<T extends Proposable>(\n\titems: T[],\n\tstate: LearnState | undefined,\n\tmaxProposals: number,\n\tcovered: (item: T) => boolean,\n\tonDeclined?: (item: T) => void,\n): { kept: T[]; suppressed: number } {\n\tif (!state) return { kept: items.slice(0, maxProposals), suppressed: 0 };\n\n\tconst kept: T[] = [];\n\tlet suppressed = 0;\n\tfor (const item of items) {\n\t\tconst verdict = judge(state, { key: item.key, lastSeen: item.lastSeen, covered: covered(item) });\n\t\tif (verdict.suppressed) {\n\t\t\tsuppressed++;\n\t\t\tcontinue;\n\t\t}\n\t\tif (verdict.previouslyDeclined) onDeclined?.(item);\n\t\tkept.push(item);\n\t}\n\treturn { kept: kept.slice(0, maxProposals), suppressed };\n}\n\n/** Nearest AGENTS.md walking up from cwd, so proposals can be checked against it. */\nfunction findAgentsFile(cwd: string): string | undefined {\n\tlet dir = resolve(cwd);\n\twhile (true) {\n\t\tfor (const name of [\"AGENTS.md\", \"AGENTS.MD\", \"CLAUDE.md\", \"CLAUDE.MD\"]) {\n\t\t\tconst candidate = join(dir, name);\n\t\t\tif (existsSync(candidate)) return candidate;\n\t\t}\n\t\tconst parent = dirname(dir);\n\t\tif (parent === dir) return undefined;\n\t\tdir = parent;\n\t}\n}\n\ninterface ToolEvent {\n\tname: string;\n\targs: Record<string, any>;\n\t/** Set once the matching result is seen. */\n\tisError?: boolean;\n\toutput?: string;\n}\n\n/** Pair tool calls with their results along one branch, in call order. */\nfunction toolEvents(entries: EntryLike[]): ToolEvent[] {\n\tconst byCallId = new Map<string, ToolEvent>();\n\tconst ordered: ToolEvent[] = [];\n\n\tfor (const entry of entries) {\n\t\tconst message = entry.type === \"message\" ? entry.message : undefined;\n\t\tif (!message) continue;\n\t\tif (message.role === \"assistant\") {\n\t\t\tfor (const block of (message.content ?? []) as unknown[]) {\n\t\t\t\tif (!isToolCall(block)) continue;\n\t\t\t\tconst event: ToolEvent = { name: block.name, args: block.arguments ?? {} };\n\t\t\t\tbyCallId.set(block.id, event);\n\t\t\t\tordered.push(event);\n\t\t\t}\n\t\t} else if (message.role === \"toolResult\") {\n\t\t\tconst event = byCallId.get(message.toolCallId);\n\t\t\tif (!event) continue;\n\t\t\tevent.isError = message.isError;\n\t\t\tevent.output = textOf(message.content);\n\t\t}\n\t}\n\treturn ordered;\n}\n\n/** User turns worth mining, in order, with the digest's own output excluded. */\nfunction userDirectives(entries: EntryLike[]): string[] {\n\tconst out: string[] = [];\n\tfor (const entry of entries) {\n\t\tconst message = entry.type === \"message\" ? entry.message : undefined;\n\t\tif (!message || message.role !== \"user\") continue;\n\t\tconst text = textOf(message.content);\n\t\tif (!text || text.startsWith(LEARN_DIGEST_MARKER)) continue;\n\t\tif (!isRuleShapedDirective(text)) continue;\n\t\tout.push(text.trim());\n\t}\n\treturn out;\n}\n\nfunction clusterDirectives(\n\tperSession: Array<{ session: ParsedSession; directives: string[] }>,\n\tcoverage: CoverageIndex,\n\tminRepeats: number,\n): DirectiveCluster[] {\n\tinterface Acc {\n\t\ttext: string;\n\t\tnormalized: string;\n\t\tcount: number;\n\t\tsessions: Set<string>;\n\t\tlastSeen: string;\n\t}\n\tconst acc = new Map<string, Acc>();\n\n\tfor (const { session, directives } of perSession) {\n\t\tfor (const text of directives) {\n\t\t\tconst normalized = normalizeDirective(text);\n\t\t\tif (!normalized) continue;\n\t\t\tconst existing = acc.get(normalized);\n\t\t\tif (existing) {\n\t\t\t\texisting.count++;\n\t\t\t\texisting.sessions.add(session.id);\n\t\t\t\tif (session.timestamp > existing.lastSeen) existing.lastSeen = session.timestamp;\n\t\t\t\tif (text.length > existing.text.length) existing.text = text;\n\t\t\t} else {\n\t\t\t\tacc.set(normalized, {\n\t\t\t\t\ttext,\n\t\t\t\t\tnormalized,\n\t\t\t\t\tcount: 1,\n\t\t\t\t\tsessions: new Set([session.id]),\n\t\t\t\t\tlastSeen: session.timestamp,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\tconst clusters: DirectiveCluster[] = [];\n\tfor (const entry of acc.values()) {\n\t\tif (entry.count < minRepeats) continue;\n\n\t\t// Everything reaching here cleared the repeat threshold. Suppression handles\n\t\t// the case that used to make these labels lie — a proposal accepted from a\n\t\t// previous run coming back as \"not working\" when nothing had happened\n\t\t// since. By the time an item survives that filter, a match genuinely means\n\t\t// you repeated yourself after the rule or skill already existed.\n\t\tconst match = matchCoverage(entry.text, coverage);\n\t\tclusters.push({\n\t\t\tkey: `directive:${entry.normalized}`,\n\t\t\ttext: entry.text,\n\t\t\tnormalized: entry.normalized,\n\t\t\tcount: entry.count,\n\t\t\tsessions: entry.sessions.size,\n\t\t\tlastSeen: entry.lastSeen,\n\t\t\tstatus: match.rule ? \"restated\" : match.skill ? \"has-skill\" : \"new\",\n\t\t\texistingRule: match.rule,\n\t\t\texistingSkill: match.skill,\n\t\t\tpreviouslyDeclined: false,\n\t\t});\n\t}\n\n\treturn clusters.sort((a, b) => b.sessions - a.sessions || b.count - a.count || a.text.localeCompare(b.text));\n}\n\n/** Files a mutating tool touched, for the resolution summary. */\nfunction editedFile(event: ToolEvent): string | undefined {\n\tif (![\"edit\", \"write\", \"multi_edit\", \"apply_patch\"].includes(event.name)) return undefined;\n\tconst path = event.args?.path ?? event.args?.file_path ?? event.args?.filePath;\n\treturn typeof path === \"string\" ? path : undefined;\n}\n\nfunction extractFixes(perSession: Array<{ session: ParsedSession; events: ToolEvent[] }>): FixCandidate[] {\n\tinterface Acc {\n\t\tcandidate: FixCandidate;\n\t\tsessions: Set<string>;\n\t}\n\tconst acc = new Map<string, Acc>();\n\n\tfor (const { session, events } of perSession) {\n\t\tfor (let i = 0; i < events.length; i++) {\n\t\t\tconst failure = events[i]!;\n\t\t\tif (failure.name !== \"bash\" || !failure.isError) continue;\n\t\t\tconst command = typeof failure.args?.command === \"string\" ? failure.args.command : \"\";\n\t\t\tif (!command || isBenignFailure(command)) continue;\n\n\t\t\tconst normalized = normalizeCommand(command);\n\t\t\tconst interveningCommands: string[] = [];\n\t\t\tconst editedFiles: string[] = [];\n\t\t\tlet resolved = false;\n\n\t\t\tfor (let j = i + 1; j < Math.min(events.length, i + 1 + FIX_LOOKAHEAD); j++) {\n\t\t\t\tconst next = events[j]!;\n\t\t\t\tconst file = editedFile(next);\n\t\t\t\tif (file) editedFiles.push(file);\n\n\t\t\t\tif (next.name !== \"bash\") continue;\n\t\t\t\tconst nextCommand = typeof next.args?.command === \"string\" ? next.args.command : \"\";\n\t\t\t\tif (!nextCommand) continue;\n\n\t\t\t\t// The same command later succeeding is the only evidence that the\n\t\t\t\t// problem was actually fixed. A *different* command passing says\n\t\t\t\t// nothing, and neither does the model moving on.\n\t\t\t\tif (normalizeCommand(nextCommand) === normalized && !next.isError) {\n\t\t\t\t\tresolved = true;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tinterveningCommands.push(nextCommand.trim());\n\t\t\t}\n\n\t\t\tif (!resolved) continue;\n\n\t\t\tconst output = failure.output ?? \"\";\n\t\t\t// An abort is the user changing their mind, not a problem that was\n\t\t\t// solved, and empty output carries nothing to sign or show.\n\t\t\tif (isUninformativeFailure(output)) continue;\n\n\t\t\t// Sign the error region, not the whole output: build tools lead with an\n\t\t\t// identical banner, so signing everything makes unrelated failures of\n\t\t\t// the same command collide on their shared preamble.\n\t\t\tconst errorRegion = extractErrorRegion(output);\n\t\t\tconst signature = normalizeErrorSignature(errorRegion);\n\t\t\tif (!signature) continue;\n\n\t\t\tconst key = `${normalized}\u0000${signature}`;\n\t\t\tconst existing = acc.get(key);\n\t\t\tif (existing) {\n\t\t\t\texisting.candidate.count++;\n\t\t\t\texisting.sessions.add(session.id);\n\t\t\t\tif (session.timestamp > existing.candidate.lastSeen) existing.candidate.lastSeen = session.timestamp;\n\t\t\t} else {\n\t\t\t\tacc.set(key, {\n\t\t\t\t\tsessions: new Set([session.id]),\n\t\t\t\t\tcandidate: {\n\t\t\t\t\t\tkey: `fix:${key}`,\n\t\t\t\t\t\tcommand: normalized,\n\t\t\t\t\t\tsignature,\n\t\t\t\t\t\terrorExcerpt: errorRegion.replace(/\\s+/g, \" \").trim().slice(0, 240),\n\t\t\t\t\t\tinterveningCommands: [...new Set(interveningCommands)].slice(0, 5),\n\t\t\t\t\t\teditedFiles: [...new Set(editedFiles)].slice(0, 5),\n\t\t\t\t\t\tcount: 1,\n\t\t\t\t\t\tsessions: 1,\n\t\t\t\t\t\tlastSeen: session.timestamp,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\tconst out: FixCandidate[] = [];\n\tfor (const { candidate, sessions } of acc.values()) {\n\t\tcandidate.sessions = sessions.size;\n\t\tout.push(candidate);\n\t}\n\treturn out.sort((a, b) => b.count - a.count || b.sessions - a.sessions || a.signature.localeCompare(b.signature));\n}\n\n/** A tool call reduced to a comparable step: the tool, plus what a bash call runs. */\nfunction stepSignature(event: ToolEvent): string {\n\tif (event.name === \"bash\") {\n\t\tconst command = typeof event.args?.command === \"string\" ? event.args.command : \"\";\n\t\tconst head = commandHead(command);\n\t\treturn head ? `bash:${head}` : \"bash\";\n\t}\n\treturn event.name;\n}\n\n/**\n * Commands that are how an agent looks around rather than what the user was\n * doing. A sequence built only from these plus file edits describes \"coding\",\n * not a workflow, and no useful skill has ever come out of one.\n */\nconst PLUMBING_COMMANDS = new Set([\n\t\"cd\",\n\t\"ls\",\n\t\"pwd\",\n\t\"cat\",\n\t\"head\",\n\t\"tail\",\n\t\"wc\",\n\t\"echo\",\n\t\"which\",\n\t\"find\",\n\t\"fd\",\n\t\"grep\",\n\t\"rg\",\n\t\"sed\",\n\t\"awk\",\n\t\"git status\",\n\t\"git diff\",\n\t\"git log\",\n\t\"git show\",\n]);\n\n/**\n * Whether a sequence is a procedure rather than the rhythm of editing code.\n *\n * Two distinct doing-commands is the bar, and it was set by looking at real\n * transcripts. One command is not enough: the edit/test loop\n * (`edit → edit → bash:npm run`) satisfies it, and because a sliding window\n * over a long alternating run produces every rotation of that cycle, it alone\n * filled all eight slots with `edit → npm run → edit`, `npm run → edit → edit`\n * and so on — one habit described eight ways.\n *\n * A procedure worth a skill chains *different* actions: test then commit then\n * push, build then tag then publish. Requiring two distinct ones keeps those and\n * drops the rhythm. The cost is real — a genuine one-command routine with setup\n * is missed — and that is the intended trade, since a missed skill costs nothing\n * while a digest full of noise costs the reader's attention every run.\n */\nfunction isProcedure(steps: string[]): boolean {\n\tconst commands = new Set<string>();\n\tfor (const step of steps) {\n\t\tif (!step.startsWith(\"bash:\")) continue;\n\t\tconst head = step.slice(\"bash:\".length);\n\t\tif (PLUMBING_COMMANDS.has(head) || PLUMBING_COMMANDS.has(head.split(\" \")[0] ?? \"\")) continue;\n\t\tcommands.add(head);\n\t}\n\treturn commands.size >= 2;\n}\n\n/** True when `needle` appears as a contiguous run inside `haystack`. */\nfunction containsSequence(haystack: string[], needle: string[]): boolean {\n\tif (needle.length > haystack.length) return false;\n\tfor (let i = 0; i + needle.length <= haystack.length; i++) {\n\t\tif (needle.every((step, offset) => haystack[i + offset] === step)) return true;\n\t}\n\treturn false;\n}\n\nfunction extractWorkflows(\n\tperSession: Array<{ session: ParsedSession; events: ToolEvent[] }>,\n\tminRepeats: number,\n): WorkflowCandidate[] {\n\tinterface Acc {\n\t\tsteps: string[];\n\t\tcount: number;\n\t\tsessions: Set<string>;\n\t\tlastSeen: string;\n\t}\n\tconst acc = new Map<string, Acc>();\n\n\tfor (const { session, events } of perSession) {\n\t\tconst steps = events.slice(0, MAX_TOOL_CALLS_PER_SESSION).map(stepSignature);\n\n\t\tfor (let len = WORKFLOW_MIN_LEN; len <= WORKFLOW_MAX_LEN; len++) {\n\t\t\t// Collect every position first, then count greedily without overlap.\n\t\t\t// Counting each sliding position separately treats one long stretch of\n\t\t\t// edit/read churn as dozens of repeats: an `edit > read > edit` run of\n\t\t\t// length 12 scores 10 occurrences when it is really one stretch of work.\n\t\t\tconst positions = new Map<string, number[]>();\n\t\t\tfor (let i = 0; i + len <= steps.length; i++) {\n\t\t\t\tconst window = steps.slice(i, i + len);\n\t\t\t\t// A run of one repeated tool is a loop, not a workflow.\n\t\t\t\tif (new Set(window).size < 2) continue;\n\t\t\t\tif (!isProcedure(window)) continue;\n\t\t\t\tconst key = window.join(\" > \");\n\t\t\t\tconst list = positions.get(key);\n\t\t\t\tif (list) list.push(i);\n\t\t\t\telse positions.set(key, [i]);\n\t\t\t}\n\n\t\t\tfor (const [key, occurrences] of positions) {\n\t\t\t\tlet count = 0;\n\t\t\t\tlet nextFree = -1;\n\t\t\t\tfor (const start of occurrences) {\n\t\t\t\t\tif (start < nextFree) continue;\n\t\t\t\t\tcount++;\n\t\t\t\t\tnextFree = start + len;\n\t\t\t\t}\n\n\t\t\t\tconst existing = acc.get(key);\n\t\t\t\tif (existing) {\n\t\t\t\t\texisting.count += count;\n\t\t\t\t\texisting.sessions.add(session.id);\n\t\t\t\t\tif (session.timestamp > existing.lastSeen) existing.lastSeen = session.timestamp;\n\t\t\t\t} else {\n\t\t\t\t\tacc.set(key, {\n\t\t\t\t\t\tsteps: key.split(\" > \"),\n\t\t\t\t\t\tcount,\n\t\t\t\t\t\tsessions: new Set([session.id]),\n\t\t\t\t\t\tlastSeen: session.timestamp,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tconst ranked = [...acc.values()]\n\t\t.filter((entry) => entry.count >= minRepeats)\n\t\t.map((entry) => ({\n\t\t\tkey: `workflow:${entry.steps.join(\" > \")}`,\n\t\t\tsteps: entry.steps,\n\t\t\tcount: entry.count,\n\t\t\tsessions: entry.sessions.size,\n\t\t\tlastSeen: entry.lastSeen,\n\t\t}))\n\t\t// Sessions first, matching directives: a sequence seen in three sessions is\n\t\t// a workflow, while one repeated ten times in a single session is usually\n\t\t// just the shape of that one task.\n\t\t.sort(\n\t\t\t(a, b) =>\n\t\t\t\tb.sessions - a.sessions ||\n\t\t\t\tb.count - a.count ||\n\t\t\t\tb.steps.length - a.steps.length ||\n\t\t\t\ta.steps.join().localeCompare(b.steps.join()),\n\t\t);\n\n\t// Every n-gram overlaps its own extensions and prefixes, so without this the\n\t// list is one workflow described five slightly different ways. The test runs\n\t// both directions on purpose: a shorter sequence always outranks the longer\n\t// one containing it (it occurs at least as often), so checking only\n\t// shorter-inside-kept would never fire. Keep the best-ranked member of each\n\t// family and drop the rest.\n\tconst distinct: typeof ranked = [];\n\tfor (const candidate of ranked) {\n\t\tconst overlapsKept = distinct.some(\n\t\t\t(kept) => containsSequence(kept.steps, candidate.steps) || containsSequence(candidate.steps, kept.steps),\n\t\t);\n\t\tif (overlapsKept) continue;\n\t\tdistinct.push(candidate);\n\t}\n\treturn distinct;\n}\n\n/**\n * Everything a proposal could already have been written into.\n *\n * Built once and shared, because the same question — is this already written\n * down? — is asked while ranking a run *and* afterwards by `/learn stats`,\n * which reconstructs adoption by comparing coverage now against coverage when\n * the item was shown.\n */\nexport interface CoverageIndex {\n\t/** Candidate rule lines from the repo context file and both user scopes. */\n\truleLines: string[];\n\tskills: Array<{ name: string; description: string }>;\n}\n\nexport interface CoverageMatch {\n\t/** The context-file line that covers this, if any. */\n\trule?: string;\n\t/** The skill that covers this, if any. Only set when no rule matched. */\n\tskill?: string;\n}\n\n/**\n * Where a piece of text is already written down, if anywhere.\n *\n * A rule wins over a skill when both match: it is the more specific answer, and\n * \"rewrite this line\" is more actionable than \"sharpen a description\".\n */\nexport function matchCoverage(text: string, index: CoverageIndex): CoverageMatch {\n\tconst words = contentWords(text);\n\n\tlet bestLine: string | undefined;\n\tlet bestOverlap = 0;\n\tfor (const line of index.ruleLines) {\n\t\tconst overlap = wordOverlap(words, line);\n\t\tif (overlap > bestOverlap) {\n\t\t\tbestOverlap = overlap;\n\t\t\tbestLine = line;\n\t\t}\n\t}\n\tif (bestOverlap >= COVERED_OVERLAP) return { rule: bestLine };\n\n\tlet bestSkill: string | undefined;\n\tlet bestSkillOverlap = 0;\n\tfor (const skill of index.skills) {\n\t\tconst haystack = `${skill.name} ${skill.description.slice(0, SKILL_DESCRIPTION_CHARS)}`;\n\t\tconst overlap = wordOverlap(words, haystack);\n\t\tif (overlap > bestSkillOverlap) {\n\t\t\tbestSkillOverlap = overlap;\n\t\t\tbestSkill = skill.name;\n\t\t}\n\t}\n\tif (bestSkillOverlap >= SKILL_COVERED_OVERLAP) return { skill: bestSkill };\n\n\treturn {};\n}\n\n/** Assemble the coverage index for a directory. */\nexport function buildCoverageIndex(options: {\n\tcwd: string;\n\tagentDir: string;\n\tskills?: Array<{ name: string; description: string }>;\n}): CoverageIndex {\n\tconst corpus = coverageCorpus(options.agentDir, findAgentsFile(options.cwd));\n\treturn {\n\t\truleLines: corpus\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => line.trim())\n\t\t\t.filter((line) => line.length > 0 && !line.startsWith(\"#\")),\n\t\tskills: options.skills ?? loadSkillIndex(options.cwd, options.agentDir),\n\t};\n}\n\n/**\n * Text a proposal is checked against to decide whether it is already written\n * down — the nearest repo context file plus both user scopes.\n *\n * All three matter for suppression, because `/learn` can route a rule to the\n * user scope. Checking only the repo file would report a rule you accepted into\n * `~/.agents/AGENTS.md` as declined.\n */\nfunction coverageCorpus(agentDir: string, repoFile: string | undefined): string {\n\tconst parts: string[] = [];\n\tfor (const candidate of [repoFile, join(getUserAgentsDir(), \"AGENTS.md\"), join(agentDir, \"AGENTS.md\")]) {\n\t\tif (!candidate || !existsSync(candidate)) continue;\n\t\ttry {\n\t\t\tparts.push(readFileSync(candidate, \"utf-8\"));\n\t\t} catch {\n\t\t\t// Unreadable context file: treat as absent rather than failing the run.\n\t\t}\n\t}\n\treturn parts.join(\"\\n\");\n}\n\n/**\n * Skills a proposal could already have become.\n *\n * `/learn` routes long or conditional guidance to a skill rather than a rule, so\n * without this a proposal you adopted *as a skill* would read as declined —\n * looking only at context files sees an unchanged `AGENTS.md` and concludes you\n * passed. Reuses the real loader rather than a second SKILL.md scanner so the\n * set of locations cannot drift from what the session actually loads.\n */\nfunction loadSkillIndex(cwd: string, agentDir: string): Array<{ name: string; description: string }> {\n\ttry {\n\t\treturn loadSkills({ cwd, agentDir, skillPaths: [], includeDefaults: true }).skills.map((skill) => ({\n\t\t\tname: skill.name,\n\t\t\tdescription: skill.description ?? \"\",\n\t\t}));\n\t} catch {\n\t\t// Skills are an enrichment here, not the point of the command.\n\t\treturn [];\n\t}\n}\n\n/** Mine the recent sessions for this cwd and return the ranked digest. */\nexport function extractLearnDigest(options: ExtractOptions): LearnDigest {\n\tconst { sessions, skipped } = listSessions(options);\n\n\tconst agentsFilePath = findAgentsFile(options.cwd);\n\tlet agentsContent: string | undefined;\n\tif (agentsFilePath) {\n\t\ttry {\n\t\t\tagentsContent = readFileSync(agentsFilePath, \"utf-8\");\n\t\t} catch {\n\t\t\tagentsContent = undefined;\n\t\t}\n\t}\n\tconst coverage = buildCoverageIndex({ cwd: options.cwd, agentDir: options.agentDir, skills: options.skills });\n\n\tconst withDirectives = sessions.map((session) => ({ session, directives: userDirectives(session.entries) }));\n\tconst withEvents = sessions.map((session) => ({ session, events: toolEvents(session.entries) }));\n\n\tconst timestamps = sessions.map((s) => s.timestamp).sort();\n\tconst state = options.ignoreState ? undefined : options.state;\n\n\t// Directives carry a real coverage signal — is this written down as a rule or\n\t// a skill right now? — which is what separates an adopted proposal from a\n\t// declined one. Fixes and workflows do not: a fix may have become a rule, a\n\t// skill, or a habit, and which one is not recoverable here, so they get\n\t// suppression only and are never labelled declined.\n\tconst maxProposals = options.maxProposals ?? DEFAULT_MAX_PER_CATEGORY;\n\tconst directives = applySuppression(\n\t\tclusterDirectives(withDirectives, coverage, options.minRepeats ?? DEFAULT_MIN_DIRECTIVE_COUNT),\n\t\tstate,\n\t\tmaxProposals,\n\t\t(item) => item.status !== \"new\",\n\t\t(item) => {\n\t\t\titem.previouslyDeclined = true;\n\t\t},\n\t);\n\tconst fixes = applySuppression(extractFixes(withEvents), state, maxProposals, () => false);\n\tconst workflows = applySuppression(\n\t\textractWorkflows(withEvents, options.minWorkflowRepeats ?? DEFAULT_MIN_WORKFLOW_COUNT),\n\t\tstate,\n\t\tmaxProposals,\n\t\t() => false,\n\t);\n\n\tconst surfaced = [\n\t\t...directives.kept.map((d) => ({ key: d.key, lastSeen: d.lastSeen, covered: d.status !== \"new\" })),\n\t\t...fixes.kept.map((f) => ({ key: f.key, lastSeen: f.lastSeen, covered: false })),\n\t\t...workflows.kept.map((w) => ({ key: w.key, lastSeen: w.lastSeen, covered: false })),\n\t];\n\n\treturn {\n\t\tscannedSessions: sessions.length,\n\t\tskippedSessions: skipped,\n\t\toldestSession: timestamps[0],\n\t\tnewestSession: timestamps[timestamps.length - 1],\n\t\tagentsFilePath,\n\t\tagentsFileTokens:\n\t\t\tagentsContent === undefined ? undefined : Math.round(Buffer.byteLength(agentsContent, \"utf-8\") / 4),\n\t\tdirectives: directives.kept,\n\t\tfixes: fixes.kept,\n\t\tworkflows: workflows.kept,\n\t\tsuppressed: directives.suppressed + fixes.suppressed + workflows.suppressed,\n\t\tsurfaced,\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"extract.d.ts","sourceRoot":"","sources":["../../../src/core/learn/extract.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAqBH,OAAO,EAAS,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAEpD;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,mBAAmB,CAAC;AAoCpD;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,eAAe,GAAG,KAAK,GAAG,UAAU,GAAG,WAAW,CAAC;AAE/D,oFAAoF;AACpF,UAAU,UAAU;IACnB,qEAAmE;IACnE,GAAG,EAAE,MAAM,CAAC;IACZ,4CAA4C;IAC5C,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,gBAAiB,SAAQ,UAAU;IACnD,gEAAgE;IAChE,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,wBAAwB;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,2EAAyE;IACzE,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,eAAe,CAAC;IACxB,iEAAiE;IACjE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,sEAAsE;IACtE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,kBAAkB,EAAE,OAAO,CAAC;CAC5B;AAED,MAAM,WAAW,YAAa,SAAQ,UAAU;IAC/C,kCAAkC;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,YAAY,EAAE,MAAM,CAAC;IACrB,qDAAqD;IACrD,mBAAmB,EAAE,MAAM,EAAE,CAAC;IAC9B,qDAAqD;IACrD,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,sEAAsE;IACtE,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAkB,SAAQ,UAAU;IACpD,qCAAqC;IACrC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,iBAAiB;IACjC,+CAA+C;IAC/C,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,6CAA6C;IAC7C,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,4DAA4D;IAC5D,KAAK,EAAE,MAAM,CAAC;IACd,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAC;IACf,gFAAgF;IAChF,QAAQ,EAAE,MAAM,CAAC;IACjB,8CAA8C;IAC9C,SAAS,EAAE,MAAM,CAAC;IAClB,2DAA2D;IAC3D,UAAU,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,WAAW;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,eAAe,EAAE,MAAM,CAAC;IACxB,8DAA8D;IAC9D,IAAI,EAAE,iBAAiB,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,EAAE,gBAAgB,EAAE,CAAC;IAC/B,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,SAAS,EAAE,iBAAiB,EAAE,CAAC;IAC/B,mFAAmF;IACnF,UAAU,EAAE,MAAM,CAAC;IACnB,oEAAoE;IACpE,QAAQ,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACrE;AAED,MAAM,WAAW,cAAc;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sFAAsF;IACtF,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,sCAAsC;IACtC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,kFAAkF;IAClF,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;OAGG;IACH,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtD,mCAAmC;IACnC,GAAG,CAAC,EAAE,IAAI,CAAC;CACX;AAmJD;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,IAAI,CAAC,cAAc,EAAE,KAAK,GAAG,UAAU,GAAG,YAAY,CAAC,GAAG,MAAM,EAAE,CAW/G;AAqHD;;;GAGG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,cAAc,GAAG,iBAAiB,CAEvE;AAkYD;;;;;;;GAOG;AACH,MAAM,WAAW,aAAa;IAC7B,4EAA4E;IAC5E,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACrD;AAED,MAAM,WAAW,aAAa;IAC7B,sDAAsD;IACtD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yEAAyE;IACzE,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,GAAG,aAAa,CA2B/E;AAED,mDAAmD;AACnD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACtD,GAAG,aAAa,CAShB;AA4CD,0EAA0E;AAC1E,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,cAAc,GAAG,WAAW,CAgEvE","sourcesContent":["/**\n * Session mining for `/learn`.\n *\n * Reads session `.jsonl` files straight off disk rather than the live context.\n * That is the whole point: the on-disk transcript is complete even when the\n * in-context one has been compacted away, and it spans every past session\n * instead of only this one. Cross-session repetition is the signal that decides\n * whether something is a durable rule or a one-off, and it is the one thing a\n * prompt reading its own context cannot see.\n *\n * The split of labour is deliberate. This module is entirely deterministic: it\n * parses, filters, normalizes, counts and ranks. Judgement — is this a rule, how\n * should it be phrased, which scope owns it — belongs to the model reading the\n * digest, which is why the output carries evidence (counts, sessions, dates)\n * rather than conclusions.\n */\n\nimport { existsSync, readdirSync, readFileSync, realpathSync, statSync } from \"node:fs\";\nimport { dirname, join, resolve, sep } from \"node:path\";\nimport type { AgentMessage } from \"@kolisachint/hoocode-agent-core\";\nimport type { TextContent, ToolCall } from \"@kolisachint/hoocode-ai\";\nimport { getUserAgentsDir } from \"../../config.js\";\nimport { getSessionDirPath } from \"../session-manager.js\";\nimport { loadSkills } from \"../skills.js\";\nimport {\n\tcommandHead,\n\tcontentWords,\n\textractErrorRegion,\n\tisBenignFailure,\n\tisRuleShapedDirective,\n\tisUninformativeFailure,\n\tnormalizeCommand,\n\tnormalizeDirective,\n\tnormalizeErrorSignature,\n\twordOverlap,\n} from \"./normalize.js\";\nimport { judge, type LearnState } from \"./state.js\";\n\n/**\n * Prefix on the message `/learn` injects. The digest is persisted like any user\n * turn, so without this marker the next `/learn` would mine its own output and\n * every proposal would compound its own count.\n */\nexport const LEARN_DIGEST_MARKER = \"[learn-digest]\";\n\n/** Sessions considered, newest first. */\nconst DEFAULT_MAX_SESSIONS = 20;\n/** Sessions older than this are ignored — a pattern that stopped is not a rule. */\nconst DEFAULT_MAX_AGE_DAYS = 30;\n/** Entries parsed per session file, as a guard against pathological transcripts. */\nconst MAX_ENTRIES_PER_SESSION = 8000;\n/** Tool calls per session fed to the workflow detector. */\nconst MAX_TOOL_CALLS_PER_SESSION = 400;\n/** How far forward the fix extractor looks for the same command succeeding. */\nconst FIX_LOOKAHEAD = 40;\n/** Word overlap against an existing rule above which a directive counts as covered. */\nconst COVERED_OVERLAP = 0.6;\n/**\n * The same bar for skills, set higher on purpose.\n *\n * A rule is one line, so overlap against it is a sharp signal. A skill is a name\n * plus a description written to attract matches, which is a far larger haystack\n * — a short directive's words turn up in it by chance much more readily. The\n * higher bar and the truncation below keep \"you already have a skill for this\"\n * from being said on a coincidence.\n */\nconst SKILL_COVERED_OVERLAP = 0.75;\n/** Description characters considered. The opening says what a skill does; the rest is trigger bait. */\nconst SKILL_DESCRIPTION_CHARS = 300;\n/** Directives must reach this many occurrences to be reported at all. */\nconst DEFAULT_MIN_DIRECTIVE_COUNT = 2;\n/** Tool sequence lengths considered as workflow candidates. */\nconst WORKFLOW_MIN_LEN = 3;\nconst WORKFLOW_MAX_LEN = 5;\n/** Repeats before a tool sequence is worth proposing as a skill. */\nconst DEFAULT_MIN_WORKFLOW_COUNT = 3;\n/** Cap on each list in the digest, so the model's budget goes to the top signals. */\nconst DEFAULT_MAX_PER_CATEGORY = 8;\n\n/**\n * Where a repeated directive already lives, if anywhere.\n *\n * A directive covered by a rule and said only once is simply dropped — the rule\n * exists and is working. What survives is one of three cases, and they want\n * different responses:\n *\n * - `new` — not written down anywhere. Propose it.\n * - `restated` — a context-file rule covers it and you said it anyway, so the\n * rule is not working. Rewrite it; do not add a second one.\n * - `has-skill` — a *skill* covers it and you asked by hand anyway, which\n * usually means the skill's `description` is not triggering. Sharpen the\n * description rather than writing a rule that duplicates the skill.\n */\nexport type DirectiveStatus = \"new\" | \"restated\" | \"has-skill\";\n\n/** Fields every proposable item shares, so suppression can be applied uniformly. */\ninterface Proposable {\n\t/** Stable identity across runs — what the state file remembers. */\n\tkey: string;\n\t/** Newest occurrence in the window, ISO. */\n\tlastSeen: string;\n}\n\nexport interface DirectiveCluster extends Proposable {\n\t/** Representative raw text, the longest seen in the cluster. */\n\ttext: string;\n\tnormalized: string;\n\t/** Total times said. */\n\tcount: number;\n\t/** Distinct sessions it was said in — the stronger of the two counts. */\n\tsessions: number;\n\tstatus: DirectiveStatus;\n\t/** The existing rule line matched, when status is `restated`. */\n\texistingRule?: string;\n\t/** The skill that already covers this, when status is `has-skill`. */\n\texistingSkill?: string;\n\t/**\n\t * Shown before and still not written down anywhere — neither as a rule nor as\n\t * a skill — so you saw this proposal and passed on it. Only meaningful for\n\t * directives, which are the only items with a real coverage signal.\n\t */\n\tpreviouslyDeclined: boolean;\n}\n\nexport interface FixCandidate extends Proposable {\n\t/** Normalized failing command. */\n\tcommand: string;\n\t/** Normalized error signature, the dedupe key. */\n\tsignature: string;\n\t/** Short raw excerpt, so the model sees the real error text. */\n\terrorExcerpt: string;\n\t/** Commands run between the failure and the pass. */\n\tinterveningCommands: string[];\n\t/** Files edited between the failure and the pass. */\n\teditedFiles: string[];\n\t/** Times this signature failed and was resolved across the window. */\n\tcount: number;\n\tsessions: number;\n}\n\nexport interface WorkflowCandidate extends Proposable {\n\t/** Tool-call signatures in order. */\n\tsteps: string[];\n\tcount: number;\n\tsessions: number;\n}\n\n/**\n * Why a session file on disk did not make it into the digest.\n *\n * \"No recent sessions\" is the one outcome a user cannot act on without this:\n * an empty session directory, a directory full of month-old sessions, and a\n * directory full of sessions belonging to another checkout all produce the same\n * sentence, and the fix differs in each case.\n */\nexport interface SessionScanReport {\n\t/** Directories actually searched, in order. */\n\tdirs: string[];\n\t/** Directories that do not exist on disk. */\n\tmissingDirs: string[];\n\t/** `.jsonl` files found across all searched directories. */\n\tfiles: number;\n\t/** Skipped for being older than the age window. */\n\ttooOld: number;\n\t/** Skipped because the session header records a different working directory. */\n\totherCwd: number;\n\t/** Skipped for being beyond `maxSessions`. */\n\toverLimit: number;\n\t/** Skipped for being unreadable, unparseable, or empty. */\n\tunreadable: number;\n}\n\nexport interface LearnDigest {\n\tscannedSessions: number;\n\tskippedSessions: number;\n\t/** Where the sessions came from, and what was passed over. */\n\tscan: SessionScanReport;\n\toldestSession?: string;\n\tnewestSession?: string;\n\tagentsFilePath?: string;\n\tagentsFileTokens?: number;\n\tdirectives: DirectiveCluster[];\n\tfixes: FixCandidate[];\n\tworkflows: WorkflowCandidate[];\n\t/** Items held back because nothing new has happened since they were last shown. */\n\tsuppressed: number;\n\t/** Everything this run put on screen, for the caller to persist. */\n\tsurfaced: Array<{ key: string; lastSeen: string; covered: boolean }>;\n}\n\nexport interface ExtractOptions {\n\tcwd: string;\n\tagentDir: string;\n\t/**\n\t * An extra directory to scan, normally the live session manager's. The\n\t * per-cwd default directory is always scanned as well, so a session manager\n\t * pointing somewhere unusual cannot hide this directory's history.\n\t */\n\tsessionDir?: string;\n\tmaxSessions?: number;\n\tmaxAgeDays?: number;\n\t/** Occurrences a directive needs before it is proposed. The signal/noise dial. */\n\tminRepeats?: number;\n\t/** Non-overlapping repeats a tool sequence needs before it is proposed as a skill. */\n\tminWorkflowRepeats?: number;\n\t/** Cap on each list in the digest. */\n\tmaxProposals?: number;\n\t/**\n\t * What previous runs already showed. Items with no new occurrences since are\n\t * held back. Omit (or pass `ignoreState`) to propose everything in the window.\n\t */\n\tstate?: LearnState;\n\t/** Re-propose everything, ignoring what previous runs surfaced (`/learn all`). */\n\tignoreState?: boolean;\n\t/**\n\t * Skills a directive can already be covered by. Defaults to the ones loaded\n\t * from disk; injectable so tests do not read the developer's real skills.\n\t */\n\tskills?: Array<{ name: string; description: string }>;\n\t/** Injectable clock, for tests. */\n\tnow?: Date;\n}\n\ninterface SessionHeaderLike {\n\ttype: \"session\";\n\tid?: string;\n\ttimestamp?: string;\n\tcwd?: string;\n}\n\ninterface EntryLike {\n\ttype: string;\n\tid?: string;\n\tparentId?: string | null;\n\ttimestamp?: string;\n\tmessage?: AgentMessage;\n}\n\n/** One session, reduced to the branch that was actually taken. */\ninterface ParsedSession {\n\tfile: string;\n\tid: string;\n\ttimestamp: string;\n\tentries: EntryLike[];\n}\n\nfunction textOf(content: unknown): string {\n\tif (typeof content === \"string\") return content;\n\tif (!Array.isArray(content)) return \"\";\n\treturn content\n\t\t.map((block) =>\n\t\t\tblock && typeof block === \"object\" && (block as TextContent).type === \"text\"\n\t\t\t\t? ((block as TextContent).text ?? \"\")\n\t\t\t\t: \"\",\n\t\t)\n\t\t.join(\"\\n\")\n\t\t.trim();\n}\n\nfunction isToolCall(block: unknown): block is ToolCall {\n\treturn !!block && typeof block === \"object\" && (block as ToolCall).type === \"toolCall\";\n}\n\n/**\n * Reduce a session's raw entries to the branch that was actually taken.\n *\n * Session files are trees — forks and clones append entries that were never\n * part of the same conversation. Walking parent links back from the last entry\n * keeps the extractor from stitching a \"fix\" out of two turns that never\n * happened in sequence. Sessions written before entry ids existed are flat, and\n * for those file order *is* the branch.\n */\nfunction activeBranch(entries: EntryLike[]): EntryLike[] {\n\tconst withIds = entries.filter((e) => typeof e.id === \"string\");\n\tif (withIds.length === 0) return entries;\n\n\tconst byId = new Map<string, EntryLike>();\n\tfor (const entry of withIds) byId.set(entry.id as string, entry);\n\n\tconst branch: EntryLike[] = [];\n\tconst seen = new Set<string>();\n\tlet cursor: EntryLike | undefined = withIds[withIds.length - 1];\n\twhile (cursor?.id && !seen.has(cursor.id)) {\n\t\tseen.add(cursor.id);\n\t\tbranch.push(cursor);\n\t\tcursor = cursor.parentId ? byId.get(cursor.parentId) : undefined;\n\t}\n\treturn branch.reverse();\n}\n\n/**\n * Compare two directory paths the way the filesystem does.\n *\n * A session header stores the cwd as it was typed, and the same directory can\n * be spelled several ways: through a symlink (`/tmp` is `/private/tmp` on\n * macOS), with a trailing separator, or in different case on the\n * case-insensitive filesystems that macOS and Windows ship by default. String\n * equality on `resolve()` alone rejects every one of those, and rejecting them\n * here means silently discarding the whole history the command exists to read.\n */\nfunction normalizeDirPath(path: string): string {\n\tlet resolved = resolve(path);\n\ttry {\n\t\tresolved = realpathSync.native(resolved);\n\t} catch {\n\t\t// Deleted or never-created directory: the textual form is all we have.\n\t}\n\t// `resolve` already drops a trailing separator except at a filesystem root,\n\t// where dropping it would turn \"/\" into \"\".\n\tif (resolved.length > 1 && resolved.endsWith(sep)) resolved = resolved.slice(0, -1);\n\treturn process.platform === \"win32\" || process.platform === \"darwin\" ? resolved.toLowerCase() : resolved;\n}\n\nfunction sameDirectory(a: string, b: string): boolean {\n\treturn normalizeDirPath(a) === normalizeDirPath(b);\n}\n\n/** Reason a candidate file produced no session, for the scan report. */\ntype SkipReason = \"otherCwd\" | \"unreadable\";\n\nfunction parseSessionFile(file: string, cwd: string, onSkip: (reason: SkipReason) => void): ParsedSession | undefined {\n\tlet raw: string;\n\ttry {\n\t\traw = readFileSync(file, \"utf-8\");\n\t} catch {\n\t\tonSkip(\"unreadable\");\n\t\treturn undefined;\n\t}\n\n\tconst lines = raw.split(\"\\n\");\n\tlet header: SessionHeaderLike | undefined;\n\tconst entries: EntryLike[] = [];\n\tfor (const line of lines) {\n\t\tif (!line.trim()) continue;\n\t\tif (entries.length >= MAX_ENTRIES_PER_SESSION) break;\n\t\tlet parsed: EntryLike | SessionHeaderLike;\n\t\ttry {\n\t\t\tparsed = JSON.parse(line);\n\t\t} catch {\n\t\t\t// A partially-flushed final line is normal for a live session.\n\t\t\tcontinue;\n\t\t}\n\t\tif (parsed.type === \"session\") {\n\t\t\theader ??= parsed as SessionHeaderLike;\n\t\t\tcontinue;\n\t\t}\n\t\tentries.push(parsed as EntryLike);\n\t}\n\n\t// An explicit `--session` path can put a session for another directory in\n\t// this directory, so trust the header over the file's location.\n\tif (header?.cwd && !sameDirectory(header.cwd, cwd)) {\n\t\tonSkip(\"otherCwd\");\n\t\treturn undefined;\n\t}\n\tif (entries.length === 0) {\n\t\tonSkip(\"unreadable\");\n\t\treturn undefined;\n\t}\n\n\treturn {\n\t\tfile,\n\t\tid: header?.id ?? file,\n\t\ttimestamp: header?.timestamp ?? statSync(file).mtime.toISOString(),\n\t\tentries: activeBranch(entries),\n\t};\n}\n\n/**\n * Every directory this cwd's sessions could be sitting in.\n *\n * The caller passes the live session manager's directory, which is the right\n * answer almost always — but not quite always, and each exception silently\n * emptied the digest. An in-memory session (`--no-session`) reports `\"\"`; an\n * explicit `--session <path>` reports wherever that file lives; a custom\n * `sessionDir` setting points at one shared directory. In every one of those\n * cases the per-cwd default directory still holds the history worth mining, so\n * search both and let the header check sort out what belongs to this cwd.\n */\nexport function candidateSessionDirs(options: Pick<ExtractOptions, \"cwd\" | \"agentDir\" | \"sessionDir\">): string[] {\n\tconst dirs: string[] = [];\n\tconst seen = new Set<string>();\n\tfor (const dir of [options.sessionDir, getSessionDirPath(options.cwd, options.agentDir)]) {\n\t\tif (!dir) continue;\n\t\tconst key = normalizeDirPath(dir);\n\t\tif (seen.has(key)) continue;\n\t\tseen.add(key);\n\t\tdirs.push(dir);\n\t}\n\treturn dirs;\n}\n\nfunction listSessions(options: ExtractOptions): {\n\tsessions: ParsedSession[];\n\tskipped: number;\n\tscan: SessionScanReport;\n} {\n\tconst dirs = candidateSessionDirs(options);\n\tconst scan: SessionScanReport = {\n\t\tdirs,\n\t\tmissingDirs: [],\n\t\tfiles: 0,\n\t\ttooOld: 0,\n\t\totherCwd: 0,\n\t\toverLimit: 0,\n\t\tunreadable: 0,\n\t};\n\n\tconst maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;\n\tconst maxAgeDays = options.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS;\n\tconst now = options.now ?? new Date();\n\tconst cutoff = now.getTime() - maxAgeDays * 24 * 60 * 60 * 1000;\n\n\tconst files: string[] = [];\n\tfor (const dir of dirs) {\n\t\tif (!existsSync(dir)) {\n\t\t\tscan.missingDirs.push(dir);\n\t\t\tcontinue;\n\t\t}\n\t\ttry {\n\t\t\tfor (const name of readdirSync(dir)) {\n\t\t\t\tif (name.endsWith(\".jsonl\")) files.push(join(dir, name));\n\t\t\t}\n\t\t} catch {\n\t\t\tscan.missingDirs.push(dir);\n\t\t}\n\t}\n\tscan.files = files.length;\n\n\t// Newest first across all directories, so `maxSessions` keeps the most recent\n\t// history rather than whichever directory happened to be searched first.\n\tconst dated = files\n\t\t.map((file) => {\n\t\t\ttry {\n\t\t\t\treturn { file, mtime: statSync(file).mtime.getTime() };\n\t\t\t} catch {\n\t\t\t\tscan.unreadable++;\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t})\n\t\t.filter((f): f is { file: string; mtime: number } => !!f)\n\t\t.sort((a, b) => b.mtime - a.mtime);\n\n\tconst sessions: ParsedSession[] = [];\n\tconst seenIds = new Set<string>();\n\tlet skipped = 0;\n\tfor (const { file, mtime } of dated) {\n\t\tif (sessions.length >= maxSessions) {\n\t\t\tscan.overLimit++;\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\tif (mtime < cutoff) {\n\t\t\tscan.tooOld++;\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\tconst parsed = parseSessionFile(file, options.cwd, (reason) => {\n\t\t\tscan[reason]++;\n\t\t});\n\t\tif (!parsed) {\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\t// Searching two directories can turn up the same session twice (an explicit\n\t\t// `--session` path inside the default directory). Counting it twice would\n\t\t// inflate the cross-session repetition that decides what gets proposed.\n\t\tif (seenIds.has(parsed.id)) {\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\tseenIds.add(parsed.id);\n\t\tsessions.push(parsed);\n\t}\n\treturn { sessions, skipped, scan };\n}\n\n/**\n * Hold back items already shown that have not recurred since, then cap the rest.\n *\n * Order matters: suppression runs *before* the cap, or an item you already\n * decided on would occupy one of the few slots the digest has and push a live\n * signal off the list.\n */\nfunction applySuppression<T extends Proposable>(\n\titems: T[],\n\tstate: LearnState | undefined,\n\tmaxProposals: number,\n\tcovered: (item: T) => boolean,\n\tonDeclined?: (item: T) => void,\n): { kept: T[]; suppressed: number } {\n\tif (!state) return { kept: items.slice(0, maxProposals), suppressed: 0 };\n\n\tconst kept: T[] = [];\n\tlet suppressed = 0;\n\tfor (const item of items) {\n\t\tconst verdict = judge(state, { key: item.key, lastSeen: item.lastSeen, covered: covered(item) });\n\t\tif (verdict.suppressed) {\n\t\t\tsuppressed++;\n\t\t\tcontinue;\n\t\t}\n\t\tif (verdict.previouslyDeclined) onDeclined?.(item);\n\t\tkept.push(item);\n\t}\n\treturn { kept: kept.slice(0, maxProposals), suppressed };\n}\n\n/**\n * Where this cwd's sessions were found and what was passed over, without\n * ranking anything. `/learn stats` reports on the window without re-mining it.\n */\nexport function scanSessions(options: ExtractOptions): SessionScanReport {\n\treturn listSessions(options).scan;\n}\n\n/** Nearest AGENTS.md walking up from cwd, so proposals can be checked against it. */\nfunction findAgentsFile(cwd: string): string | undefined {\n\tlet dir = resolve(cwd);\n\twhile (true) {\n\t\tfor (const name of [\"AGENTS.md\", \"AGENTS.MD\", \"CLAUDE.md\", \"CLAUDE.MD\"]) {\n\t\t\tconst candidate = join(dir, name);\n\t\t\tif (existsSync(candidate)) return candidate;\n\t\t}\n\t\tconst parent = dirname(dir);\n\t\tif (parent === dir) return undefined;\n\t\tdir = parent;\n\t}\n}\n\ninterface ToolEvent {\n\tname: string;\n\targs: Record<string, any>;\n\t/** Set once the matching result is seen. */\n\tisError?: boolean;\n\toutput?: string;\n}\n\n/** Pair tool calls with their results along one branch, in call order. */\nfunction toolEvents(entries: EntryLike[]): ToolEvent[] {\n\tconst byCallId = new Map<string, ToolEvent>();\n\tconst ordered: ToolEvent[] = [];\n\n\tfor (const entry of entries) {\n\t\tconst message = entry.type === \"message\" ? entry.message : undefined;\n\t\tif (!message) continue;\n\t\tif (message.role === \"assistant\") {\n\t\t\tfor (const block of (message.content ?? []) as unknown[]) {\n\t\t\t\tif (!isToolCall(block)) continue;\n\t\t\t\tconst event: ToolEvent = { name: block.name, args: block.arguments ?? {} };\n\t\t\t\tbyCallId.set(block.id, event);\n\t\t\t\tordered.push(event);\n\t\t\t}\n\t\t} else if (message.role === \"toolResult\") {\n\t\t\tconst event = byCallId.get(message.toolCallId);\n\t\t\tif (!event) continue;\n\t\t\tevent.isError = message.isError;\n\t\t\tevent.output = textOf(message.content);\n\t\t}\n\t}\n\treturn ordered;\n}\n\n/** User turns worth mining, in order, with the digest's own output excluded. */\nfunction userDirectives(entries: EntryLike[]): string[] {\n\tconst out: string[] = [];\n\tfor (const entry of entries) {\n\t\tconst message = entry.type === \"message\" ? entry.message : undefined;\n\t\tif (!message || message.role !== \"user\") continue;\n\t\tconst text = textOf(message.content);\n\t\tif (!text || text.startsWith(LEARN_DIGEST_MARKER)) continue;\n\t\tif (!isRuleShapedDirective(text)) continue;\n\t\tout.push(text.trim());\n\t}\n\treturn out;\n}\n\nfunction clusterDirectives(\n\tperSession: Array<{ session: ParsedSession; directives: string[] }>,\n\tcoverage: CoverageIndex,\n\tminRepeats: number,\n): DirectiveCluster[] {\n\tinterface Acc {\n\t\ttext: string;\n\t\tnormalized: string;\n\t\tcount: number;\n\t\tsessions: Set<string>;\n\t\tlastSeen: string;\n\t}\n\tconst acc = new Map<string, Acc>();\n\n\tfor (const { session, directives } of perSession) {\n\t\tfor (const text of directives) {\n\t\t\tconst normalized = normalizeDirective(text);\n\t\t\tif (!normalized) continue;\n\t\t\tconst existing = acc.get(normalized);\n\t\t\tif (existing) {\n\t\t\t\texisting.count++;\n\t\t\t\texisting.sessions.add(session.id);\n\t\t\t\tif (session.timestamp > existing.lastSeen) existing.lastSeen = session.timestamp;\n\t\t\t\tif (text.length > existing.text.length) existing.text = text;\n\t\t\t} else {\n\t\t\t\tacc.set(normalized, {\n\t\t\t\t\ttext,\n\t\t\t\t\tnormalized,\n\t\t\t\t\tcount: 1,\n\t\t\t\t\tsessions: new Set([session.id]),\n\t\t\t\t\tlastSeen: session.timestamp,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\tconst clusters: DirectiveCluster[] = [];\n\tfor (const entry of acc.values()) {\n\t\tif (entry.count < minRepeats) continue;\n\n\t\t// Everything reaching here cleared the repeat threshold. Suppression handles\n\t\t// the case that used to make these labels lie — a proposal accepted from a\n\t\t// previous run coming back as \"not working\" when nothing had happened\n\t\t// since. By the time an item survives that filter, a match genuinely means\n\t\t// you repeated yourself after the rule or skill already existed.\n\t\tconst match = matchCoverage(entry.text, coverage);\n\t\tclusters.push({\n\t\t\tkey: `directive:${entry.normalized}`,\n\t\t\ttext: entry.text,\n\t\t\tnormalized: entry.normalized,\n\t\t\tcount: entry.count,\n\t\t\tsessions: entry.sessions.size,\n\t\t\tlastSeen: entry.lastSeen,\n\t\t\tstatus: match.rule ? \"restated\" : match.skill ? \"has-skill\" : \"new\",\n\t\t\texistingRule: match.rule,\n\t\t\texistingSkill: match.skill,\n\t\t\tpreviouslyDeclined: false,\n\t\t});\n\t}\n\n\treturn clusters.sort((a, b) => b.sessions - a.sessions || b.count - a.count || a.text.localeCompare(b.text));\n}\n\n/** Files a mutating tool touched, for the resolution summary. */\nfunction editedFile(event: ToolEvent): string | undefined {\n\tif (![\"edit\", \"write\", \"multi_edit\", \"apply_patch\"].includes(event.name)) return undefined;\n\tconst path = event.args?.path ?? event.args?.file_path ?? event.args?.filePath;\n\treturn typeof path === \"string\" ? path : undefined;\n}\n\nfunction extractFixes(perSession: Array<{ session: ParsedSession; events: ToolEvent[] }>): FixCandidate[] {\n\tinterface Acc {\n\t\tcandidate: FixCandidate;\n\t\tsessions: Set<string>;\n\t}\n\tconst acc = new Map<string, Acc>();\n\n\tfor (const { session, events } of perSession) {\n\t\tfor (let i = 0; i < events.length; i++) {\n\t\t\tconst failure = events[i]!;\n\t\t\tif (failure.name !== \"bash\" || !failure.isError) continue;\n\t\t\tconst command = typeof failure.args?.command === \"string\" ? failure.args.command : \"\";\n\t\t\tif (!command || isBenignFailure(command)) continue;\n\n\t\t\tconst normalized = normalizeCommand(command);\n\t\t\tconst interveningCommands: string[] = [];\n\t\t\tconst editedFiles: string[] = [];\n\t\t\tlet resolved = false;\n\n\t\t\tfor (let j = i + 1; j < Math.min(events.length, i + 1 + FIX_LOOKAHEAD); j++) {\n\t\t\t\tconst next = events[j]!;\n\t\t\t\tconst file = editedFile(next);\n\t\t\t\tif (file) editedFiles.push(file);\n\n\t\t\t\tif (next.name !== \"bash\") continue;\n\t\t\t\tconst nextCommand = typeof next.args?.command === \"string\" ? next.args.command : \"\";\n\t\t\t\tif (!nextCommand) continue;\n\n\t\t\t\t// The same command later succeeding is the only evidence that the\n\t\t\t\t// problem was actually fixed. A *different* command passing says\n\t\t\t\t// nothing, and neither does the model moving on.\n\t\t\t\tif (normalizeCommand(nextCommand) === normalized && !next.isError) {\n\t\t\t\t\tresolved = true;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tinterveningCommands.push(nextCommand.trim());\n\t\t\t}\n\n\t\t\tif (!resolved) continue;\n\n\t\t\tconst output = failure.output ?? \"\";\n\t\t\t// An abort is the user changing their mind, not a problem that was\n\t\t\t// solved, and empty output carries nothing to sign or show.\n\t\t\tif (isUninformativeFailure(output)) continue;\n\n\t\t\t// Sign the error region, not the whole output: build tools lead with an\n\t\t\t// identical banner, so signing everything makes unrelated failures of\n\t\t\t// the same command collide on their shared preamble.\n\t\t\tconst errorRegion = extractErrorRegion(output);\n\t\t\tconst signature = normalizeErrorSignature(errorRegion);\n\t\t\tif (!signature) continue;\n\n\t\t\tconst key = `${normalized}\u0000${signature}`;\n\t\t\tconst existing = acc.get(key);\n\t\t\tif (existing) {\n\t\t\t\texisting.candidate.count++;\n\t\t\t\texisting.sessions.add(session.id);\n\t\t\t\tif (session.timestamp > existing.candidate.lastSeen) existing.candidate.lastSeen = session.timestamp;\n\t\t\t} else {\n\t\t\t\tacc.set(key, {\n\t\t\t\t\tsessions: new Set([session.id]),\n\t\t\t\t\tcandidate: {\n\t\t\t\t\t\tkey: `fix:${key}`,\n\t\t\t\t\t\tcommand: normalized,\n\t\t\t\t\t\tsignature,\n\t\t\t\t\t\terrorExcerpt: errorRegion.replace(/\\s+/g, \" \").trim().slice(0, 240),\n\t\t\t\t\t\tinterveningCommands: [...new Set(interveningCommands)].slice(0, 5),\n\t\t\t\t\t\teditedFiles: [...new Set(editedFiles)].slice(0, 5),\n\t\t\t\t\t\tcount: 1,\n\t\t\t\t\t\tsessions: 1,\n\t\t\t\t\t\tlastSeen: session.timestamp,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\tconst out: FixCandidate[] = [];\n\tfor (const { candidate, sessions } of acc.values()) {\n\t\tcandidate.sessions = sessions.size;\n\t\tout.push(candidate);\n\t}\n\treturn out.sort((a, b) => b.count - a.count || b.sessions - a.sessions || a.signature.localeCompare(b.signature));\n}\n\n/** A tool call reduced to a comparable step: the tool, plus what a bash call runs. */\nfunction stepSignature(event: ToolEvent): string {\n\tif (event.name === \"bash\") {\n\t\tconst command = typeof event.args?.command === \"string\" ? event.args.command : \"\";\n\t\tconst head = commandHead(command);\n\t\treturn head ? `bash:${head}` : \"bash\";\n\t}\n\treturn event.name;\n}\n\n/**\n * Commands that are how an agent looks around rather than what the user was\n * doing. A sequence built only from these plus file edits describes \"coding\",\n * not a workflow, and no useful skill has ever come out of one.\n */\nconst PLUMBING_COMMANDS = new Set([\n\t\"cd\",\n\t\"ls\",\n\t\"pwd\",\n\t\"cat\",\n\t\"head\",\n\t\"tail\",\n\t\"wc\",\n\t\"echo\",\n\t\"which\",\n\t\"find\",\n\t\"fd\",\n\t\"grep\",\n\t\"rg\",\n\t\"sed\",\n\t\"awk\",\n\t\"git status\",\n\t\"git diff\",\n\t\"git log\",\n\t\"git show\",\n]);\n\n/**\n * Whether a sequence is a procedure rather than the rhythm of editing code.\n *\n * Two distinct doing-commands is the bar, and it was set by looking at real\n * transcripts. One command is not enough: the edit/test loop\n * (`edit → edit → bash:npm run`) satisfies it, and because a sliding window\n * over a long alternating run produces every rotation of that cycle, it alone\n * filled all eight slots with `edit → npm run → edit`, `npm run → edit → edit`\n * and so on — one habit described eight ways.\n *\n * A procedure worth a skill chains *different* actions: test then commit then\n * push, build then tag then publish. Requiring two distinct ones keeps those and\n * drops the rhythm. The cost is real — a genuine one-command routine with setup\n * is missed — and that is the intended trade, since a missed skill costs nothing\n * while a digest full of noise costs the reader's attention every run.\n */\nfunction isProcedure(steps: string[]): boolean {\n\tconst commands = new Set<string>();\n\tfor (const step of steps) {\n\t\tif (!step.startsWith(\"bash:\")) continue;\n\t\tconst head = step.slice(\"bash:\".length);\n\t\tif (PLUMBING_COMMANDS.has(head) || PLUMBING_COMMANDS.has(head.split(\" \")[0] ?? \"\")) continue;\n\t\tcommands.add(head);\n\t}\n\treturn commands.size >= 2;\n}\n\n/** True when `needle` appears as a contiguous run inside `haystack`. */\nfunction containsSequence(haystack: string[], needle: string[]): boolean {\n\tif (needle.length > haystack.length) return false;\n\tfor (let i = 0; i + needle.length <= haystack.length; i++) {\n\t\tif (needle.every((step, offset) => haystack[i + offset] === step)) return true;\n\t}\n\treturn false;\n}\n\nfunction extractWorkflows(\n\tperSession: Array<{ session: ParsedSession; events: ToolEvent[] }>,\n\tminRepeats: number,\n): WorkflowCandidate[] {\n\tinterface Acc {\n\t\tsteps: string[];\n\t\tcount: number;\n\t\tsessions: Set<string>;\n\t\tlastSeen: string;\n\t}\n\tconst acc = new Map<string, Acc>();\n\n\tfor (const { session, events } of perSession) {\n\t\tconst steps = events.slice(0, MAX_TOOL_CALLS_PER_SESSION).map(stepSignature);\n\n\t\tfor (let len = WORKFLOW_MIN_LEN; len <= WORKFLOW_MAX_LEN; len++) {\n\t\t\t// Collect every position first, then count greedily without overlap.\n\t\t\t// Counting each sliding position separately treats one long stretch of\n\t\t\t// edit/read churn as dozens of repeats: an `edit > read > edit` run of\n\t\t\t// length 12 scores 10 occurrences when it is really one stretch of work.\n\t\t\tconst positions = new Map<string, number[]>();\n\t\t\tfor (let i = 0; i + len <= steps.length; i++) {\n\t\t\t\tconst window = steps.slice(i, i + len);\n\t\t\t\t// A run of one repeated tool is a loop, not a workflow.\n\t\t\t\tif (new Set(window).size < 2) continue;\n\t\t\t\tif (!isProcedure(window)) continue;\n\t\t\t\tconst key = window.join(\" > \");\n\t\t\t\tconst list = positions.get(key);\n\t\t\t\tif (list) list.push(i);\n\t\t\t\telse positions.set(key, [i]);\n\t\t\t}\n\n\t\t\tfor (const [key, occurrences] of positions) {\n\t\t\t\tlet count = 0;\n\t\t\t\tlet nextFree = -1;\n\t\t\t\tfor (const start of occurrences) {\n\t\t\t\t\tif (start < nextFree) continue;\n\t\t\t\t\tcount++;\n\t\t\t\t\tnextFree = start + len;\n\t\t\t\t}\n\n\t\t\t\tconst existing = acc.get(key);\n\t\t\t\tif (existing) {\n\t\t\t\t\texisting.count += count;\n\t\t\t\t\texisting.sessions.add(session.id);\n\t\t\t\t\tif (session.timestamp > existing.lastSeen) existing.lastSeen = session.timestamp;\n\t\t\t\t} else {\n\t\t\t\t\tacc.set(key, {\n\t\t\t\t\t\tsteps: key.split(\" > \"),\n\t\t\t\t\t\tcount,\n\t\t\t\t\t\tsessions: new Set([session.id]),\n\t\t\t\t\t\tlastSeen: session.timestamp,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tconst ranked = [...acc.values()]\n\t\t.filter((entry) => entry.count >= minRepeats)\n\t\t.map((entry) => ({\n\t\t\tkey: `workflow:${entry.steps.join(\" > \")}`,\n\t\t\tsteps: entry.steps,\n\t\t\tcount: entry.count,\n\t\t\tsessions: entry.sessions.size,\n\t\t\tlastSeen: entry.lastSeen,\n\t\t}))\n\t\t// Sessions first, matching directives: a sequence seen in three sessions is\n\t\t// a workflow, while one repeated ten times in a single session is usually\n\t\t// just the shape of that one task.\n\t\t.sort(\n\t\t\t(a, b) =>\n\t\t\t\tb.sessions - a.sessions ||\n\t\t\t\tb.count - a.count ||\n\t\t\t\tb.steps.length - a.steps.length ||\n\t\t\t\ta.steps.join().localeCompare(b.steps.join()),\n\t\t);\n\n\t// Every n-gram overlaps its own extensions and prefixes, so without this the\n\t// list is one workflow described five slightly different ways. The test runs\n\t// both directions on purpose: a shorter sequence always outranks the longer\n\t// one containing it (it occurs at least as often), so checking only\n\t// shorter-inside-kept would never fire. Keep the best-ranked member of each\n\t// family and drop the rest.\n\tconst distinct: typeof ranked = [];\n\tfor (const candidate of ranked) {\n\t\tconst overlapsKept = distinct.some(\n\t\t\t(kept) => containsSequence(kept.steps, candidate.steps) || containsSequence(candidate.steps, kept.steps),\n\t\t);\n\t\tif (overlapsKept) continue;\n\t\tdistinct.push(candidate);\n\t}\n\treturn distinct;\n}\n\n/**\n * Everything a proposal could already have been written into.\n *\n * Built once and shared, because the same question — is this already written\n * down? — is asked while ranking a run *and* afterwards by `/learn stats`,\n * which reconstructs adoption by comparing coverage now against coverage when\n * the item was shown.\n */\nexport interface CoverageIndex {\n\t/** Candidate rule lines from the repo context file and both user scopes. */\n\truleLines: string[];\n\tskills: Array<{ name: string; description: string }>;\n}\n\nexport interface CoverageMatch {\n\t/** The context-file line that covers this, if any. */\n\trule?: string;\n\t/** The skill that covers this, if any. Only set when no rule matched. */\n\tskill?: string;\n}\n\n/**\n * Where a piece of text is already written down, if anywhere.\n *\n * A rule wins over a skill when both match: it is the more specific answer, and\n * \"rewrite this line\" is more actionable than \"sharpen a description\".\n */\nexport function matchCoverage(text: string, index: CoverageIndex): CoverageMatch {\n\tconst words = contentWords(text);\n\n\tlet bestLine: string | undefined;\n\tlet bestOverlap = 0;\n\tfor (const line of index.ruleLines) {\n\t\tconst overlap = wordOverlap(words, line);\n\t\tif (overlap > bestOverlap) {\n\t\t\tbestOverlap = overlap;\n\t\t\tbestLine = line;\n\t\t}\n\t}\n\tif (bestOverlap >= COVERED_OVERLAP) return { rule: bestLine };\n\n\tlet bestSkill: string | undefined;\n\tlet bestSkillOverlap = 0;\n\tfor (const skill of index.skills) {\n\t\tconst haystack = `${skill.name} ${skill.description.slice(0, SKILL_DESCRIPTION_CHARS)}`;\n\t\tconst overlap = wordOverlap(words, haystack);\n\t\tif (overlap > bestSkillOverlap) {\n\t\t\tbestSkillOverlap = overlap;\n\t\t\tbestSkill = skill.name;\n\t\t}\n\t}\n\tif (bestSkillOverlap >= SKILL_COVERED_OVERLAP) return { skill: bestSkill };\n\n\treturn {};\n}\n\n/** Assemble the coverage index for a directory. */\nexport function buildCoverageIndex(options: {\n\tcwd: string;\n\tagentDir: string;\n\tskills?: Array<{ name: string; description: string }>;\n}): CoverageIndex {\n\tconst corpus = coverageCorpus(options.agentDir, findAgentsFile(options.cwd));\n\treturn {\n\t\truleLines: corpus\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => line.trim())\n\t\t\t.filter((line) => line.length > 0 && !line.startsWith(\"#\")),\n\t\tskills: options.skills ?? loadSkillIndex(options.cwd, options.agentDir),\n\t};\n}\n\n/**\n * Text a proposal is checked against to decide whether it is already written\n * down — the nearest repo context file plus both user scopes.\n *\n * All three matter for suppression, because `/learn` can route a rule to the\n * user scope. Checking only the repo file would report a rule you accepted into\n * `~/.agents/AGENTS.md` as declined.\n */\nfunction coverageCorpus(agentDir: string, repoFile: string | undefined): string {\n\tconst parts: string[] = [];\n\tfor (const candidate of [repoFile, join(getUserAgentsDir(), \"AGENTS.md\"), join(agentDir, \"AGENTS.md\")]) {\n\t\tif (!candidate || !existsSync(candidate)) continue;\n\t\ttry {\n\t\t\tparts.push(readFileSync(candidate, \"utf-8\"));\n\t\t} catch {\n\t\t\t// Unreadable context file: treat as absent rather than failing the run.\n\t\t}\n\t}\n\treturn parts.join(\"\\n\");\n}\n\n/**\n * Skills a proposal could already have become.\n *\n * `/learn` routes long or conditional guidance to a skill rather than a rule, so\n * without this a proposal you adopted *as a skill* would read as declined —\n * looking only at context files sees an unchanged `AGENTS.md` and concludes you\n * passed. Reuses the real loader rather than a second SKILL.md scanner so the\n * set of locations cannot drift from what the session actually loads.\n */\nfunction loadSkillIndex(cwd: string, agentDir: string): Array<{ name: string; description: string }> {\n\ttry {\n\t\treturn loadSkills({ cwd, agentDir, skillPaths: [], includeDefaults: true }).skills.map((skill) => ({\n\t\t\tname: skill.name,\n\t\t\tdescription: skill.description ?? \"\",\n\t\t}));\n\t} catch {\n\t\t// Skills are an enrichment here, not the point of the command.\n\t\treturn [];\n\t}\n}\n\n/** Mine the recent sessions for this cwd and return the ranked digest. */\nexport function extractLearnDigest(options: ExtractOptions): LearnDigest {\n\tconst { sessions, skipped, scan } = listSessions(options);\n\n\tconst agentsFilePath = findAgentsFile(options.cwd);\n\tlet agentsContent: string | undefined;\n\tif (agentsFilePath) {\n\t\ttry {\n\t\t\tagentsContent = readFileSync(agentsFilePath, \"utf-8\");\n\t\t} catch {\n\t\t\tagentsContent = undefined;\n\t\t}\n\t}\n\tconst coverage = buildCoverageIndex({ cwd: options.cwd, agentDir: options.agentDir, skills: options.skills });\n\n\tconst withDirectives = sessions.map((session) => ({ session, directives: userDirectives(session.entries) }));\n\tconst withEvents = sessions.map((session) => ({ session, events: toolEvents(session.entries) }));\n\n\tconst timestamps = sessions.map((s) => s.timestamp).sort();\n\tconst state = options.ignoreState ? undefined : options.state;\n\n\t// Directives carry a real coverage signal — is this written down as a rule or\n\t// a skill right now? — which is what separates an adopted proposal from a\n\t// declined one. Fixes and workflows do not: a fix may have become a rule, a\n\t// skill, or a habit, and which one is not recoverable here, so they get\n\t// suppression only and are never labelled declined.\n\tconst maxProposals = options.maxProposals ?? DEFAULT_MAX_PER_CATEGORY;\n\tconst directives = applySuppression(\n\t\tclusterDirectives(withDirectives, coverage, options.minRepeats ?? DEFAULT_MIN_DIRECTIVE_COUNT),\n\t\tstate,\n\t\tmaxProposals,\n\t\t(item) => item.status !== \"new\",\n\t\t(item) => {\n\t\t\titem.previouslyDeclined = true;\n\t\t},\n\t);\n\tconst fixes = applySuppression(extractFixes(withEvents), state, maxProposals, () => false);\n\tconst workflows = applySuppression(\n\t\textractWorkflows(withEvents, options.minWorkflowRepeats ?? DEFAULT_MIN_WORKFLOW_COUNT),\n\t\tstate,\n\t\tmaxProposals,\n\t\t() => false,\n\t);\n\n\tconst surfaced = [\n\t\t...directives.kept.map((d) => ({ key: d.key, lastSeen: d.lastSeen, covered: d.status !== \"new\" })),\n\t\t...fixes.kept.map((f) => ({ key: f.key, lastSeen: f.lastSeen, covered: false })),\n\t\t...workflows.kept.map((w) => ({ key: w.key, lastSeen: w.lastSeen, covered: false })),\n\t];\n\n\treturn {\n\t\tscannedSessions: sessions.length,\n\t\tskippedSessions: skipped,\n\t\tscan,\n\t\toldestSession: timestamps[0],\n\t\tnewestSession: timestamps[timestamps.length - 1],\n\t\tagentsFilePath,\n\t\tagentsFileTokens:\n\t\t\tagentsContent === undefined ? undefined : Math.round(Buffer.byteLength(agentsContent, \"utf-8\") / 4),\n\t\tdirectives: directives.kept,\n\t\tfixes: fixes.kept,\n\t\tworkflows: workflows.kept,\n\t\tsuppressed: directives.suppressed + fixes.suppressed + workflows.suppressed,\n\t\tsurfaced,\n\t};\n}\n"]}
|