From 04d7eda8c40f1e2c245d5001bf101d16c01ab143 Mon Sep 17 00:00:00 2001 From: Christoph Schmatzler Date: Sun, 22 Mar 2026 20:42:50 +0000 Subject: [PATCH] Add pi-harness breadcrumbs and vendored session naming --- flake.lock | 17 ++ flake.nix | 4 + modules/_ai-tools/session-name.ts | 260 ++++++++++++++++++++++++++++++ modules/ai-tools.nix | 8 + modules/dendritic.nix | 4 + 5 files changed, 293 insertions(+) create mode 100644 modules/_ai-tools/session-name.ts diff --git a/flake.lock b/flake.lock index 3efc352..bd65dca 100644 --- a/flake.lock +++ b/flake.lock @@ -848,6 +848,22 @@ "type": "github" } }, + "pi-harness": { + "flake": false, + "locked": { + "lastModified": 1774210293, + "narHash": "sha256-YzQn5r5KAORu6FI7dIkKWTU5f/HYinYu7i8QwPKCYik=", + "owner": "aliou", + "repo": "pi-harness", + "rev": "720db4f57cafd732953fae14ecb5eb50ff4d808c", + "type": "github" + }, + "original": { + "owner": "aliou", + "repo": "pi-harness", + "type": "github" + } + }, "pi-rose-pine": { "flake": false, "locked": { @@ -911,6 +927,7 @@ "nixvim": "nixvim", "pi-agent-stuff": "pi-agent-stuff", "pi-elixir": "pi-elixir", + "pi-harness": "pi-harness", "pi-rose-pine": "pi-rose-pine", "sops-nix": "sops-nix", "zjstatus": "zjstatus" diff --git a/flake.nix b/flake.nix index 7316639..7116e81 100644 --- a/flake.nix +++ b/flake.nix @@ -76,6 +76,10 @@ url = "github:dannote/pi-elixir"; flake = false; }; + pi-harness = { + url = "github:aliou/pi-harness"; + flake = false; + }; pi-rose-pine = { url = "github:zenobi-us/pi-rose-pine"; flake = false; diff --git a/modules/_ai-tools/session-name.ts b/modules/_ai-tools/session-name.ts new file mode 100644 index 0000000..856b7f4 --- /dev/null +++ b/modules/_ai-tools/session-name.ts @@ -0,0 +1,260 @@ +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { + createAgentSession, + DefaultResourceLoader, + getAgentDir, + SessionManager, + SettingsManager, +} from "@mariozechner/pi-coding-agent"; + +interface SessionNameState { + hasAutoNamed: boolean; +} + +const TITLE_MODEL = { + provider: "openai-codex", + id: "gpt-5.4-mini", +} as const; + +const MAX_TITLE_LENGTH = 50; +const MAX_RETRIES = 2; +const FALLBACK_LENGTH = 50; +const TITLE_ENTRY_TYPE = "vendored-session-title"; + +const TITLE_SYSTEM_PROMPT = `You are generating a succinct title for a coding session based on the provided conversation. + +Requirements: +- Maximum 50 characters +- Sentence case (capitalize only first word and proper nouns) +- Capture the main intent or task +- Reuse the user's exact words and technical terms +- Match the user's language +- No quotes, colons, or markdown formatting +- No generic titles like "Coding session" or "Help with code" +- No explanations or commentary + +Output ONLY the title text. Nothing else.`; + +function isTurnCompleted(event: unknown): boolean { + if (!event || typeof event !== "object") return false; + const message = (event as { message?: unknown }).message; + if (!message || typeof message !== "object") return false; + const stopReason = (message as { stopReason?: unknown }).stopReason; + return typeof stopReason === "string" && stopReason.toLowerCase() === "stop"; +} + +function buildFallbackTitle(userText: string): string { + const text = userText.trim(); + if (text.length <= FALLBACK_LENGTH) return text; + const truncated = text.slice(0, FALLBACK_LENGTH - 3); + const lastSpace = truncated.lastIndexOf(" "); + return `${lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated}...`; +} + +function postProcessTitle(raw: string): string { + let title = raw; + + title = title.replace(/\s*/g, ""); + title = title.replace(/^["'`]+|["'`]+$/g, ""); + title = title.replace(/^#+\s*/, ""); + title = title.replace(/\*{1,2}(.*?)\*{1,2}/g, "$1"); + title = title.replace(/_{1,2}(.*?)_{1,2}/g, "$1"); + title = title.replace(/^(Title|Summary|Session)\s*:\s*/i, ""); + title = + title + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0) ?? title; + title = title.trim(); + + if (title.length > MAX_TITLE_LENGTH) { + const truncated = title.slice(0, MAX_TITLE_LENGTH - 3); + const lastSpace = truncated.lastIndexOf(" "); + title = `${lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated}...`; + } + + return title; +} + +function getLatestUserText(ctx: ExtensionContext): string | null { + const entries = ctx.sessionManager.getEntries(); + for (let i = entries.length - 1; i >= 0; i -= 1) { + const entry = entries[i]; + if (!entry || entry.type !== "message") continue; + if (entry.message.role !== "user") continue; + + const { content } = entry.message as { content: unknown }; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return null; + + return content + .filter( + (part): part is { type: string; text?: string } => + typeof part === "object" && part !== null && "type" in part, + ) + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => part.text ?? "") + .join(" "); + } + + return null; +} + +function getLatestAssistantText(ctx: ExtensionContext): string | null { + const entries = ctx.sessionManager.getEntries(); + for (let i = entries.length - 1; i >= 0; i -= 1) { + const entry = entries[i]; + if (!entry || entry.type !== "message") continue; + if (entry.message.role !== "assistant") continue; + + const { content } = entry.message as { content: unknown }; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return null; + + return content + .filter( + (part): part is { type: string; text?: string } => + typeof part === "object" && part !== null && "type" in part, + ) + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => part.text ?? "") + .join("\n"); + } + + return null; +} + +function resolveModel(ctx: ExtensionContext) { + const available = ctx.modelRegistry.getAvailable(); + const model = available.find( + (candidate) => candidate.provider === TITLE_MODEL.provider && candidate.id === TITLE_MODEL.id, + ); + if (model) return model; + + const existsWithoutKey = ctx.modelRegistry + .getAll() + .some((candidate) => candidate.provider === TITLE_MODEL.provider && candidate.id === TITLE_MODEL.id); + if (existsWithoutKey) { + throw new Error( + `Model ${TITLE_MODEL.provider}/${TITLE_MODEL.id} exists but has no configured API key.`, + ); + } + + throw new Error(`Model ${TITLE_MODEL.provider}/${TITLE_MODEL.id} is not available.`); +} + +async function generateTitle(userText: string, assistantText: string, ctx: ExtensionContext): Promise { + const agentDir = getAgentDir(); + const settingsManager = SettingsManager.create(ctx.cwd, agentDir); + const resourceLoader = new DefaultResourceLoader({ + cwd: ctx.cwd, + agentDir, + settingsManager, + noExtensions: true, + noPromptTemplates: true, + noThemes: true, + noSkills: true, + systemPromptOverride: () => TITLE_SYSTEM_PROMPT, + appendSystemPromptOverride: () => [], + agentsFilesOverride: () => ({ agentsFiles: [] }), + }); + await resourceLoader.reload(); + + const { session } = await createAgentSession({ + model: resolveModel(ctx), + thinkingLevel: "off", + sessionManager: SessionManager.inMemory(), + modelRegistry: ctx.modelRegistry, + resourceLoader, + }); + + let accumulated = ""; + const unsubscribe = session.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + accumulated += event.assistantMessageEvent.delta; + } + }); + + const description = assistantText + ? `${userText}\n${assistantText}` + : `${userText}`; + const userMessage = `\n${description}\n\n\nGenerate a title:`; + + try { + await session.prompt(userMessage); + } finally { + unsubscribe(); + session.dispose(); + } + + return postProcessTitle(accumulated); +} + +async function generateAndSetTitle(pi: ExtensionAPI, ctx: ExtensionContext): Promise { + const userText = getLatestUserText(ctx); + if (!userText?.trim()) return; + + const assistantText = getLatestAssistantText(ctx) ?? ""; + if (!assistantText.trim()) return; + + let lastError: Error | null = null; + for (let attempt = 1; attempt <= MAX_RETRIES; attempt += 1) { + try { + const title = await generateTitle(userText, assistantText, ctx); + if (!title) continue; + + pi.setSessionName(title); + pi.appendEntry(TITLE_ENTRY_TYPE, { + title, + rawUserText: userText, + rawAssistantText: assistantText, + attempt, + model: `${TITLE_MODEL.provider}/${TITLE_MODEL.id}`, + }); + ctx.ui.notify(`Session: ${title}`, "info"); + return; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + } + } + + const fallback = buildFallbackTitle(userText); + pi.setSessionName(fallback); + pi.appendEntry(TITLE_ENTRY_TYPE, { + title: fallback, + fallback: true, + error: lastError?.message ?? "Unknown error", + rawUserText: userText, + rawAssistantText: assistantText, + model: `${TITLE_MODEL.provider}/${TITLE_MODEL.id}`, + }); + ctx.ui.notify(`Title generation failed, using fallback: ${fallback}`, "warning"); +} + +export default function setupSessionNameHook(pi: ExtensionAPI) { + const state: SessionNameState = { + hasAutoNamed: false, + }; + + pi.on("session_start", async () => { + state.hasAutoNamed = false; + }); + + pi.on("session_switch", async () => { + state.hasAutoNamed = false; + }); + + pi.on("turn_end", async (event, ctx) => { + if (state.hasAutoNamed) return; + + if (pi.getSessionName()) { + state.hasAutoNamed = true; + return; + } + + if (!isTurnCompleted(event)) return; + + await generateAndSetTitle(pi, ctx); + state.hasAutoNamed = true; + }); +} diff --git a/modules/ai-tools.nix b/modules/ai-tools.nix index 80fef04..c674375 100644 --- a/modules/ai-tools.nix +++ b/modules/ai-tools.nix @@ -23,6 +23,7 @@ ".pi/agent/extensions/no-git.ts".source = ./_ai-tools/no-git.ts; ".pi/agent/extensions/no-scripting.ts".source = ./_ai-tools/no-scripting.ts; ".pi/agent/extensions/review.ts".source = ./_ai-tools/review.ts; + ".pi/agent/extensions/session-name.ts".source = ./_ai-tools/session-name.ts; ".pi/agent/skills/elixir-dev" = { source = "${inputs.pi-elixir}/skills/elixir-dev"; recursive = true; @@ -51,6 +52,13 @@ prompts = []; themes = []; } + { + source = "${inputs.pi-harness}"; + extensions = ["extensions/breadcrumbs/index.ts"]; + skills = []; + prompts = []; + themes = []; + } ]; }; ".pi/agent/mcp.json".text = diff --git a/modules/dendritic.nix b/modules/dendritic.nix index b3a10ec..dbdaa7a 100644 --- a/modules/dendritic.nix +++ b/modules/dendritic.nix @@ -66,6 +66,10 @@ url = "github:zenobi-us/pi-rose-pine"; flake = false; }; + pi-harness = { + url = "github:aliou/pi-harness"; + flake = false; + }; # Overlay inputs himalaya.url = "github:pimalaya/himalaya"; jj-ryu = {