@fyeeme/pi-session-name 1.0.2 → 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,12 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [1.0.1] - 2025-07-25
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
+
21
+ ## [1.0.3] - 2026-09-09
22
+
23
+ ### Changed
24
+
25
+ - Project config path uses `CONFIG_DIR_NAME` instead of a hardcoded `.pi`.
26
+ - Title generation forwards `ctx.signal` so aborts cancel the nested model call.
27
+
28
+ ## [1.0.2] - 2025-07-25
11
29
 
12
30
  ### Fixed
13
31
 
14
32
  - Race condition where auto-generated title could overwrite manual rename when `generateTitle` async call completes after user renamed the session
15
33
 
34
+ ## [1.0.1] - 2025-07-25
35
+
16
36
  ### Added
17
37
 
18
38
  - `/rename [name]` command: manually rename the current session on demand. With a name argument it sets that name; with no argument it auto-generates one from the conversation. Invoking it locks out background auto-naming (manual control), consistent with `/name`.
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,6 +1,6 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { basename, join } from "node:path";
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";
6
6
 
@@ -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
  // ---------------------------------------------------------------------------
@@ -122,7 +214,7 @@ const DEFAULT_CONFIG: SessionNameConfig = { mode: "first", enabled: true, maxLen
122
214
 
123
215
  export function loadConfig(cwd: string, env: Record<string, string | undefined> = process.env): SessionNameConfig {
124
216
  let fileCfg: Partial<SessionNameConfig> = {};
125
- const file = join(cwd, ".pi", "session-name.json");
217
+ const file = join(cwd, CONFIG_DIR_NAME, "session-name.json");
126
218
  try {
127
219
  fileCfg = JSON.parse(readFileSync(file, "utf8")) as Partial<SessionNameConfig>;
128
220
  } catch {
@@ -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
  // ---------------------------------------------------------------------------
@@ -168,6 +273,7 @@ export async function generateTitle(
168
273
  prompt: string,
169
274
  auth: ModelAuth,
170
275
  completeFn: typeof complete = complete,
276
+ signal?: AbortSignal,
171
277
  ): Promise<string> {
172
278
  const response = await completeFn(
173
279
  auth.model,
@@ -176,7 +282,8 @@ export async function generateTitle(
176
282
  { role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() },
177
283
  ],
178
284
  },
179
- { apiKey: auth.apiKey, headers: auth.headers },
285
+ // ctx.signal (undefined while idle) lets Esc/abort cancel the nested call.
286
+ { apiKey: auth.apiKey, headers: auth.headers, signal },
180
287
  );
181
288
  return response.content
182
289
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
@@ -189,6 +296,17 @@ export async function generateTitle(
189
296
  // Pi extension entry point
190
297
  // ---------------------------------------------------------------------------
191
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
+
192
310
  export default function (pi: ExtensionAPI): void {
193
311
  let manuallyLocked = false;
194
312
  let inFlight = false;
@@ -224,9 +342,11 @@ export default function (pi: ExtensionAPI): void {
224
342
 
225
343
  let title: string | null;
226
344
  if (cfg.mode === "first" || !currentName) {
227
- title = cleanTitle(await generateTitle(buildFirstPrompt(text, cfg), auth), 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);
228
347
  } else {
229
- const verdict = await generateTitle(buildAutoPrompt(currentName, text, cfg), auth);
348
+ const prompt = buildAutoPrompt(currentName, text, { maxLength: cfg.maxLength, recentTitles: siblingTitles(ctx, currentName ? [currentName] : []) });
349
+ const verdict = await generateTitle(prompt, auth, complete, ctx.signal);
230
350
  title = /^keep$/i.test(verdict.trim()) ? null : cleanTitle(verdict, cfg.maxLength);
231
351
  }
232
352
  if (!title) return;
@@ -276,7 +396,9 @@ export default function (pi: ExtensionAPI): void {
276
396
  return;
277
397
  }
278
398
  try {
279
- const title = cleanTitle(await generateTitle(buildFirstPrompt(text, cfg), auth), 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);
280
402
  if (!title) {
281
403
  ctx.ui.notify("Could not generate a name from the model response", "warning");
282
404
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fyeeme/pi-session-name",
3
- "version": "1.0.2",
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",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "repository": {
12
12
  "type": "git",
13
- "url": "https://github.com/fyeeme/pi-packages"
13
+ "url": "git+https://github.com/fyeeme/pi-packages.git"
14
14
  },
15
15
  "bugs": {
16
16
  "url": "https://github.com/fyeeme/pi-packages/issues"
@@ -43,12 +43,12 @@
43
43
  "typecheck": "tsc"
44
44
  },
45
45
  "peerDependencies": {
46
- "@earendil-works/pi-coding-agent": ">=0.80.0",
47
- "@earendil-works/pi-ai": ">=0.80.0"
46
+ "@earendil-works/pi-coding-agent": ">=0.84.1",
47
+ "@earendil-works/pi-ai": ">=0.84.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@earendil-works/pi-coding-agent": "0.80.10",
51
- "@earendil-works/pi-ai": "0.80.10",
50
+ "@earendil-works/pi-coding-agent": "0.84.1",
51
+ "@earendil-works/pi-ai": "0.84.1",
52
52
  "@types/node": "22.19.19",
53
53
  "typescript": "5.9.3",
54
54
  "vitest": "3.2.7"