From 1300e15910e3eb4a2856d388cb4855c4ccba11b7 Mon Sep 17 00:00:00 2001 From: Amolith Date: Fri, 5 Jun 2026 15:08:52 -0600 Subject: [PATCH] Add handoff extraction model picker Register a handoff command that opens Pi's model picker and stores the selected extraction model in the user's settings. Rename the extraction model flag, environment variable, and settings key so they consistently describe the handoff extraction model. Bump @amolith/pi-handoff to 0.1.0-beta.2 for publishing with the beta tag. --- package-lock.json | 2 +- packages/handoff/README.md | 26 ++++- packages/handoff/package.json | 2 +- packages/handoff/src/handoff-command.ts | 6 +- packages/handoff/src/handoff-tool.ts | 6 +- packages/handoff/src/index.ts | 10 +- packages/handoff/src/model-utils.ts | 32 ++++- packages/handoff/src/session-analysis.ts | 143 +++++++++++++++++------ 8 files changed, 173 insertions(+), 54 deletions(-) diff --git a/package-lock.json b/package-lock.json index ca2e2ce1697612d10931c5675a34c64be870f302..311fe30408734d3a7a613d3771245be2b7f0c2a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3347,7 +3347,7 @@ }, "packages/handoff": { "name": "@amolith/pi-handoff", - "version": "0.1.0-beta.1", + "version": "0.1.0-beta.2", "peerDependencies": { "@earendil-works/pi-ai": "*", "@earendil-works/pi-coding-agent": "*", diff --git a/packages/handoff/README.md b/packages/handoff/README.md index f261e4b3026bb0f93366357a4ae5b7abe87ba55d..6efb239269cb54444d3fc22a76ece31a55c5c5ee 100644 --- a/packages/handoff/README.md +++ b/packages/handoff/README.md @@ -27,6 +27,11 @@ by pressing most any key. pi install npm:@amolith/pi-handoff ``` +If you try it and find that it doesn't behave as expected, please send me your +session file, the goal, and the resulting handoff so I can debug. Contact me via +the methods listed at the bottom of [my website](https://secluded.site), or if +you know me elsewhere, you're welcome to DM me there. + ## Do it yourself Run `/handoff` with whatever the next session should do: @@ -59,12 +64,21 @@ editor so you can tweak before starting the new session. ## Configure the extraction model -It uses the current session model by default. To use a cheaper/faster model, set -a `provider/modelId` with one of these: +It uses the current session model by default. Interactively select a +cheaper/faster model by running: + +```text +/handoff:set-extraction-model +``` + +It opens Pi's model picker and saves the selected model in +`$PI_CODING_AGENT_DIR/settings.json` as `handoffExtractionModel`. + +You can also set a `provider/modelId` with one of these: -1. `--handoff-model provider/modelId` -2. `$PI_HANDOFF_MODEL` -3. `handoffModel` in `$PI_CODING_AGENT_DIR/settings.json` +1. Startup flag: `--handoff-extraction-model provider/modelId` +2. Env var: `$PI_HANDOFF_EXTRACTION_MODEL` +3. Settings value: `handoffExtractionModel` in `$PI_CODING_AGENT_DIR/settings.json` If you set more than one, that list is the precedence order. @@ -72,7 +86,7 @@ Example settings: ```json { - "handoffModel": "neuralwatt/qwen3.6-35b-fast" + "handoffExtractionModel": "neuralwatt/qwen3.6-35b-fast" } ``` diff --git a/packages/handoff/package.json b/packages/handoff/package.json index 4d8fe048d4977713e2c4f331cd564bb7e8768f5d..5165cca3798973aeec682323afbb16b95fa82226 100644 --- a/packages/handoff/package.json +++ b/packages/handoff/package.json @@ -1,6 +1,6 @@ { "name": "@amolith/pi-handoff", - "version": "0.1.0-beta.1", + "version": "0.1.0-beta.2", "peerDependencies": { "@earendil-works/pi-ai": "*", "@earendil-works/pi-coding-agent": "*", diff --git a/packages/handoff/src/handoff-command.ts b/packages/handoff/src/handoff-command.ts index 8caa3c14476b062ca882fb8031f228b3b50aa614..3d527f579ecffda0f33bf647a137d596a6084245 100644 --- a/packages/handoff/src/handoff-command.ts +++ b/packages/handoff/src/handoff-command.ts @@ -10,9 +10,9 @@ import type { SessionEntry, } from "@earendil-works/pi-coding-agent"; import { BorderedLoader, convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent"; -import { extractCandidateFiles, extractLoadedSkills, resolveExtractionModel } from "./session-analysis.js"; -import { type HandoffExtraction, assembleHandoffDraft, extractHandoffContext } from "./handoff-extraction.js"; +import { assembleHandoffDraft, extractHandoffContext, type HandoffExtraction } from "./handoff-extraction.js"; import { parseAndSetModelWithNotify } from "./model-utils.js"; +import { configuredExtractionModel, extractCandidateFiles, extractLoadedSkills } from "./session-analysis.js"; export function registerHandoffCommand(pi: ExtensionAPI, startCountdown: (ctx: ExtensionContext) => void) { pi.registerCommand("handoff", { @@ -63,7 +63,7 @@ export function registerHandoffCommand(pi: ExtensionAPI, startCountdown: (ctx: E const currentSessionFile = ctx.sessionManager.getSessionFile(); const candidateFiles = [...extractCandidateFiles(branch, conversationText)]; const loadedSkills = extractLoadedSkills(branch); - const extractionModel = resolveExtractionModel(pi, ctx) ?? ctx.model; + const extractionModel = configuredExtractionModel(pi, ctx) ?? ctx.model; const result = await ctx.ui.custom((tui, theme, _kb, done) => { const loader = new BorderedLoader(tui, theme, "Extracting handoff context..."); diff --git a/packages/handoff/src/handoff-tool.ts b/packages/handoff/src/handoff-tool.ts index 63bbc0df0b6b6d6538bd088d1753b21aff474fdd..4c0155748c6e627d7722f1b18ee7d13edcac8441 100644 --- a/packages/handoff/src/handoff-tool.ts +++ b/packages/handoff/src/handoff-tool.ts @@ -6,8 +6,8 @@ import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent"; import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; -import { extractCandidateFiles, extractLoadedSkills, resolveExtractionModel } from "./session-analysis.js"; -import { type HandoffExtraction, assembleHandoffDraft, extractHandoffContext } from "./handoff-extraction.js"; +import { assembleHandoffDraft, extractHandoffContext, type HandoffExtraction } from "./handoff-extraction.js"; +import { configuredExtractionModel, extractCandidateFiles, extractLoadedSkills } from "./session-analysis.js"; export type PendingHandoff = { prompt: string; @@ -61,7 +61,7 @@ export function registerHandoffTool(pi: ExtensionAPI, setPendingHandoff: (h: Pen const candidateFiles = [...extractCandidateFiles(branch, conversationText)]; const loadedSkills = extractLoadedSkills(branch); - const extractionModel = resolveExtractionModel(pi, ctx) ?? ctx.model; + const extractionModel = configuredExtractionModel(pi, ctx) ?? ctx.model; const auth = await ctx.modelRegistry.getApiKeyAndHeaders(extractionModel); if (!auth.ok) { return { diff --git a/packages/handoff/src/index.ts b/packages/handoff/src/index.ts index 7131a10c81c6b5bf90371eeb0d99bcc7290c4787..af1cb28b87f797b92fcb11095dac5228ce9a4c9f 100644 --- a/packages/handoff/src/index.ts +++ b/packages/handoff/src/index.ts @@ -3,13 +3,12 @@ // // SPDX-License-Identifier: MIT -import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; -import type { SessionManager } from "@earendil-works/pi-coding-agent"; +import type { ExtensionAPI, ExtensionContext, SessionManager } from "@earendil-works/pi-coding-agent"; import { Key, matchesKey, parseKey } from "@earendil-works/pi-tui"; import { registerHandoffCommand } from "./handoff-command.js"; -import { registerHandoffTool, type PendingHandoff } from "./handoff-tool.js"; +import { type PendingHandoff, registerHandoffTool } from "./handoff-tool.js"; import { parseAndSetModelWithNotify } from "./model-utils.js"; -import { registerHandoffModelFlag } from "./session-analysis.js"; +import { registerHandoffExtractionModelCommand, registerHandoffExtractionModelFlag } from "./session-analysis.js"; import { registerSessionQueryTool } from "./session-query-tool.js"; const STATUS_KEY = "handoff"; @@ -198,7 +197,8 @@ export default function (pi: ExtensionAPI) { // --- Register tools and commands --- - registerHandoffModelFlag(pi); + registerHandoffExtractionModelFlag(pi); + registerHandoffExtractionModelCommand(pi); registerSessionQueryTool(pi); registerHandoffTool(pi, (h) => { pendingHandoff = h; diff --git a/packages/handoff/src/model-utils.ts b/packages/handoff/src/model-utils.ts index 403140463fca09df147485b48214db1ef37b5b28..a72ce6b266673e0c83a36a16ea5b4983ed7dc86e 100644 --- a/packages/handoff/src/model-utils.ts +++ b/packages/handoff/src/model-utils.ts @@ -2,7 +2,12 @@ // // SPDX-License-Identifier: MIT -import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { + type ExtensionAPI, + type ExtensionContext, + ModelSelectorComponent, + SettingsManager, +} from "@earendil-works/pi-coding-agent"; export type SetModelResult = | { ok: true } @@ -50,3 +55,28 @@ export async function parseAndSetModelWithNotify( } return result; } + +/** + * Show Pi's built-in model picker and return the user's selection without + * changing Pi's active model or default model setting. + */ +export async function pickModel( + ctx: ExtensionContext, + currentModel: ExtensionContext["model"] = ctx.model, + initialSearchInput?: string, +): Promise { + return ctx.ui.custom((tui, _theme, _keybindings, done) => { + const selector = new ModelSelectorComponent( + tui, + currentModel, + SettingsManager.inMemory(), + ctx.modelRegistry, + [], + (model) => done(model), + () => done(undefined), + initialSearchInput, + ); + + return selector; + }); +} diff --git a/packages/handoff/src/session-analysis.ts b/packages/handoff/src/session-analysis.ts index 250567f8e996fb50de4c52a35a7b006e62e0d978..c702ebc4157874ee7610b47e2b4762320dc2c4d2 100644 --- a/packages/handoff/src/session-analysis.ts +++ b/packages/handoff/src/session-analysis.ts @@ -3,31 +3,81 @@ // // SPDX-License-Identifier: MIT +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import * as path from "node:path"; import { - getAgentDir, - SettingsManager, type ExtensionAPI, + type ExtensionCommandContext, type ExtensionContext, + getAgentDir, type SessionEntry, + SettingsManager, } from "@earendil-works/pi-coding-agent"; -import * as path from "node:path"; +import { pickModel } from "./model-utils.js"; /** * Model override sources for handoff extraction calls. Use provider/modelId * values (e.g. "anthropic/claude-haiku-4-5"). Precedence is: - * --handoff-model, PI_HANDOFF_MODEL, user settings handoffModel, session model. + * --handoff-extraction-model, PI_HANDOFF_EXTRACTION_MODEL, user settings + * handoffExtractionModel, session model. */ -const HANDOFF_MODEL_FLAG = "handoff-model"; -const HANDOFF_MODEL_ENV = "PI_HANDOFF_MODEL"; -const HANDOFF_MODEL_SETTING = "handoffModel"; +const HANDOFF_EXTRACTION_MODEL_FLAG = "handoff-extraction-model"; +const HANDOFF_EXTRACTION_MODEL_ENV = "PI_HANDOFF_EXTRACTION_MODEL"; +const HANDOFF_EXTRACTION_MODEL_SETTING = "handoffExtractionModel"; + +type JsonValue = string | number | boolean | null | JsonObject | JsonValue[]; -export function registerHandoffModelFlag(pi: ExtensionAPI): void { - pi.registerFlag(HANDOFF_MODEL_FLAG, { +interface JsonObject { + [key: string]: JsonValue; +} + +export function registerHandoffExtractionModelFlag(pi: ExtensionAPI): void { + pi.registerFlag(HANDOFF_EXTRACTION_MODEL_FLAG, { description: "Model for handoff extraction as provider/modelId", type: "string", }); } +export function registerHandoffExtractionModelCommand(pi: ExtensionAPI): void { + pi.registerCommand("handoff:set-extraction-model", { + description: "Choose the model used to extract handoff context", + handler: async (args: string, ctx: ExtensionCommandContext) => { + if (!ctx.hasUI || ctx.mode !== "tui") { + ctx.ui.notify("/handoff:set-extraction-model requires interactive mode", "error"); + return; + } + + const currentModel = configuredExtractionModel(pi, ctx); + const selectedModel = await pickModel(ctx, currentModel, nonEmptyString(args)); + if (!selectedModel) { + ctx.ui.notify("Handoff extraction model unchanged", "info"); + return; + } + + const modelSpec = `${selectedModel.provider}/${selectedModel.id}`; + try { + setUserSettingsHandoffExtractionModel(modelSpec); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + ctx.ui.notify(`Failed to save handoff extraction model: ${message}`, "error"); + return; + } + + process.env[HANDOFF_EXTRACTION_MODEL_ENV] = modelSpec; + + if (configuredFlagHandoffExtractionModel(pi)) { + ctx.ui.notify( + `Saved handoff extraction model: ${modelSpec}. The --${HANDOFF_EXTRACTION_MODEL_FLAG} flag still takes precedence for this run.`, + "warning", + ); + return; + } + + ctx.ui.notify(`Handoff extraction model: ${modelSpec}`, "info"); + }, + }); +} + function nonEmptyString(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; @@ -39,15 +89,46 @@ function stringProperty(source: object, key: string): string | undefined { return typeof descriptor.value === "string" ? nonEmptyString(descriptor.value) : undefined; } -function userSettingsHandoffModel(cwd: string): string | undefined { +function userSettingsHandoffExtractionModel(cwd: string): string | undefined { const settingsManager = SettingsManager.create(cwd, getAgentDir()); - return stringProperty(settingsManager.getGlobalSettings(), HANDOFF_MODEL_SETTING); + return stringProperty(settingsManager.getGlobalSettings(), HANDOFF_EXTRACTION_MODEL_SETTING); } -function configuredHandoffModel(pi: ExtensionAPI, ctx: { cwd: string }): string | undefined { - const flagValue = pi.getFlag(HANDOFF_MODEL_FLAG); +function configuredFlagHandoffExtractionModel(pi: ExtensionAPI): string | undefined { + const flagValue = pi.getFlag(HANDOFF_EXTRACTION_MODEL_FLAG); const flagModel = typeof flagValue === "string" ? nonEmptyString(flagValue) : undefined; - return flagModel ?? nonEmptyString(process.env[HANDOFF_MODEL_ENV]) ?? userSettingsHandoffModel(ctx.cwd); + return flagModel; +} + +function configuredHandoffExtractionModel(pi: ExtensionAPI, ctx: { cwd: string }): string | undefined { + const flagModel = configuredFlagHandoffExtractionModel(pi); + return ( + flagModel ?? + nonEmptyString(process.env[HANDOFF_EXTRACTION_MODEL_ENV]) ?? + userSettingsHandoffExtractionModel(ctx.cwd) + ); +} + +function parseJsonObject(content: string, source: string): JsonObject { + const parsed: JsonValue = JSON.parse(content); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${source} must contain a JSON object`); + } + return parsed; +} + +function readSettingsObject(settingsPath: string): JsonObject { + if (!existsSync(settingsPath)) return {}; + return parseJsonObject(readFileSync(settingsPath, "utf-8"), settingsPath); +} + +function setUserSettingsHandoffExtractionModel(modelSpec: string): void { + const settingsPath = path.join(getAgentDir(), "settings.json"); + const settings = readSettingsObject(settingsPath); + settings[HANDOFF_EXTRACTION_MODEL_SETTING] = modelSpec; + + mkdirSync(path.dirname(settingsPath), { recursive: true }); + writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8"); } function parseModelSpec(spec: string): { provider: string; modelId: string } | undefined { @@ -59,6 +140,20 @@ function parseModelSpec(spec: string): { provider: string; modelId: string } | u }; } +export function configuredExtractionModel( + pi: ExtensionAPI, + ctx: { + cwd: string; + modelRegistry: ExtensionContext["modelRegistry"]; + }, +): ExtensionContext["model"] { + const modelSpec = configuredHandoffExtractionModel(pi, ctx); + if (!modelSpec) return undefined; + const parsed = parseModelSpec(modelSpec); + if (!parsed) return undefined; + return ctx.modelRegistry.find(parsed.provider, parsed.modelId); +} + /** * Build a candidate file set from two sources: * 1. Primary: actual tool calls (read, write, edit, create) in the session @@ -134,23 +229,3 @@ export function extractLoadedSkills(entries: SessionEntry[]): string[] { } return [...skills].sort(); } - -/** - * Resolve the model to use for handoff extraction calls. Uses --handoff-model, - * PI_HANDOFF_MODEL, then handoffModel in user settings, falling back to the - * session model if no valid override is configured. - */ -export function resolveExtractionModel( - pi: ExtensionAPI, - ctx: { - cwd: string; - model: ExtensionContext["model"]; - modelRegistry: ExtensionContext["modelRegistry"]; - }, -): ExtensionContext["model"] { - const modelSpec = configuredHandoffModel(pi, ctx); - if (!modelSpec) return ctx.model; - const parsed = parseModelSpec(modelSpec); - if (!parsed) return ctx.model; - return ctx.modelRegistry.find(parsed.provider, parsed.modelId) ?? ctx.model; -}