@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 +21 -1
- package/README.md +6 -1
- package/index.ts +153 -31
- package/package.json +6 -6
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.
|
|
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
|
-
- **
|
|
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
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
47
|
-
"@earendil-works/pi-ai": ">=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.
|
|
51
|
-
"@earendil-works/pi-ai": "0.
|
|
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"
|