aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-11-05 20:22:02 +0200
committerIgor Soarez <igor@soarez.org>2026-11-05 20:22:02 +0200
commit93cdec89edb819984c475c60dcbad51293339e77 (patch)
tree942f6a05654843d62015e968986a2c0ba3d238d9
parent9575ead9c637a2dc482c00f417a1d784d46e076a (diff)
name pi sessions automatically, replacing the pi-auto-session-name package
-rw-r--r--README.md14
-rw-r--r--crates/dinggerd/assets/pi/pi-agent-state.ts229
-rwxr-xr-xtests/fake-model.py13
-rwxr-xr-xtests/pi-live.sh42
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 <name>`) 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<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;
}
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 <the
+latest user message>". 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 <your latest message>".)
+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 <name>: 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