diff --git a/dot_pi/agent/extensions/answer/index.ts b/dot_pi/agent/extensions/answer/index.ts index f9b4c5cc..377f9141 100644 --- a/dot_pi/agent/extensions/answer/index.ts +++ b/dot_pi/agent/extensions/answer/index.ts @@ -14,10 +14,11 @@ import { complete, + parseJsonWithRepair, type Model, type Api, type UserMessage, -} from "@earendil-works/pi-ai"; +} from "@earendil-works/pi-ai/compat"; import type { ExtensionAPI, ExtensionContext, @@ -46,6 +47,11 @@ interface ExtractionResult { questions: ExtractedQuestion[]; } +type ExtractionOutcome = + | { status: "ok"; result: ExtractionResult } + | { status: "cancelled" } + | { status: "error"; message: string }; + const SYSTEM_PROMPT = `You are a question extractor. Given text from a conversation, extract any questions that need answering. Output a JSON object with this structure: @@ -78,57 +84,119 @@ Example output: ] }`; -const OPENAI_MODEL_ID = "gpt-5.4-mini"; -const ANTHROPIC_MODEL_ID = "claude-haiku-4-5"; +const CODEX_MODEL_IDS = [ + "gpt-5.4-mini", + "gpt-5.3-codex-spark", + "gpt-5.4", + "gpt-5.3-codex", +]; +const HAIKU_MODEL_ID = "claude-haiku-4-5"; /** - * Prefer Haiku for extraction when available, otherwise fall back to Codex mini or the current model. + * Prefer a fast configured Codex model for extraction, then haiku, then the + * current model. */ async function selectExtractionModel( currentModel: Model, modelRegistry: ModelRegistry, ): Promise> { - const haikuModel = modelRegistry.find("anthropic", ANTHROPIC_MODEL_ID); - if (haikuModel) { - const auth = await modelRegistry.getApiKeyAndHeaders(haikuModel); - if (auth.ok) { - return haikuModel; + for (const modelId of CODEX_MODEL_IDS) { + const codexModel = modelRegistry.find("openai-codex", modelId); + if (codexModel) { + const auth = await modelRegistry.getApiKeyAndHeaders(codexModel); + if (auth.ok) { + return codexModel; + } } } - const codexModel = modelRegistry.find("openai-codex", OPENAI_MODEL_ID); - if (codexModel) { - const auth = await modelRegistry.getApiKeyAndHeaders(codexModel); - if (auth.ok) { - return codexModel; - } + const haikuModel = modelRegistry.find("anthropic", HAIKU_MODEL_ID); + if (!haikuModel) { + return currentModel; + } + + const auth = await modelRegistry.getApiKeyAndHeaders(haikuModel); + if (auth.ok === false) { + return currentModel; } - return currentModel; + return haikuModel; +} + +function toExtractedQuestion(value: unknown): ExtractedQuestion | null { + if (typeof value !== "object" || value === null) { + return null; + } + const record = value as Record; + const question = record.question; + const context = record.context; + if (typeof question !== "string") { + return null; + } + if ( + context !== undefined && + context !== null && + typeof context !== "string" + ) { + return null; + } + return typeof context === "string" && context.length > 0 + ? { question, context } + : { question }; +} + +function toExtractionResult(value: unknown): ExtractionResult | null { + if (typeof value !== "object" || value === null) { + return null; + } + const record = value as Record; + if (!Array.isArray(record.questions)) { + return null; + } + const questions: ExtractedQuestion[] = []; + for (const question of record.questions) { + const extractedQuestion = toExtractedQuestion(question); + if (!extractedQuestion) { + return null; + } + questions.push(extractedQuestion); + } + return { questions }; } /** - * Parse the JSON response from the LLM + * Parse the JSON response from the LLM. */ function parseExtractionResult(text: string): ExtractionResult | null { - try { - // Try to find JSON in the response (it might be wrapped in markdown code blocks) - let jsonStr = text; - - // Remove markdown code block if present - const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/); - if (jsonMatch) { - jsonStr = jsonMatch[1].trim(); - } + const candidates: string[] = []; + const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (jsonMatch) { + candidates.push(jsonMatch[1].trim()); + } - const parsed = JSON.parse(jsonStr); - if (parsed && Array.isArray(parsed.questions)) { - return parsed as ExtractionResult; + const trimmed = text.trim(); + candidates.push(trimmed); + + const firstBrace = trimmed.indexOf("{"); + const lastBrace = trimmed.lastIndexOf("}"); + if (firstBrace !== -1 && lastBrace > firstBrace) { + candidates.push(trimmed.slice(firstBrace, lastBrace + 1)); + } + + for (const candidate of candidates) { + try { + const result = toExtractionResult( + parseJsonWithRepair(candidate), + ); + if (result) { + return result; + } + } catch { + // Try the next candidate. } - return null; - } catch { - return null; } + + return null; } /** @@ -169,9 +237,11 @@ class QnAComponent implements Component { const editorTheme: EditorTheme = { borderColor: this.dim, selectList: { - selectedBg: (s: string) => `\x1b[44m${s}\x1b[0m`, - matchHighlight: this.cyan, - itemSecondary: this.gray, + selectedPrefix: this.cyan, + selectedText: (s: string) => `\x1b[44m${s}\x1b[0m`, + description: this.gray, + scrollInfo: this.dim, + noMatch: this.yellow, }, }; @@ -471,27 +541,27 @@ export default function (pi: ExtensionAPI) { return; } - // Select the best model for extraction (prefer Codex mini, then haiku) + // Select the best model for extraction. const extractionModel = await selectExtractionModel( ctx.model, ctx.modelRegistry, ); // Run extraction with loader UI - const extractionResult = await ctx.ui.custom( + const extractionOutcome = await ctx.ui.custom( (tui, theme, _kb, done) => { const loader = new BorderedLoader( tui, theme, `Extracting questions using ${extractionModel.id}...`, ); - loader.onAbort = () => done(null); + loader.onAbort = () => done({ status: "cancelled" }); - const doExtract = async () => { + const doExtract = async (): Promise => { const auth = await ctx.modelRegistry.getApiKeyAndHeaders(extractionModel); - if (!auth.ok) { - throw new Error(auth.error); + if (auth.ok === false) { + return { status: "error", message: auth.error }; } const userMessage: UserMessage = { role: "user", @@ -510,7 +580,13 @@ export default function (pi: ExtensionAPI) { ); if (response.stopReason === "aborted") { - return null; + return { status: "cancelled" }; + } + if (response.stopReason === "error") { + return { + status: "error", + message: response.errorMessage ?? "question extraction failed", + }; } const responseText = response.content @@ -519,23 +595,42 @@ export default function (pi: ExtensionAPI) { ) .map((c) => c.text) .join("\n"); + const result = parseExtractionResult(responseText); + if (!result) { + return { + status: "error", + message: "question extraction returned invalid JSON", + }; + } - return parseExtractionResult(responseText); + return { status: "ok", result }; }; doExtract() .then(done) - .catch(() => done(null)); + .catch((error: unknown) => { + const message = + error instanceof Error ? error.message : String(error); + done({ status: "error", message }); + }); return loader; }, ); - if (extractionResult === null) { + if (extractionOutcome.status === "cancelled") { ctx.ui.notify("Cancelled", "info"); return; } + if (extractionOutcome.status === "error") { + ctx.ui.notify( + `Question extraction failed: ${extractionOutcome.message}`, + "error", + ); + return; + } + const extractionResult = extractionOutcome.result; if (extractionResult.questions.length === 0) { ctx.ui.notify("No questions found in the last message", "info"); return; diff --git a/dot_pi/agent/extensions/commit/index.ts b/dot_pi/agent/extensions/commit/index.ts index 482b8833..f9d4bf7e 100644 --- a/dot_pi/agent/extensions/commit/index.ts +++ b/dot_pi/agent/extensions/commit/index.ts @@ -9,7 +9,7 @@ import { type Api, type Model, type UserMessage, -} from "@earendil-works/pi-ai"; +} from "@earendil-works/pi-ai/compat"; import { BorderedLoader } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI, diff --git a/dot_pi/agent/extensions/split-fork/index.ts b/dot_pi/agent/extensions/split-fork/index.ts new file mode 100644 index 00000000..303e9054 --- /dev/null +++ b/dot_pi/agent/extensions/split-fork/index.ts @@ -0,0 +1,204 @@ +// Source: mitsuhiko/agent-stuff (https://github.com/mitsuhiko/agent-stuff) +// Path: extensions/split-fork.ts +import type { + ExtensionAPI, + ExtensionCommandContext, +} from "@earendil-works/pi-coding-agent"; +import { existsSync, promises as fs } from "node:fs"; +import * as path from "node:path"; +import { randomUUID } from "node:crypto"; + +const GHOSTTY_SPLIT_SCRIPT = `on run argv + set targetCwd to item 1 of argv + set startupInput to item 2 of argv + tell application "Ghostty" + set cfg to new surface configuration + set initial working directory of cfg to targetCwd + set initial input of cfg to startupInput + if (count of windows) > 0 then + try + set frontWindow to front window + set targetTerminal to focused terminal of selected tab of frontWindow + split targetTerminal direction right with configuration cfg + on error + new window with configuration cfg + end try + else + new window with configuration cfg + end if + activate + end tell +end run`; + +const GHOSTTY_TAB_SCRIPT = `on run argv + set targetCwd to item 1 of argv + set startupInput to item 2 of argv + tell application "Ghostty" + set cfg to new surface configuration + set initial working directory of cfg to targetCwd + set initial input of cfg to startupInput + if (count of windows) > 0 then + new tab in front window with configuration cfg + else + new window with configuration cfg + end if + activate + end tell +end run`; + +type ForkMode = "tab" | "split"; + +function parseArgs(args: string): { mode: ForkMode; prompt: string } { + const trimmed = args.trim(); + const match = /^(tab|split)\b\s*/i.exec(trimmed); + if (match) { + return { + mode: match[1].toLowerCase() as ForkMode, + prompt: trimmed.slice(match[0].length).trim(), + }; + } + return { mode: "tab", prompt: trimmed }; +} + +function shellQuote(value: string): string { + if (value.length === 0) return "''"; + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function getPiInvocationParts(): string[] { + const currentScript = process.argv[1]; + if (currentScript && existsSync(currentScript)) { + return [process.execPath, currentScript]; + } + + const execName = path.basename(process.execPath).toLowerCase(); + const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName); + if (!isGenericRuntime) { + return [process.execPath]; + } + + return ["pi"]; +} + +function buildPiStartupInput( + sessionFile: string | undefined, + prompt: string, +): string { + const commandParts = [...getPiInvocationParts()]; + + if (sessionFile) { + commandParts.push("--session", sessionFile); + } + + if (prompt.length > 0) { + commandParts.push("--", prompt); + } + + return `${commandParts.map(shellQuote).join(" ")}\n`; +} + +async function createForkedSession( + ctx: ExtensionCommandContext, +): Promise { + const sessionFile = ctx.sessionManager.getSessionFile(); + if (!sessionFile) { + return undefined; + } + + const sessionDir = path.dirname(sessionFile); + const branchEntries = ctx.sessionManager.getBranch(); + const currentHeader = ctx.sessionManager.getHeader(); + + const timestamp = new Date().toISOString(); + const fileTimestamp = timestamp.replace(/[:.]/g, "-"); + const newSessionId = randomUUID(); + const newSessionFile = path.join( + sessionDir, + `${fileTimestamp}_${newSessionId}.jsonl`, + ); + + const newHeader = { + type: "session", + version: currentHeader?.version ?? 3, + id: newSessionId, + timestamp, + cwd: currentHeader?.cwd ?? ctx.cwd, + parentSession: sessionFile, + }; + + const lines = + [ + JSON.stringify(newHeader), + ...branchEntries.map((entry) => JSON.stringify(entry)), + ].join("\n") + "\n"; + + await fs.mkdir(sessionDir, { recursive: true }); + await fs.writeFile(newSessionFile, lines, "utf8"); + + return newSessionFile; +} + +export default function (pi: ExtensionAPI): void { + pi.registerCommand("split-fork", { + description: + "Fork this session into a new pi process in a Ghostty tab or split. Usage: /split-fork [tab|split] [optional prompt] (defaults to tab)", + handler: async (args, ctx) => { + if (process.platform !== "darwin") { + ctx.ui.notify( + "/split-fork currently requires macOS (Ghostty AppleScript).", + "warning", + ); + return; + } + + const wasBusy = !ctx.isIdle(); + const { mode, prompt } = parseArgs(args); + const script = + mode === "split" ? GHOSTTY_SPLIT_SCRIPT : GHOSTTY_TAB_SCRIPT; + const forkedSessionFile = await createForkedSession(ctx); + const startupInput = buildPiStartupInput(forkedSessionFile, prompt); + + const result = await pi.exec("osascript", [ + "-e", + script, + "--", + ctx.cwd, + startupInput, + ]); + if (result.code !== 0) { + const reason = + result.stderr?.trim() || + result.stdout?.trim() || + "unknown osascript error"; + ctx.ui.notify(`Failed to launch Ghostty ${mode}: ${reason}`, "error"); + if (forkedSessionFile) { + ctx.ui.notify( + `Forked session was created: ${forkedSessionFile}`, + "info", + ); + } + return; + } + + if (forkedSessionFile) { + const fileName = path.basename(forkedSessionFile); + const suffix = prompt ? " and sent prompt" : ""; + ctx.ui.notify( + `Forked to ${fileName} in a new Ghostty ${mode}${suffix}.`, + "info", + ); + if (wasBusy) { + ctx.ui.notify( + "Forked from current committed state (in-flight turn continues in original session).", + "info", + ); + } + } else { + ctx.ui.notify( + `Opened a new Ghostty ${mode} (no persisted session to fork).`, + "warning", + ); + } + }, + }); +} diff --git a/private_dot_local/bin/executable_ai-update b/private_dot_local/bin/executable_ai-update index 710b0571..20effa34 100644 --- a/private_dot_local/bin/executable_ai-update +++ b/private_dot_local/bin/executable_ai-update @@ -19,8 +19,3 @@ echo echo "🥧 Update Pi local packages" "${CHEZMOI_SOURCE_DIR:-$HOME/.local/share/chezmoi}/scripts/pi-packages-update" echo - -ask_for_confirmation "📊 Do you want to update gcx Agent Skills?" -if answer_is_yes; then - gcx agent skills install --all --force -fi diff --git a/private_dot_local/bin/executable_custom-update b/private_dot_local/bin/executable_custom-update index 8afbc6ff..14e3002f 100644 --- a/private_dot_local/bin/executable_custom-update +++ b/private_dot_local/bin/executable_custom-update @@ -31,6 +31,11 @@ execute "brew cleanup" "Cleanup brew" execute "nvim --headless -c 'autocmd User MasonUpdateAllComplete qall' -c 'MasonUpdateAll'" "Update Neovim Mason packages" +ask_for_confirmation "📊 Do you want to update gcx Agent Skills?" +if answer_is_yes; then + gcx agent skills install --all --force +fi + ask_for_confirmation "Run Mole?" if answer_is_yes; then mo clean