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; -}