From 93cdec89edb819984c475c60dcbad51293339e77 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Thu, 5 Nov 2026 20:22:02 +0200 Subject: name pi sessions automatically, replacing the pi-auto-session-name package --- README.md | 14 +- crates/dinggerd/assets/pi/pi-agent-state.ts | 229 ++++++++++++++++++++++++++++ tests/fake-model.py | 13 +- tests/pi-live.sh | 42 ++++- 4 files changed, 293 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c3dd806..81f859e 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,16 @@ than a poll is still a completion. after compacting pi does not know it until the next reply, and neither does dingger (no meter). Agents on an older extension show none until they reload. +- **Session names** (pi): every pi session gets a short name of what it is + about, from the model, after its first run settles; it is refreshed + every 8 of your messages, and an old unnamed session is named when you + resume it. A name you give (`/name`, `pi --name`, `/autoname `) is + never replaced; `/autoname` alone renames now. It runs in every pi + session (print runs and outside tmux too) and costs one short model call + per naming; `PI_AUTONAME_MODEL=provider/model-id` picks a cheaper model. + It replaces the `pi-auto-session-name` package (remove it: `pi remove + git:github.com/Optomatica/pi-auto-session-name`), and keeps the names + that package gave automatic. - **Forgotten drafts** (pi): an agent with text left in its input box shows `✎` before its name wherever you are not looking at it (where you look, you may be typing it). Idle agents with one come first among idle agents; @@ -298,7 +308,9 @@ own pi agent directory: no network, no credentials, your pi untouched. It covers identification, the extension's state and session reports, a turn, `/reload`, an extension's blocked event, dialogs (blocked during a run, nothing while idle), a cancelled run (idle, not done), a failed run (done, -with why; cleared when seen or run again), screen detection +with why; cleared when seen or run again), names (the session's, not the +terminal title's; named after the first run, renamed every 8 messages, +yours kept, print runs and resumed old sessions named), screen detection without the extension, restore, reloading an agent on an old extension (and refusing while busy or with a draft), drafts (reported, cleared, kept across a diff --git a/crates/dinggerd/assets/pi/pi-agent-state.ts b/crates/dinggerd/assets/pi/pi-agent-state.ts index 0575ba9..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"; @@ -252,7 +253,235 @@ async function drainStateQueue(): Promise { } } +// ---- 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 ) 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.", + "", + "", + ...excerpt(said).map((one) => `${one.role === "user" ? "User" : "Assistant"}: ${one.text}`), + "", + ].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 { + 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((_, 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 { + 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) => (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; } diff --git a/tests/fake-model.py b/tests/fake-model.py index 98a61db..a00ccb2 100755 --- a/tests/fake-model.py +++ b/tests/fake-model.py @@ -2,7 +2,8 @@ """A fake OpenAI-compatible model for tests/pi-live.sh: streams a short reply to /v1/chat/completions, after a delay taken from the last user message ("slow 3" waits 3 seconds), or fails as a provider does ("fail 401": an -authentication error). Prints its port, then serves until killed.""" +authentication error). Asked to name a session, it answers "About ". Prints its port, then serves until killed.""" import json import re import sys @@ -33,6 +34,10 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self): body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}") text = last_user_text(body) + if "Name this coding-agent session" in text: + said = re.findall(r"^User: (.*)$", text, re.M) + self.reply("About " + (said[-1][:40] if said else "nothing")) + return match = re.search(r"slow (\d+(?:\.\d+)?)", text) time.sleep(float(match.group(1)) if match else 0.2) failure = re.search(r"fail (\d{3})", text) @@ -43,6 +48,9 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(error.encode()) return + self.reply("Done: fake reply.") + + def reply(self, text): self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-cache") @@ -56,8 +64,7 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(f"data: {json.dumps(data)}\n\n".encode()) self.wfile.flush() - chunk({"role": "assistant", "content": "Done: "}) - chunk({"content": "fake reply."}) + chunk({"role": "assistant", "content": text}) chunk({}, "stop", {"prompt_tokens": 10, "completion_tokens": 3, "total_tokens": 13}) self.wfile.write(b"data: [DONE]\n\n") self.wfile.flush() diff --git a/tests/pi-live.sh b/tests/pi-live.sh index 3c8604d..c2d2db7 100755 --- a/tests/pi-live.sh +++ b/tests/pi-live.sh @@ -317,6 +317,46 @@ expect "a title written over keeps the name" name "Find the sidecar" expect "the menu shows it" "\"$MENU\" --print | grep -c 'Find the sidecar'" 1 t kill-window -t main:named +# Session names: from the model after the first run, refreshed every 8 of +# your messages; a name you give is kept. (The fake model names a session +# "About ".) +t new-window -d -t main: -n auto -c "$D/work3" +t send-keys -t main:auto "pi $NO_MCP" Enter +expect "naming: an agent of its own" "st auto" idle 150 +an() { t show -pv -t main:auto @agent_name 2>/dev/null; } +said() { grep -c "\"role\":\"$1\"" "$(t show -pv -t main:auto @agent_session)" 2>/dev/null; } +say() { # message count: send it, wait for its reply + type_line auto "$1" + expect "reply $2" "said assistant" "$2" 80 +} +expect "before any message: no name" an "" +say m1 1 +expect "named after its first run" an "About m1" 80 +for i in 2 3 4 5 6 7 8; do say "m$i" $i; done +expect "not renamed within 8 of your messages" an "About m1" 30 +say m9 9 +expect "renamed at the 8th since" an "About m9" 80 +say m10 10 +type_line auto "/autoname" +expect "/autoname: renamed now" an "About m10" 80 +type_line auto "/autoname Mine now" +expect "/autoname : yours" an "Mine now" 50 +for i in 11 12 13 14 15 16 17 18; do say "m$i" $i; done +sleep 2 +expect "and kept past 8 more" an "Mine now" +t kill-window -t main:auto +# A print run names its session too, before pi exits. +(cd "$D/work3" && pi -p "print me" $NO_MCP >"$D/print.out" 2>&1) +expect "a print run is named" "grep -l '\"name\":\"About print me\"' \$(ls -t \"$D\"/agent/sessions/*work3*/*.jsonl | head -1) | wc -l | tr -d ' '" 1 +# An old session never named is named when resumed. +(cd "$D/work3" && pi --no-extensions -p "an old one" $NO_MCP >"$D/old.out" 2>&1) +OLD=$(ls -t "$D"/agent/sessions/*work3*/*.jsonl | head -1) +[ "$(grep -c '"type":"session_info"' "$OLD")" = 0 ] && pass "without the extension: unnamed" || fail "the old session has a name" +t new-window -d -t main: -n old -c "$D/work3" +t send-keys -t main:old "pi --session '$OLD' $NO_MCP" Enter +expect "resumed: named from its history" "t show -pv -t main:old @agent_name 2>/dev/null" "About an old one" 150 +t kill-window -t main:old + echo "2. screen detection (pi without the extension)" t send-keys -t main:plain "PI_CODING_AGENT_DIR=$D/agent-plain pi $NO_MCP" Enter expect "identified as pi" "t show -pv -t main:plain @agent 2>/dev/null" pi 150 @@ -341,7 +381,7 @@ expect "restore resumes it by its session file" "grep -c -- \"resume .*pi --sess expect "resumed 1 of 1 agents" "grep -c '^resumed 1 of 1 agents' $D/restore.out" 1 expect "it runs again, reporting the same session" "t show -pv -t main:agent @agent_session 2>/dev/null" "$SESSION" 150 # Its last message: the first may have scrolled off a long conversation. -expect "with its conversation" "t capture-pane -p -t main:agent -S - | grep -c 'slow 1 after\$'" 1 50 +expect "with its conversation" "t capture-pane -p -t main:agent -S - | grep -cE '^ *slow 1 after\$'" 1 50 expect "and its importance" "t show -pv -t main:agent @dingger_importance 2>/dev/null" high 50 expect "and its draft, back in the input box" "t capture-pane -p -t main:agent | grep -c '^an unsent idea;'" 1 50 expect "reported again" "t show -pv -t main:agent @dingger_draft 2>/dev/null" "an unsent idea;" 30 -- cgit v1.2.3