@fyeeme/pi-session-name 1.0.3 → 1.0.4

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 CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.4] - 2026-09-17
11
+
12
+ ### Added
13
+
14
+ - Conflict-aware naming: `collectRecentSessionTitles` / `parseSessionTitle` scan local sibling session files (newest first, 60s TTL cache) and inject the recent titles into both the first-title and auto-rename prompts, so a generated title never duplicates or rewords one already in the session list. The current session's file and its own name are excluded; any storage read failure degrades silently to no list.
15
+
16
+ ### Changed
17
+
18
+ - Prompt rewrite for distinctiveness: titles must lead with the concrete entity, error, or identifier instead of generic labels ("bug fix", "code review"); parentheses are banned from output; auto-mode titles now aim for 30-55 characters and prefer capturing the conversation's root cause or conclusion over staying short.
19
+ - README tip: in `first` mode the session is named after the first turn — switch to `"mode": "auto"` when the key point usually emerges only in later turns.
20
+
10
21
  ## [1.0.3] - 2026-09-09
11
22
 
12
23
  ### Changed
package/README.md CHANGED
@@ -12,7 +12,8 @@ Auto-name [pi](https://pi.dev) sessions with a short LLM-generated title so `--r
12
12
  - **Never overwrites manual names** — detects `/name`, `--name`, the resume picker's rename, or any other extension calling `setSessionName`, and locks itself for the rest of the session
13
13
  - **`/rename [name]`** — rename the current session on demand. With an argument it sets that name; without, it generates one from the conversation
14
14
  - **Language-aware** — titles use the same language as your first message
15
- - **Descriptive titles** — key entity + action + goal (~15-40 chars), not terse labels
15
+ - **Distinctive titles** — leads with the concrete entity/error/identifier, so similar sessions don't blur together (~15-40 chars)
16
+ - **Conflict-aware** — reads recent sibling session titles from local session storage and injects them into the prompt, so a new title never duplicates or rewords one already in the list
16
17
  - **Graceful failure** — model unavailable or no API key? Stays silent, never blocks the session
17
18
 
18
19
  ## Prerequisites
@@ -49,6 +50,8 @@ ln -s "$(pwd)" ~/.pi/agent/extensions/pi-session-name
49
50
 
50
51
  No configuration needed for the default `first` mode. Just install and use pi — your first session will auto-name itself.
51
52
 
53
+ > **Tip**: in `first` mode the session is named after the first turn, so if the root cause or key point usually emerges only in later turns, set `"mode": "auto"` — the title then re-evaluates each turn and tracks the conversation's conclusion.
54
+
52
55
  If you want to change behavior, see [Configuration](#configuration).
53
56
 
54
57
  ### Commands
@@ -137,6 +140,8 @@ import {
137
140
  buildAutoPrompt,
138
141
  loadConfig,
139
142
  generateTitle,
143
+ parseSessionTitle,
144
+ collectRecentSessionTitles,
140
145
  } from "@fyeeme/pi-session-name";
141
146
  ```
142
147
 
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
3
  import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import type { Model } from "@earendil-works/pi-ai";
5
5
  import { complete } from "@earendil-works/pi-ai/compat";
@@ -61,52 +61,144 @@ export function cleanTitle(raw: string, maxLength: number = 200): string | null
61
61
  return t.length > 0 ? t : null;
62
62
  }
63
63
 
64
+ // ---------------------------------------------------------------------------
65
+ // Sibling titles — scan local session files for other sessions' names, so the
66
+ // prompt can steer the model away from titles already in use (distinctiveness
67
+ // is a property of the list, so the model must see the list).
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /** Extract a session file's display name: the latest `session_info` entry; an empty name clears it. */
71
+ export function parseSessionTitle(content: string): string | undefined {
72
+ const lines = content.split("\n");
73
+ for (let i = lines.length - 1; i >= 0; i--) {
74
+ const line = lines[i]!.trim();
75
+ if (!line) continue;
76
+ try {
77
+ const d = JSON.parse(line) as { type?: string; name?: string };
78
+ if (d.type === "session_info") return d.name?.trim() || undefined;
79
+ } catch {
80
+ // skip malformed lines
81
+ }
82
+ }
83
+ return undefined;
84
+ }
85
+
86
+ const TITLE_CACHE_TTL_MS = 60_000;
87
+ const titleCache = new Map<string, { fetchedAt: number; entries: Array<{ file: string; name: string }> }>();
88
+
89
+ /**
90
+ * Recent sibling session titles from `sessionsDir` (newest first).
91
+ * Excludes the current session's file and `excludeNames` (e.g. the current title,
92
+ * so a rename is not compared against itself). Best effort: any error → [].
93
+ */
94
+ export function collectRecentSessionTitles(
95
+ sessionsDir: string,
96
+ opts: { currentSessionFile?: string; excludeNames?: string[]; maxTitles?: number; maxFiles?: number } = {},
97
+ ): string[] {
98
+ const maxTitles = opts.maxTitles ?? 20;
99
+ const maxFiles = opts.maxFiles ?? 50;
100
+
101
+ let scanned: Array<{ file: string; name: string }>;
102
+ const cached = titleCache.get(sessionsDir);
103
+ if (cached && Date.now() - cached.fetchedAt < TITLE_CACHE_TTL_MS) {
104
+ scanned = cached.entries;
105
+ } else {
106
+ scanned = [];
107
+ try {
108
+ const files = readdirSync(sessionsDir)
109
+ .filter((f) => f.endsWith(".jsonl"))
110
+ .sort()
111
+ .reverse()
112
+ .slice(0, maxFiles);
113
+ for (const fname of files) {
114
+ try {
115
+ const name = parseSessionTitle(readFileSync(join(sessionsDir, fname), "utf8"));
116
+ if (name) scanned.push({ file: fname, name });
117
+ } catch {
118
+ // unreadable file — skip
119
+ }
120
+ }
121
+ } catch {
122
+ // missing/unreadable dir — empty
123
+ }
124
+ titleCache.set(sessionsDir, { fetchedAt: Date.now(), entries: scanned });
125
+ }
126
+
127
+ const exclude = new Set((opts.excludeNames ?? []).map((n) => n.trim()).filter((n) => n.length > 0));
128
+ const currentBase = opts.currentSessionFile ? basename(opts.currentSessionFile) : undefined;
129
+ const seen = new Set<string>();
130
+ const out: string[] = [];
131
+ for (const { file, name } of scanned) {
132
+ if (currentBase && file === currentBase) continue;
133
+ if (exclude.has(name) || seen.has(name)) continue;
134
+ seen.add(name);
135
+ out.push(name);
136
+ if (out.length >= maxTitles) break;
137
+ }
138
+ return out;
139
+ }
140
+
64
141
  // ---------------------------------------------------------------------------
65
142
  // Prompt builders — first-title & auto-rename prompts
66
143
  // ---------------------------------------------------------------------------
67
144
 
68
145
  export function buildFirstPrompt(
69
146
  conversationText: string,
70
- opts: { maxLength?: number } = {},
147
+ opts: { maxLength?: number; recentTitles?: string[] } = {},
71
148
  ): string {
72
149
  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.",
150
+ const lines: string[] = [
151
+ "You generate a title so the user can recognize this conversation at a glance in a session list.",
75
152
  "Rules:",
76
- '- Output ONLY the title text. No quotes, no trailing punctuation, no explanation.',
153
+ '- Output ONLY the title text. No quotes, no parentheses, no trailing punctuation, no explanation.',
77
154
  "- 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");
155
+ '- Distinctiveness first: the title must tell this session apart from other sessions in the list. Generic labels ("bug fix", "problem analysis", "code review") could describe any session — never use them as the headline.',
156
+ "- Carry the concrete detail: name the specific module, error, symptom, or business object involved, and include the single most identifying identifier (ticket, order, class, or file name) when there is one. Action verbs (debug, analyze, fix) carry little identifying weight.",
157
+ `- Keep it clear and readable. Prefer capturing the key point over staying short: when the conversation found a root cause or conclusion, include it (specific field, config, or error). Aim for roughly 15-40 characters; never exceed ${maxLength} characters.`,
158
+ ];
159
+ appendRecentTitles(lines, opts.recentTitles);
160
+ lines.push("", "<conversation>", conversationText, "</conversation>");
161
+ return lines.join("\n");
85
162
  }
86
163
 
87
164
  export function buildAutoPrompt(
88
165
  currentName: string,
89
166
  conversationText: string,
90
- opts: { maxLength?: number } = {},
167
+ opts: { maxLength?: number; recentTitles?: string[] } = {},
91
168
  ): string {
92
169
  const maxLength = opts.maxLength ?? 200;
93
- return [
170
+ const hasRecent = (opts.recentTitles ?? []).some((t) => t.trim().length > 0);
171
+ const lines: string[] = [
94
172
  "You decide whether the session title still matches the conversation.",
95
173
  `- Current title: ${currentName}`,
96
174
  "If the title is still accurate, reply with exactly: KEEP",
97
- "If it is inaccurate or too vague now, output a NEW descriptive title.",
175
+ hasRecent
176
+ ? "If it is inaccurate, outdated, or hard to tell apart from other sessions (check it against <recent_session_titles> below), output a NEW title."
177
+ : "If it is inaccurate, outdated, or hard to tell apart from other sessions, output a NEW title.",
98
178
  "Rules for a new title:",
99
- '- ONLY the title text. No quotes, no trailing punctuation, no explanation.',
179
+ '- ONLY the title text. No quotes, no parentheses, no trailing punctuation, no explanation.',
100
180
  "- 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");
181
+ '- Distinctiveness first: the title must tell this session apart from other sessions in the list. Generic labels ("bug fix", "problem analysis", "code review") could describe any session — never use them as the headline.',
182
+ "- Carry the concrete detail of the current main task: name the specific module, error, symptom, or business object involved, and include the single most identifying identifier (ticket, order, class, or file name) when there is one.",
183
+ `- Keep it clear and readable. Prefer capturing the key point over staying short: when the conversation found a root cause or conclusion, include it (specific field, config, or error). Aim for roughly 30-55 characters; never exceed ${maxLength} characters.`,
184
+ ];
185
+ appendRecentTitles(lines, opts.recentTitles);
186
+ lines.push("", "<conversation>", conversationText, "</conversation>");
187
+ return lines.join("\n");
108
188
  }
109
189
 
190
+ const appendRecentTitles = (lines: string[], recentTitles?: string[]): void => {
191
+ const recent = (recentTitles ?? []).map((t) => t.trim()).filter((t) => t.length > 0);
192
+ if (recent.length === 0) return;
193
+ lines.push(
194
+ "- The <recent_session_titles> list below shows titles already used by other sessions of this project. Your title must be clearly distinguishable from every one of them (different subject, ID, or error) — never a rewording of one. When a listed title already covers the same subject, shift the focus to what THIS session newly found or changed.",
195
+ "",
196
+ "<recent_session_titles>",
197
+ ...recent.map((t) => `- ${t}`),
198
+ "</recent_session_titles>",
199
+ );
200
+ };
201
+
110
202
  // ---------------------------------------------------------------------------
111
203
  // SessionNameConfig + loadConfig
112
204
  // ---------------------------------------------------------------------------
@@ -147,6 +239,18 @@ export function loadConfig(cwd: string, env: Record<string, string | undefined>
147
239
 
148
240
  export type ModelAuth = { model: Model<any>; apiKey: string; headers: Record<string, string> | undefined };
149
241
 
242
+ /** Registry headers are ProviderHeaders (values may be null); our auth contract is Record<string, string>. */
243
+ export function dropNullHeaders(
244
+ headers?: Record<string, string | null>,
245
+ ): Record<string, string> | undefined {
246
+ if (!headers) return undefined;
247
+ const out: Record<string, string> = {};
248
+ for (const [k, v] of Object.entries(headers)) {
249
+ if (typeof v === "string") out[k] = v;
250
+ }
251
+ return out;
252
+ }
253
+
150
254
  export async function resolveModelAndAuth(
151
255
  ctx: ExtensionContext,
152
256
  cfg: SessionNameConfig,
@@ -157,7 +261,8 @@ export async function resolveModelAndAuth(
157
261
  if (!model) return null;
158
262
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
159
263
  if (!auth?.ok || !auth.apiKey) return null;
160
- return { model, apiKey: auth.apiKey, headers: auth.headers };
264
+ // registry headers are ProviderHeaders (values may be null); our auth contract is Record<string, string>.
265
+ return { model, apiKey: auth.apiKey, headers: dropNullHeaders(auth.headers) };
161
266
  }
162
267
 
163
268
  // ---------------------------------------------------------------------------
@@ -191,6 +296,17 @@ export async function generateTitle(
191
296
  // Pi extension entry point
192
297
  // ---------------------------------------------------------------------------
193
298
 
299
+ const siblingTitles = (ctx: ExtensionContext, excludeNames: string[]): string[] => {
300
+ try {
301
+ return collectRecentSessionTitles(ctx.sessionManager.getSessionDir(), {
302
+ currentSessionFile: ctx.sessionManager.getSessionFile(),
303
+ excludeNames,
304
+ });
305
+ } catch {
306
+ return [];
307
+ }
308
+ };
309
+
194
310
  export default function (pi: ExtensionAPI): void {
195
311
  let manuallyLocked = false;
196
312
  let inFlight = false;
@@ -226,9 +342,11 @@ export default function (pi: ExtensionAPI): void {
226
342
 
227
343
  let title: string | null;
228
344
  if (cfg.mode === "first" || !currentName) {
229
- title = cleanTitle(await generateTitle(buildFirstPrompt(text, cfg), auth, complete, ctx.signal), cfg.maxLength);
345
+ const prompt = buildFirstPrompt(text, { maxLength: cfg.maxLength, recentTitles: siblingTitles(ctx, currentName ? [currentName] : []) });
346
+ title = cleanTitle(await generateTitle(prompt, auth, complete, ctx.signal), cfg.maxLength);
230
347
  } else {
231
- const verdict = await generateTitle(buildAutoPrompt(currentName, text, cfg), auth, complete, ctx.signal);
348
+ const prompt = buildAutoPrompt(currentName, text, { maxLength: cfg.maxLength, recentTitles: siblingTitles(ctx, currentName ? [currentName] : []) });
349
+ const verdict = await generateTitle(prompt, auth, complete, ctx.signal);
232
350
  title = /^keep$/i.test(verdict.trim()) ? null : cleanTitle(verdict, cfg.maxLength);
233
351
  }
234
352
  if (!title) return;
@@ -278,7 +396,9 @@ export default function (pi: ExtensionAPI): void {
278
396
  return;
279
397
  }
280
398
  try {
281
- const title = cleanTitle(await generateTitle(buildFirstPrompt(text, cfg), auth, complete, ctx.signal), cfg.maxLength);
399
+ const current = pi.getSessionName();
400
+ const prompt = buildFirstPrompt(text, { maxLength: cfg.maxLength, recentTitles: siblingTitles(ctx, current ? [current] : []) });
401
+ const title = cleanTitle(await generateTitle(prompt, auth, complete, ctx.signal), cfg.maxLength);
282
402
  if (!title) {
283
403
  ctx.ui.notify("Could not generate a name from the model response", "warning");
284
404
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fyeeme/pi-session-name",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
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
5
  "type": "module",
6
6
  "license": "MIT",