diff options
Diffstat (limited to 'crates/dinggerd/assets/pi')
| -rw-r--r-- | crates/dinggerd/assets/pi/pi-agent-state.ts | 335 |
1 files changed, 325 insertions, 10 deletions
diff --git a/crates/dinggerd/assets/pi/pi-agent-state.ts b/crates/dinggerd/assets/pi/pi-agent-state.ts index fc70638..d86f812 100644 --- a/crates/dinggerd/assets/pi/pi-agent-state.ts +++ b/crates/dinggerd/assets/pi/pi-agent-state.ts @@ -2,6 +2,7 @@ // managed by dingger; reinstalling or updating the integration overwrites this file. // add custom hooks/plugins beside this file instead of editing it. +import crypto from "node:crypto"; import fs from "node:fs"; import net from "node:net"; import path from "node:path"; @@ -119,6 +120,9 @@ let draft = ""; // How full the context window is, in percent, as pi's footer shows it; // "" when unknown (right after compacting, before the next reply). let context = ""; +// The session's name ("" for none): dingger shows it as the agent's name, +// whatever the terminal title says. +let sessionName = ""; const draftDir = path.join(path.dirname(socketPath), "drafts"); // Enough to find the agent by it; the file keeps all of it. const reportedDraftChars = 500; @@ -167,7 +171,34 @@ function requestId(): string { return `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`; } -async function report(state: AgentState | undefined, message?: string): Promise<void> { +/** How a run ended, sent with the idle report that ends it. */ +type RunEnd = { aborted?: boolean; error?: string }; + +/** + * A provider error as one short readable line: the message inside a JSON + * body ("401: invalid x-api-key"), else the first line. + */ +export function readableError(text: string): string { + const first = text.split("\n").find((line) => line.trim()) ?? ""; + let line = first; + const json = first.indexOf("{"); + if (json >= 0) { + try { + const body = JSON.parse(first.slice(json)); + const message = body?.error?.message ?? body?.message ?? body?.detail ?? body?.error; + if (typeof message === "string" && message.trim()) { + const status = first.slice(0, json).replace(/[\s:]+$/, ""); + line = status ? `${status}: ${message}` : message; + } + } catch { + // Not JSON after all. + } + } + line = line.replace(/\s+/g, " ").trim(); + return line.length > 100 ? `${line.slice(0, 99)}…` : line || "error"; +} + +async function report(state: AgentState | undefined, message?: string, end: RunEnd = {}): Promise<void> { await sendRequest({ id: requestId(), method: "pane.report_agent", @@ -177,11 +208,15 @@ async function report(state: AgentState | undefined, message?: string): Promise< agent: "pi", ...(state ? { state } : {}), message, + // The run this idle report ends was cancelled (not done), or failed. + ...(end.aborted ? { aborted: true } : {}), + ...(end.error ? { error: end.error } : {}), importance, extension: extensionVersion, ...(controlListening ? { control: controlPath } : {}), draft: draft.slice(0, reportedDraftChars), context, + name: sessionName, seq: Date.now() * 1000, ...(sessionFile ? { agent_session_path: sessionFile } : {}), }, @@ -189,10 +224,10 @@ async function report(state: AgentState | undefined, message?: string): Promise< } let sendInFlight = false; -let queuedState: { state: AgentState; message?: string } | undefined; +let queuedState: { state: AgentState; message?: string; end: RunEnd } | undefined; -function queueState(state: AgentState, message?: string): void { - queuedState = { state, message }; +function queueState(state: AgentState, message?: string, end: RunEnd = {}): void { + queuedState = { state, message, end }; if (!sendInFlight) { void drainStateQueue(); } @@ -208,7 +243,7 @@ async function drainStateQueue(): Promise<void> { while (queuedState) { const next = queuedState; queuedState = undefined; - await report(next.state, next.message); + await report(next.state, next.message, next.end); } } finally { sendInFlight = false; @@ -218,7 +253,235 @@ async function drainStateQueue(): Promise<void> { } } +// ---- Session names ----------------------------------------------------- +// +// Every session gets a short name of what it is about, from the model, +// after its first run settles, refreshed every few of your messages, and +// for an old unnamed session when you resume it. A name you give it +// (/name, `pi --name`, /autoname <name>) is never replaced. This runs in +// every pi session (print and JSON runs too, outside tmux too), not only +// where dingger watches. PI_AUTONAME_MODEL="provider/model-id" picks a +// (cheaper) model; the session's own model otherwise. +// +// It replaces the pi-auto-session-name package and reads the marker that +// package saved in sessions, so names it gave stay automatic. + +/** A session entry recording an automatic name, and how many of your messages it covered. */ +const autoNameEntry = "auto-session-name"; +const nameChars = 100; +const renameEvery = 8; +const namePromptChars = 4000; +const nameMessageChars = 350; +const nameTimeoutMs = 30_000; + +type Said = { role: "user" | "assistant"; text: string }; + +/** Message text without injected context blocks, on one line. */ +function plainText(content: unknown): string { + const text = + typeof content === "string" + ? content + : Array.isArray(content) + ? content + .filter((part) => part?.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join("\n") + : ""; + return text + .replace(/<(system-reminder|selected-context|environment_context)>[\s\S]*?<\/\1>/gi, "") + .replace(/\s+/g, " ") + .trim(); +} + +/** At most `max` characters, cut at a word when one is near. */ +function clip(text: string, max: number): string { + if (text.length <= max) { + return text; + } + const cut = text.slice(0, max); + const space = cut.lastIndexOf(" "); + return (space > max / 2 ? cut.slice(0, space) : cut).trim(); +} + +/** What was said on the session's branch. */ +function conversation(entries: any[]): Said[] { + return entries + .filter((entry) => entry?.type === "message" && ["user", "assistant"].includes(entry.message?.role)) + .map((entry) => ({ role: entry.message.role, text: plainText(entry.message.content) })) + .filter((said) => said.text); +} + +/** The first message (what it set out to do) and the latest ones, within budget. */ +function excerpt(said: Said[]): Said[] { + const short = (one: Said) => ({ role: one.role, text: clip(one.text, nameMessageChars) }); + const first = short(said[0]); + const latest: Said[] = []; + let used = first.text.length; + for (const one of said.slice(1).reverse()) { + const next = short(one); + if (used + next.text.length > namePromptChars || latest.length >= 8) { + break; + } + latest.unshift(next); + used += next.text.length; + } + return [first, ...latest]; +} + +export function namePrompt(said: Said[]): string { + return [ + "Name this coding-agent session for a list of sessions.", + "Answer with the name alone: one line of at most 14 words, without quotes or a final period.", + 'Say what the task or topic is, concretely (like "Fix the sidecar\'s reconnect after a daemon restart").', + "Write it in the conversation's language.", + "", + "<conversation>", + ...excerpt(said).map((one) => `${one.role === "user" ? "User" : "Assistant"}: ${one.text}`), + "</conversation>", + ].join("\n"); +} + +/** A model's answer as a name: one line, no quotes or label, no final punctuation. */ +export function nameFrom(answer: string): string { + const name = plainText(answer) + .replace(/^(title|name):\s*/i, "") + .replace(/^["'`“”«»]+|["'`“”«»]+$/g, "") + .replace(/[.!?]+$/, "") + .trim(); + return clip(name, nameChars); +} + +function autoName(pi) { + let yours = false; // you named it: never rename + let given: string | undefined; // the name this gave it + let namedAt = 0; // your messages when it was named + let pending: string | undefined; // a name just set, until pi says so + let naming = false; + + function nameModel(ctx) { + const spec = process.env.PI_AUTONAME_MODEL?.trim() ?? ""; + const slash = spec.indexOf("/"); + const chosen = slash > 0 ? ctx.modelRegistry.find(spec.slice(0, slash), spec.slice(slash + 1)) : undefined; + return [chosen, ctx.model].find((model) => model && ctx.modelRegistry.hasConfiguredAuth(model)); + } + + async function ask(ctx, said: Said[]): Promise<string> { + const model = nameModel(ctx); + if (model) { + try { + const call = ctx.modelRegistry.complete( + model, + { messages: [{ role: "user", content: [{ type: "text", text: namePrompt(said) }], timestamp: Date.now() }] }, + { reasoningEffort: "low", cacheRetention: "none", sessionId: crypto.randomUUID() }, + ); + call.catch(() => {}); + const timeout = new Promise<never>((_, reject) => setTimeout(() => reject(new Error("timeout")), nameTimeoutMs).unref?.()); + const answer = await Promise.race([call, timeout]); + const name = nameFrom( + (answer?.content ?? []).filter((block) => block.type === "text").map((block) => block.text).join(" "), + ); + if (name.length >= 3) { + return name; + } + } catch { + // The first message, then. + } + } + return clip(said.find((one) => one.role === "user")?.text ?? "", nameChars); + } + + async function nameSession(ctx, now = false): Promise<void> { + if (naming || !ctx.sessionManager.getSessionFile()) { + return; + } + const said = conversation(ctx.sessionManager.getBranch()); + const yourMessages = said.filter((one) => one.role === "user").length; + if (yourMessages === 0 || (yours && !now)) { + return; + } + if (!now && namedAt > 0 && yourMessages - namedAt < renameEvery) { + return; + } + naming = true; + try { + const name = await ask(ctx, said); + if (!name || (yours && !now)) { + return; + } + pending = name; + given = name; + namedAt = yourMessages; + pi.setSessionName(name); + pi.appendEntry(autoNameEntry, { name, count: yourMessages }); + if (ctx.hasUI) { + ctx.ui.notify(`Session named: ${name}`, "info"); + } + } catch { + // Naming never breaks a session. + } finally { + naming = false; + } + } + + // In print and JSON runs, before pi exits. + const finish = (ctx, naming: Promise<void>) => (ctx.mode === "print" || ctx.mode === "json" ? naming : undefined); + + pi.on("session_start", async (_event, ctx) => { + yours = false; + given = undefined; + namedAt = 0; + pending = undefined; + naming = false; + for (const entry of ctx.sessionManager.getEntries()) { + if (entry?.type === "custom" && entry.customType === autoNameEntry && entry.data) { + given = typeof entry.data.name === "string" ? entry.data.name : given; + namedAt = typeof entry.data.count === "number" ? entry.data.count : namedAt; + } + } + const current = pi.getSessionName?.(); + yours = !!current && current !== given; + // An old session never named: name it now. + if (!yours && namedAt === 0) { + await finish(ctx, nameSession(ctx).catch(() => {})); + } + }); + + pi.on("session_info_changed", (event) => { + if (pending !== undefined && event?.name === pending) { + pending = undefined; + return; + } + // Cleared: name it again; anything else is yours. + yours = event?.name !== undefined; + }); + + pi.on("agent_settled", async (_event, ctx) => { + await finish(ctx, nameSession(ctx).catch(() => {})); + }); + + pi.registerCommand("autoname", { + description: "Name this session now, or give it a name: /autoname [name]", + handler: async (args, ctx) => { + const name = (args ?? "").trim(); + if (name) { + yours = true; + pi.setSessionName(name); + if (ctx.hasUI) { + ctx.ui.notify(`Session named: ${name}`, "info"); + } + return; + } + if (ctx.hasUI) { + ctx.ui.notify("Naming this session…", "info"); + } + await nameSession(ctx, true); + }, + }); +} + export default function (pi) { + // Everywhere pi runs, not only in tmux. + autoName(pi); if (!enabled()) { return; } @@ -239,16 +502,56 @@ export default function (pi) { return { state: "idle" as const, message: undefined }; } - function publishState(force = false) { + function publishState(force = false, end: RunEnd = {}) { const next = desiredState(); if (!force && next.state === lastState && next.message === lastMessage) { return; } lastState = next.state; lastMessage = next.message; - queueState(next.state, next.message); + queueState(next.state, next.message, next.state === "idle" ? end : {}); } + // How the current run is ending: pi says before it settles (not for a + // cancelled run), and the last reply carries the error. + let runOutcome: string | undefined; + let replyError: string | undefined; + pi.on("agent_before_settle", (event) => { + runOutcome = event?.outcome; + }); + pi.on("message_end", (event) => { + const reply = event?.message; + if (reply?.role === "assistant") { + replyError = reply.stopReason === "error" ? (reply.errorMessage ?? "") : undefined; + } + }); + + // A dialog that opens during a run (an extension asking before it goes + // on): the agent waits on you, so it is blocked until the dialog closes. + // One opened while idle comes from what you just did, and changes nothing. + let dialogsBlocking = 0; + pi.on("ui_prompt_start", (event, ctx) => { + latestCtx = ctx; + if (!agentActive) { + return; + } + dialogsBlocking += 1; + blockedCount += 1; + blockedMessage = event?.title; + publishState(); + }); + pi.on("ui_prompt_end", () => { + if (dialogsBlocking === 0) { + return; + } + dialogsBlocking -= 1; + blockedCount = Math.max(0, blockedCount - 1); + if (blockedCount === 0) { + blockedMessage = undefined; + } + publishState(); + }); + pi.events.on("blocked", (data) => { if (!data?.active) { blockedCount = Math.max(0, blockedCount - 1); @@ -493,14 +796,17 @@ export default function (pi) { // Each session has its own: /new starts at normal, /resume brings its own back. importance = savedImportance(ctx); announceImportance(); - // The report below carries it. + // The reports below carry them. checkContext(ctx, true); + sessionName = pi.getSessionName?.() ?? ""; publishState(true); }); pi.on("agent_start", (_event, ctx) => { latestCtx = ctx; agentActive = true; + runOutcome = undefined; + replyError = undefined; updateSessionFile(ctx); publishState(); // The prompt left the input box. @@ -516,7 +822,13 @@ export default function (pi) { }); } - pi.on("agent_settled", (_event, ctx) => { + // /name, or a name given by an extension. + pi.on("session_info_changed", (event) => { + sessionName = event?.name ?? ""; + void report(undefined); + }); + + pi.on("agent_settled", (event, ctx) => { latestCtx = ctx; if (ctx?.isIdle?.() !== true) { return; @@ -525,7 +837,10 @@ export default function (pi) { agentActive = false; // The last reply's usage: message_end comes before pi counts it. checkContext(ctx); - publishState(); + // A cancelled run (Escape) is idle, not done; a failed one says why. + const aborted = event?.aborted === true; + const error = !aborted && runOutcome === "error" ? readableError(replyError ?? "") : undefined; + publishState(false, { aborted, error }); // Escape puts queued messages back into the input box. checkDraftSoon(); }); |
