aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/assets/pi
diff options
context:
space:
mode:
Diffstat (limited to 'crates/dinggerd/assets/pi')
-rw-r--r--crates/dinggerd/assets/pi/pi-agent-state.ts335
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();
});