@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 +11 -0
- package/README.md +6 -1
- package/index.ts +148 -28
- package/package.json +1 -1
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
|
-
- **
|
|
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
|
-
|
|
74
|
-
"You generate a
|
|
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
|
-
'-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
'-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
"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",
|