@fyeeme/pi-session-name 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.0.1] - 2025-07-15
11
+
12
+ ### Fixed
13
+
14
+ - Race condition where auto-generated title could overwrite manual rename when `generateTitle` async call completes after user renamed the session
15
+
16
+ ### Added
17
+
18
+ - `/rename [name]` command: manually rename the current session on demand. With
19
+ a name argument it sets that name; with no argument it auto-generates one from
20
+ the conversation. Invoking it locks out background auto-naming (manual
21
+ control), consistent with `/name`.
22
+
23
+ ### Changed
24
+
25
+ - Title-generation prompt now favors descriptive titles (key entity + action +
26
+ goal, ~15-40 characters) over terse labels. Previously the prompt emphasized
27
+ "short/concise", which produced overly brief session names.
28
+
29
+ ## [1.0.0] - 2026-07-20
30
+
31
+ ### Added
32
+
33
+ - Initial release
34
+ - Auto-name pi sessions with a short LLM-generated title on first `agent_settled`
35
+ - `first` mode (default): name once, never overwrite
36
+ - `auto` mode: re-evaluate each turn, rename when the topic drifts (LLM returns `KEEP` or a new title)
37
+ - Never overwrites manual names (`/name`, `--name`, RPC, other extensions)
38
+ - Title follows the language of the user's first message
39
+ - Config via `.pi/session-name.json` and `PI_SESSION_NAME_*` env vars
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fyeeme
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # pi-session-name
2
+
3
+ Auto-name your [pi](https://pi.dev) sessions with a short, LLM-generated title so
4
+ `pi --resume` lists are easy to scan — instead of the raw first message.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ # Global (user) install
10
+ pi install npm:@fyeeme/pi-session-name
11
+
12
+ # Project-local (.pi/settings.json)
13
+ pi install -l npm:@fyeeme/pi-session-name
14
+
15
+ # Try once without saving
16
+ pi -e npm:@fyeeme/pi-session-name
17
+ ```
18
+
19
+ Requires pi `>= 0.80.0` (uses the `agent_settled` / `session_info_changed` events).
20
+
21
+ ## Behavior
22
+
23
+ - When the first agent run of a session **settles**, the extension asks the model
24
+ for a descriptive title (same language as your first message) — key entity +
25
+ action + goal, not a terse label — and writes it via `setSessionName`. It then
26
+ shows up in the resume picker immediately.
27
+ - **Never overwrites a name you set manually** (`/name`, `--name`, the picker's
28
+ rename, or any other extension). Once it detects an external rename it locks
29
+ itself for the rest of the session.
30
+ - Two modes (config `mode`):
31
+ - `first` (default) — name once, then leave it alone.
32
+ - `auto` — re-evaluate each turn; the model replies `KEEP` or a new title, so
33
+ the name tracks the current topic.
34
+ - **`/rename [name]`** — manually rename the current session on demand. With a
35
+ name argument it sets that name; with no argument it generates one from the
36
+ conversation (same descriptive prompt as auto-naming). Invoking `/rename` is a
37
+ manual action, so it locks out background auto-naming for the rest of the
38
+ session — you've taken control.
39
+
40
+ ## Configuration
41
+
42
+ Create `.pi/session-name.json` in your project root:
43
+
44
+ ```json
45
+ { "mode": "auto", "maxLength": 200, "model": { "provider": "openai", "id": "gpt-4o-mini" } }
46
+ ```
47
+
48
+ | Option | Default | Env override | Notes |
49
+ |---|---|---|---|
50
+ | `mode` | `"first"` | `PI_SESSION_NAME_MODE` | `first` \| `auto` |
51
+ | `maxLength` | `200` | `PI_SESSION_NAME_MAX_LENGTH` | Title character cap |
52
+ | `enabled` | `true` | `PI_SESSION_NAME_ENABLED=false` | Master switch |
53
+ | `model` | current session model | `PI_SESSION_NAME_MODEL_PROVIDER` + `PI_SESSION_NAME_MODEL_ID` | Override the model used to generate titles |
54
+
55
+ By default the extension uses the model you're already chatting with (`ctx.model`),
56
+ so no extra API key is needed.
57
+
58
+ ## Failure handling
59
+
60
+ If the model is unavailable, has no API key, or returns garbage, the extension
61
+ stays silent — your session is never blocked. It retries on the next turn while
62
+ the session is still unnamed.
63
+
64
+ ## Manual smoke test
65
+
66
+ 1. `pi -e ./packages/extensions/pi-session-name/index.ts`, ask a question in a
67
+ fresh session, wait for the reply → the resume picker shows a generated title.
68
+ 2. `/name foo`, chat a few turns → name stays `foo` (locked).
69
+ 3. `mode: "auto"`: shift topic across turns → name updates; same topic → stays.
70
+ 4. `/rename foo` → name becomes `foo` and stays (locked). `/rename` with no
71
+ argument → generates a fresh name from the conversation.
72
+ 5. Unset the model's API key → no errors, session runs normally.
package/index.ts ADDED
@@ -0,0 +1,292 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type { Model } from "@earendil-works/pi-ai";
5
+ import { complete } from "@earendil-works/pi-ai/compat";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Types & helpers for buildConversationText
9
+ // ---------------------------------------------------------------------------
10
+
11
+ type ContentBlock = { type?: string; text?: string };
12
+ export type SessionEntry = { type: string; message?: { role?: string; content?: unknown } };
13
+
14
+ const extractText = (content: unknown): string[] => {
15
+ if (typeof content === "string") return [content];
16
+ if (!Array.isArray(content)) return [];
17
+ const parts: string[] = [];
18
+ for (const part of content) {
19
+ if (part && typeof part === "object") {
20
+ const b = part as ContentBlock;
21
+ if (b.type === "text" && typeof b.text === "string") parts.push(b.text);
22
+ }
23
+ }
24
+ return parts;
25
+ };
26
+
27
+ const truncate = (s: string, max: number): string => (s.length <= max ? s : `${s.slice(0, max)}\u2026`);
28
+
29
+ export function buildConversationText(
30
+ entries: SessionEntry[],
31
+ opts: { maxMessages?: number; maxCharsPerMessage?: number } = {},
32
+ ): string {
33
+ const maxMessages = opts.maxMessages ?? 8;
34
+ const maxCharsPerMessage = opts.maxCharsPerMessage ?? 600;
35
+ const msgs = entries
36
+ .filter((e) => e.type === "message" && (e.message?.role === "user" || e.message?.role === "assistant"))
37
+ .map((e) => ({
38
+ role: e.message!.role as "user" | "assistant",
39
+ text: extractText(e.message!.content).join("\n").trim(),
40
+ }))
41
+ .filter((m) => m.text.length > 0);
42
+ if (msgs.length === 0) return "";
43
+ const selected = msgs.length > maxMessages ? [msgs[0], ...msgs.slice(-(maxMessages - 1))] : msgs;
44
+ return selected
45
+ .map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${truncate(m.text, maxCharsPerMessage)}`)
46
+ .join("\n\n");
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // cleanTitle — strip wrapping quotes/brackets, collapse whitespace, truncate
51
+ // ---------------------------------------------------------------------------
52
+
53
+ export function cleanTitle(raw: string, maxLength: number = 200): string | null {
54
+ if (!raw) return null;
55
+ let t = raw.trim();
56
+ t = t.replace(/^["'`\u300C\u300E\uFF08(\[]+|["'`\u300D\u300F\uFF09)\].]+$/g, "").trim();
57
+ t = t.replace(/\s+/g, " ");
58
+ t = t.replace(/[.\u3002!\uFF01?\uFF1F]+$/g, "");
59
+ if (!t) return null;
60
+ if (t.length > maxLength) t = t.slice(0, maxLength).trim();
61
+ return t.length > 0 ? t : null;
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Prompt builders — first-title & auto-rename prompts
66
+ // ---------------------------------------------------------------------------
67
+
68
+ export function buildFirstPrompt(
69
+ conversationText: string,
70
+ opts: { maxLength?: number } = {},
71
+ ): string {
72
+ const maxLength = opts.maxLength ?? 200;
73
+ return [
74
+ "You generate a descriptive title for this conversation so the user can find it later in a session list.",
75
+ "Rules:",
76
+ '- Output ONLY the title text. No quotes, no trailing punctuation, no explanation.',
77
+ "- Use the SAME language as the user's first message.",
78
+ '- Be descriptive, not terse: include the key entity (class, component, or concept), the action, and the goal — not a vague category.',
79
+ `- Aim for roughly 15-40 characters; never exceed ${maxLength} characters.`,
80
+ "",
81
+ "<conversation>",
82
+ conversationText,
83
+ "</conversation>",
84
+ ].join("\n");
85
+ }
86
+
87
+ export function buildAutoPrompt(
88
+ currentName: string,
89
+ conversationText: string,
90
+ opts: { maxLength?: number } = {},
91
+ ): string {
92
+ const maxLength = opts.maxLength ?? 200;
93
+ return [
94
+ "You decide whether the session title still matches the conversation.",
95
+ `- Current title: ${currentName}`,
96
+ "If the title is still accurate, reply with exactly: KEEP",
97
+ "If it is inaccurate or too vague now, output a NEW descriptive title.",
98
+ "Rules for a new title:",
99
+ '- ONLY the title text. No quotes, no trailing punctuation, no explanation.',
100
+ "- Use the SAME language as the user's first message.",
101
+ '- Be descriptive, not terse: include the key entity (class, component, or concept), the action, and the goal — not a vague category.',
102
+ `- Aim for roughly 15-40 characters; never exceed ${maxLength} characters.`,
103
+ "",
104
+ "<conversation>",
105
+ conversationText,
106
+ "</conversation>",
107
+ ].join("\n");
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // SessionNameConfig + loadConfig
112
+ // ---------------------------------------------------------------------------
113
+
114
+ export interface SessionNameConfig {
115
+ mode: "first" | "auto";
116
+ enabled: boolean;
117
+ maxLength: number;
118
+ model?: { provider: string; id: string };
119
+ }
120
+
121
+ const DEFAULT_CONFIG: SessionNameConfig = { mode: "first", enabled: true, maxLength: 200 };
122
+
123
+ export function loadConfig(cwd: string, env: Record<string, string | undefined> = process.env): SessionNameConfig {
124
+ let fileCfg: Partial<SessionNameConfig> = {};
125
+ const file = join(cwd, ".pi", "session-name.json");
126
+ try {
127
+ fileCfg = JSON.parse(readFileSync(file, "utf8")) as Partial<SessionNameConfig>;
128
+ } catch {
129
+ // missing or malformed config — ignore, fall back to defaults
130
+ }
131
+ const cfg: SessionNameConfig = { ...DEFAULT_CONFIG, ...fileCfg };
132
+ if (env.PI_SESSION_NAME_MODE === "first" || env.PI_SESSION_NAME_MODE === "auto") cfg.mode = env.PI_SESSION_NAME_MODE;
133
+ if (env.PI_SESSION_NAME_ENABLED === "false") cfg.enabled = false;
134
+ if (env.PI_SESSION_NAME_MAX_LENGTH) {
135
+ const n = Number(env.PI_SESSION_NAME_MAX_LENGTH);
136
+ if (Number.isFinite(n) && n > 0) cfg.maxLength = n;
137
+ }
138
+ const provider = env.PI_SESSION_NAME_MODEL_PROVIDER;
139
+ const id = env.PI_SESSION_NAME_MODEL_ID;
140
+ if (provider && id) cfg.model = { provider, id };
141
+ return cfg;
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // ModelAuth + resolveModelAndAuth
146
+ // ---------------------------------------------------------------------------
147
+
148
+ export type ModelAuth = { model: Model<any>; apiKey: string; headers: Record<string, string> | undefined };
149
+
150
+ export async function resolveModelAndAuth(
151
+ ctx: ExtensionContext,
152
+ cfg: SessionNameConfig,
153
+ getModelFn?: (provider: string, id: string) => Model<any> | undefined,
154
+ ): Promise<ModelAuth | null> {
155
+ const find = getModelFn ?? ((p: string, i: string) => ctx.modelRegistry.find(p, i) as Model<any> | undefined);
156
+ const model = cfg.model ? find(cfg.model.provider, cfg.model.id) : ctx.model;
157
+ if (!model) return null;
158
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
159
+ if (!auth?.ok || !auth.apiKey) return null;
160
+ return { model, apiKey: auth.apiKey, headers: auth.headers };
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // generateTitle — call LLM to produce a short title
165
+ // ---------------------------------------------------------------------------
166
+
167
+ export async function generateTitle(
168
+ prompt: string,
169
+ auth: ModelAuth,
170
+ completeFn: typeof complete = complete,
171
+ ): Promise<string> {
172
+ const response = await completeFn(
173
+ auth.model,
174
+ {
175
+ messages: [
176
+ { role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() },
177
+ ],
178
+ },
179
+ { apiKey: auth.apiKey, headers: auth.headers },
180
+ );
181
+ return response.content
182
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
183
+ .map((c) => c.text)
184
+ .join("\n")
185
+ .trim();
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Pi extension entry point
190
+ // ---------------------------------------------------------------------------
191
+
192
+ export default function (pi: ExtensionAPI): void {
193
+ let manuallyLocked = false;
194
+ let inFlight = false;
195
+ let lastAutoName: string | undefined;
196
+ let cfg: SessionNameConfig | null = null;
197
+
198
+ pi.on("session_start", () => {
199
+ inFlight = false;
200
+ lastAutoName = undefined;
201
+ cfg = null;
202
+ manuallyLocked = !!pi.getSessionName();
203
+ });
204
+
205
+ pi.on("session_info_changed", (event) => {
206
+ if (event.name === lastAutoName) return;
207
+ manuallyLocked = true;
208
+ });
209
+
210
+ pi.on("agent_settled", async (_event, ctx) => {
211
+ if (cfg === null) cfg = loadConfig(ctx.cwd);
212
+ if (!cfg.enabled || manuallyLocked || inFlight) return;
213
+
214
+ const currentName = pi.getSessionName();
215
+ if (cfg.mode === "first" && (lastAutoName !== undefined || currentName)) return;
216
+
217
+ inFlight = true;
218
+ try {
219
+ const auth = await resolveModelAndAuth(ctx, cfg);
220
+ if (!auth) return;
221
+
222
+ const text = buildConversationText(ctx.sessionManager.getBranch());
223
+ if (!text.trim()) return;
224
+
225
+ let title: string | null;
226
+ if (cfg.mode === "first" || !currentName) {
227
+ title = cleanTitle(await generateTitle(buildFirstPrompt(text, cfg), auth), cfg.maxLength);
228
+ } else {
229
+ const verdict = await generateTitle(buildAutoPrompt(currentName, text, cfg), auth);
230
+ title = /^keep$/i.test(verdict.trim()) ? null : cleanTitle(verdict, cfg.maxLength);
231
+ }
232
+ if (!title) return;
233
+
234
+ // Re-check after async gap: manual rename or external setSessionName
235
+ // may have fired while generateTitle was in flight.
236
+ if (manuallyLocked || pi.getSessionName() !== currentName) return;
237
+
238
+ lastAutoName = title;
239
+ pi.setSessionName(title);
240
+ } catch {
241
+ // silent: failure does not block the session; first mode retries next round, auto mode skips this round
242
+ } finally {
243
+ inFlight = false;
244
+ }
245
+ });
246
+
247
+ pi.registerCommand("rename", {
248
+ description: "Rename this session. Pass a name, or leave empty to auto-generate one from the conversation.",
249
+ handler: async (args, ctx) => {
250
+ // /rename is a manual action: take control and stop background auto-naming
251
+ manuallyLocked = true;
252
+ const cfg = loadConfig(ctx.cwd);
253
+ const name = args.trim();
254
+
255
+ if (name) {
256
+ const cleaned = cleanTitle(name, cfg.maxLength);
257
+ if (!cleaned) {
258
+ ctx.ui.notify("Invalid name", "warning");
259
+ return;
260
+ }
261
+ lastAutoName = cleaned;
262
+ pi.setSessionName(cleaned);
263
+ ctx.ui.notify(`Renamed to: ${cleaned}`, "info");
264
+ return;
265
+ }
266
+
267
+ // no argument → generate a name from the conversation
268
+ const auth = await resolveModelAndAuth(ctx, cfg);
269
+ if (!auth) {
270
+ ctx.ui.notify("Cannot generate a name: model unavailable or no API key", "warning");
271
+ return;
272
+ }
273
+ const text = buildConversationText(ctx.sessionManager.getBranch());
274
+ if (!text.trim()) {
275
+ ctx.ui.notify("No conversation to generate a name from yet", "warning");
276
+ return;
277
+ }
278
+ try {
279
+ const title = cleanTitle(await generateTitle(buildFirstPrompt(text, cfg), auth), cfg.maxLength);
280
+ if (!title) {
281
+ ctx.ui.notify("Could not generate a name from the model response", "warning");
282
+ return;
283
+ }
284
+ lastAutoName = title;
285
+ pi.setSessionName(title);
286
+ ctx.ui.notify(`Renamed to: ${title}`, "info");
287
+ } catch {
288
+ ctx.ui.notify("Failed to generate a name", "error");
289
+ }
290
+ },
291
+ });
292
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@fyeeme/pi-session-name",
3
+ "version": "1.0.1",
4
+ "description": "Auto-name pi sessions with a short LLM-generated title so --resume lists are easy to scan. Supports first (once) and auto (content-aware rename) modes; never overwrites manual names.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "fyeeme",
8
+ "publishConfig": { "access": "public" },
9
+ "repository": { "type": "git", "url": "https://github.com/fyeeme/pi-packages" },
10
+ "bugs": { "url": "https://github.com/fyeeme/pi-packages/issues" },
11
+ "homepage": "https://github.com/fyeeme/pi-packages#readme",
12
+ "engines": { "node": ">=18" },
13
+ "keywords": ["pi-package", "pi", "session", "auto-name", "rename", "resume"],
14
+ "files": ["index.ts", "README.md", "LICENSE", "CHANGELOG.md"],
15
+ "pi": { "extensions": ["./index.ts"] },
16
+ "scripts": { "test": "vitest --run", "typecheck": "tsc" },
17
+ "peerDependencies": {
18
+ "@earendil-works/pi-coding-agent": ">=0.80.0",
19
+ "@earendil-works/pi-ai": ">=0.80.0"
20
+ },
21
+ "devDependencies": {
22
+ "@earendil-works/pi-coding-agent": "0.80.10",
23
+ "@earendil-works/pi-ai": "0.80.10",
24
+ "@types/node": "22.19.19",
25
+ "typescript": "5.9.3",
26
+ "vitest": "3.2.7"
27
+ }
28
+ }