@fyeeme/pi-session-name 1.0.3 → 1.0.5

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.
Files changed (4) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +45 -22
  3. package/index.ts +405 -133
  4. package/package.json +5 -5
package/CHANGELOG.md CHANGED
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.5] - 2026-10-01
11
+
12
+ ### Removed
13
+
14
+ - **BREAKING**: the sibling-session title dedup is gone — the extension no longer scans local session files for other sessions' names, and prompts no longer embed a `<recent_session_titles>` block. Removed the exported `parseSessionTitle()` and `collectRecentSessionTitles()` helpers, the `recentTitles` option from all prompt builders, and `classifyKeep()`'s `recentTitles` parameter (classifier state drops `recentSessionTitles`). Prompts keep their general distinctiveness rules, so titles still lead with the concrete entity/error/identifier.
15
+ - **BREAKING**: the custom title-model override is gone — titles are always generated with the current session model (`ctx.model`). Removed the `model` field from `session-name.json`, the `PI_SESSION_NAME_MODEL_PROVIDER` / `PI_SESSION_NAME_MODEL_ID` environment variables, and the exported `resolveModel()` helper (its getAvailableOfType fallback chain went with it). Auto mode's classifier-based KEEP/NEW verdict is unaffected.
16
+
17
+ ### Added
18
+
19
+ - Creation-time prefix: every set title becomes `yyyy-mm-dd hh:mm - title` (local timezone, parsed from the session file name). The resume picker sorts by last-modified, so editing an old session made its age invisible and it could get lost; the prefix keeps the original creation time visible. Opt out with `PI_SESSION_NAME_TIMESTAMP=false` or `"appendCreationTime": false` in `.pi/agent/session-name.json`.
20
+ - Prompt-style switch: `prompt: "concise" | "editorial"` (env `PI_SESSION_NAME_PROMPT`). `concise` (default) is the deepseek-harness system prompt verbatim with a 512-token budget; `editorial` adds pi's distinctiveness/concrete-detail rules with a 1024-token budget — measured A/B on 50 local sessions: concise 96% yield / p50 5.1 s, editorial denser titles / ~30% slower.
21
+ - New `mode: "follow"` (env `PI_SESSION_NAME_MODE=follow`): every settled turn regenerates the title unconditionally — the deepseek-harness `all-prompts` cadence — so the title tracks the conversation as its real subject emerges instead of freezing on an early-turn snapshot. Unlike `first`/`auto`, `follow` also keeps tracking a resumed session's inherited title (treated as the last revision, not a pin); `/rename` still pins either way.
22
+ - Title-hardening practices adapted from deepseek-harness's `session-title` package: (1) terminal- and spoof-safe title sanitization — ANSI/OSC/CSI/ESC escapes, C0/C1 control characters, and zero-width/bidi directional controls are stripped before acceptance, and truncation is code-point-safe (never splits a surrogate pair); (2) the conversation is JSON-framed in the prompt (`JSON.stringify` of the selected messages), so untrusted user text cannot forge structural delimiters like a fake `</conversation>` or `Assistant:` turn; (3) title generation caps the auxiliary call at `maxTokens: 512`, bounding runaway output; (4) prompt length targets are language-aware (CJK characters vs. non-CJK words).
23
+ - Auto mode's KEEP/NEW verdict now runs on a classifier model when the host has one (pi 0.99 `ModelRuntime.classify()`, bool question): a KEEP verdict skips the generation call entirely, and a NEW verdict generates the replacement through a rules-only prompt. Without a classifier — or when the classifier call fails — the original single-verdict `complete()` path runs unchanged.
24
+
25
+ ### Changed
26
+
27
+ - **BREAKING**: the config file moved from `.pi/session-name.json` to `.pi/agent/session-name.json`.
28
+ - **BREAKING**: the title pipeline is now aligned 1:1 with deepseek-harness's `session-title` architecture wherever the pi extension host allows: (1) requests carry only **human messages** (assistant/system no longer enter a title prompt) in a **system/user split** — the system instruction is deepseek-harness's exact wording (plain text, no Markdown/XML/code/terminal codes, ~6 words / ~18 CJK chars) and the user payload is the JSON-framed message array; (2) `maxLength` is now a **UTF-8 byte budget** (200 bytes ≈ 200 ASCII chars or ~66 CJK chars, `truncateTitleUtf8` semantics) instead of a character count; (3) generation has a 20 s deadline composed with Esc/abort, and any failure (error, abort, timeout, non-`stop` finish, empty output) falls back to a **deterministic first-message title** (`fallbackSessionTitle`, zero LLM) instead of leaving the session untitled; (4) input carries a 16 KiB UTF-8 budget that narrows from the oldest non-first message.
29
+ - **BREAKING**: the default `mode` is now `"follow"` — every settled turn regenerates the title so it tracks the conversation's real subject instead of freezing on an early-turn snapshot (and across resumes: an inherited title is treated as the last revision). Set `mode: "first"` (or `PI_SESSION_NAME_MODE=first`) to keep the old one-shot behavior; `"auto"` remains available.
30
+ - ~~Model resolution accepts a provider-only `model` config~~ (superseded: the custom-model override was removed — see Removed above; title generation now reads `ctx.model` directly.)
31
+ - Peer dependency floor raised to `@earendil-works/pi-coding-agent >= 0.99.0`; dev toolchain pinned to 0.99.2.
32
+
33
+ ## [1.0.4] - 2026-09-17
34
+
35
+ ### Added
36
+
37
+ - 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.
38
+
39
+ ### Changed
40
+
41
+ - 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.
42
+ - 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.
43
+
10
44
  ## [1.0.3] - 2026-09-09
11
45
 
12
46
  ### Changed
package/README.md CHANGED
@@ -7,17 +7,19 @@ Auto-name [pi](https://pi.dev) sessions with a short LLM-generated title so `--r
7
7
 
8
8
  ## Features
9
9
 
10
- - **First-mode** (default) — names the session once on first agent response, then leaves it alone
11
- - **Auto-mode** — re-evaluates each turn; the title tracks the current topic
10
+ - **Follow-mode** (default) — regenerates the title every turn, so it tracks the conversation as its real subject emerges (never freezes on an early-turn snapshot)
11
+ - **First-mode** — names the session once on first agent response, then leaves it alone
12
+ - **Auto-mode** — re-evaluates each turn (KEEP/NEW verdict); the title tracks the current topic
12
13
  - **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
14
+ - **Creation-time prefix** — every title is prefixed `yyyy-mm-dd hh:mm - …` so sessions edited later still show when they were created (the list sorts by last-modified)
13
15
  - **`/rename [name]`** — rename the current session on demand. With an argument it sets that name; without, it generates one from the conversation
14
16
  - **Language-aware** — titles use the same language as your first message
15
- - **Descriptive titles** — key entity + action + goal (~15-40 chars), not terse labels
16
- - **Graceful failure** — model unavailable or no API key? Stays silent, never blocks the session
17
+ - **Distinctive titles** — leads with the concrete entity/error/identifier, so similar sessions don't blur together (~15-40 chars)
18
+ - **Graceful failure** — model unavailable or model call fails? Stays silent, never blocks the session
17
19
 
18
20
  ## Prerequisites
19
21
 
20
- - [pi](https://pi.dev) >= 0.80.0 (uses `agent_settled` / `session_info_changed` events)
22
+ - [pi](https://pi.dev) >= 0.87.0 (uses `agent_settled` / `session_info_changed` events and `ctx.modelRegistry.complete()`)
21
23
 
22
24
  ## Installation
23
25
 
@@ -49,6 +51,8 @@ ln -s "$(pwd)" ~/.pi/agent/extensions/pi-session-name
49
51
 
50
52
  No configuration needed for the default `first` mode. Just install and use pi — your first session will auto-name itself.
51
53
 
54
+ > **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.
55
+
52
56
  If you want to change behavior, see [Configuration](#configuration).
53
57
 
54
58
  ### Commands
@@ -62,7 +66,7 @@ If you want to change behavior, see [Configuration](#configuration).
62
66
 
63
67
  ## Configuration
64
68
 
65
- Create `.pi/session-name.json` in your project root:
69
+ Create `.pi/agent/session-name.json` in your project root:
66
70
 
67
71
  ```json
68
72
  {
@@ -76,12 +80,13 @@ Create `.pi/session-name.json` in your project root:
76
80
 
77
81
  | Option | Default | Env override | Description |
78
82
  |--------|---------|--------------|-------------|
79
- | `mode` | `"first"` | `PI_SESSION_NAME_MODE` | `"first"` — name once; `"auto"` — re-evaluate each turn |
80
- | `maxLength` | `200` | `PI_SESSION_NAME_MAX_LENGTH` | Character cap for generated titles |
83
+ | `mode` | `"follow"` | `PI_SESSION_NAME_MODE` | `"follow"` — regenerate every turn (all-prompts cadence); `"first"` — name once; `"auto"` — re-evaluate each turn (KEEP/NEW) |
84
+ | `maxLength` | `200` | `PI_SESSION_NAME_MAX_LENGTH` | UTF-8 byte budget for accepted titles (200 bytes ≈ 200 ASCII chars or ~66 CJK chars) |
85
+ | `prompt` | `"concise"` | `PI_SESSION_NAME_PROMPT` | `"concise"` — deepseek-harness wording, 512-token budget (default); `"editorial"` — adds distinctiveness/concrete-detail rules, 1024-token budget |
81
86
  | `enabled` | `true` | `PI_SESSION_NAME_ENABLED=false` | Master switch to disable auto-naming |
82
- | `model` | current session model | `PI_SESSION_NAME_MODEL_PROVIDER` + `PI_SESSION_NAME_MODEL_ID` | Override the model used to generate titles |
87
+ | `appendCreationTime` | `true` | `PI_SESSION_NAME_TIMESTAMP=false` | Prefix every title with the session's creation time — `yyyy-mm-dd hh:mm - title`. The resume list sorts by last-modified, so an edited old session resurfaces at the top; the prefix keeps its original age visible. Parsed from the session file name (best effort: no stamp, no prefix). |
83
88
 
84
- By default the extension uses the model you're already chatting with (`ctx.model`), so no extra API key is needed.
89
+ Titles are generated with the model you're already chatting with (`ctx.model`) — there is no model override. Model calls go through `ctx.modelRegistry.complete()`, which resolves authentication at request time — API keys and OAuth subscription logins both work, with no extra API key configuration.
85
90
 
86
91
  ### Environment variables only
87
92
 
@@ -91,17 +96,34 @@ If you prefer environment variables over a config file:
91
96
  export PI_SESSION_NAME_MODE=auto
92
97
  export PI_SESSION_NAME_MAX_LENGTH=150
93
98
  export PI_SESSION_NAME_ENABLED=true
94
- export PI_SESSION_NAME_MODEL_PROVIDER=openai
95
- export PI_SESSION_NAME_MODEL_ID=gpt-4o-mini
96
99
  ```
97
100
 
98
101
  ## How it works
99
102
 
100
- 1. On `agent_settled` (after the first turn completes), the extension builds a condensed text of the conversation
101
- 2. It asks the LLM (same model as the session, unless overridden) to generate a descriptive title
102
- 3. The title is set via `pi.setSessionName()`, which updates the resume picker immediately
103
- 4. In `first` mode, it stops there. In `auto` mode, each subsequent turn re-evaluates: the LLM returns either `KEEP` or a new title
104
- 5. If a manual rename is detected (`session_info_changed` with a name the extension didn't set), auto-naming locks permanently
103
+ ### Modes
104
+
105
+ - **`follow`** (default) — every settled turn regenerates the title unconditionally (the deepseek-harness `all-prompts` cadence): the title always tracks the conversation's current subject, including across resumes (an inherited title is treated as the last revision, not a pin). Costs one short generation call per turn; the title may change between turns — `/rename` pins it for good.
106
+ - **`first`** — one title after the first turn. Cheapest, but the title freezes on the early-turn snapshot: if the conversation's real subject emerges later, the title drifts.
107
+ - **`auto`** — after the first title, each settled turn runs a KEEP/NEW verdict (a classifier model when the host has one); KEEP costs no generation call, NEW regenerates. Balanced.
108
+
109
+ ### Prompt styles
110
+
111
+ Two system-prompt styles ship (measured A/B on 50 local sessions with a reasoning model, glm-5.3-flash):
112
+
113
+ - **`concise`** (default) — deepseek-harness wording verbatim: four lines of format discipline, no editorial content rules. 96% title yield, p50 latency 5.1 s, uniform lengths.
114
+ - **`editorial`** — same architecture plus pi's distinctiveness rules (never a generic headline; carry the concrete module/error/identifier). Titles are more information-dense, but the extra rules induce longer chain-of-thought on reasoning models, so the output budget is raised to 1024 tokens and latency runs ~30% higher.
115
+
116
+ Switch in `.pi/agent/session-name.json` (`"prompt": "editorial"`) or via `PI_SESSION_NAME_PROMPT=editorial`.
117
+
118
+ ### Pipeline (deepseek-harness discipline, 1:1 where the host allows)
119
+
120
+ 1. On `agent_settled`, the extension collects the eligible **human messages** (assistant/system never enter a title request): first message plus the recent tail, per-message cap, and a 16 KiB UTF-8 input budget that narrows from the oldest non-first message
121
+ 2. It sends a **system/user split** request: the system instruction carries the output discipline (plain text, no Markdown/XML/code/terminal codes, language of the messages, ~6 words / ~18 CJK chars); the user payload is the JSON-framed message array, so untrusted text cannot forge structural delimiters
122
+ 3. Output is normalized — ANSI/OSC/CSI/control/bidi stripping, quote/punctuation trimming — and capped by a **UTF-8 byte budget** (`maxLength`, default 200 bytes ≈ 200 ASCII chars or ~66 CJK chars)
123
+ 4. On any failure (provider error, abort, 20 s timeout, non-`stop` finish, empty output), a **deterministic fallback** names the session from the first human message's leading words — zero LLM involvement
124
+ 5. The title is set via `pi.setSessionName()`, which updates the resume picker immediately
125
+ 6. In `first` mode, it stops there. `follow` regenerates every turn (all-prompts cadence). `auto` runs a KEEP/NEW verdict (classifier when available)
126
+ 7. If a manual rename is detected (`session_info_changed` with a name the extension didn't set), auto-naming locks permanently
105
127
 
106
128
  ## Smoke test
107
129
 
@@ -121,7 +143,7 @@ pi -e @fyeeme/pi-session-name
121
143
  /rename foo # → name is "foo", locked
122
144
  /rename # → generates a fresh name from the conversation
123
145
 
124
- # 5. Graceful degradation (unset model's API key)
146
+ # 5. Graceful degradation (model unavailable / model call fails)
125
147
  # → No errors, session runs normally
126
148
  ```
127
149
 
@@ -131,10 +153,11 @@ This extension exposes utilities that other extensions can import:
131
153
 
132
154
  ```typescript
133
155
  import {
134
- buildConversationText,
135
- cleanTitle,
136
- buildFirstPrompt,
137
- buildAutoPrompt,
156
+ buildTitleMessages,
157
+ normalizeSessionTitle,
158
+ fallbackSessionTitle,
159
+ buildTitleRequest,
160
+ buildVerdictRequest,
138
161
  loadConfig,
139
162
  generateTitle,
140
163
  } from "@fyeeme/pi-session-name";
package/index.ts CHANGED
@@ -1,14 +1,94 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
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
- import { complete } from "@earendil-works/pi-ai/compat";
6
5
 
7
6
  // ---------------------------------------------------------------------------
8
- // Types & helpers for buildConversationText
7
+ // Normalize layer — terminal-safe & spoof-safe text normalization with UTF-8
8
+ // byte budgets. Adapted 1:1 from deepseek-harness session-title/normalize.ts:
9
+ // escape/control stripping before acceptance, code-point-safe truncation, and
10
+ // a deterministic first-words fallback. `maxBytes` budgets are UTF-8 bytes.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** Operating-system-command escape sequences, including unterminated tails. */
14
+ const OSC_SEQUENCE = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu;
15
+ /** Control-sequence-introducer escapes such as SGR color codes. */
16
+ const CSI_SEQUENCE = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu;
17
+ /** Remaining two-byte ESC control sequences. */
18
+ const ESC_SEQUENCE = /\u001B[@-_]/gu;
19
+ /** Non-whitespace C0/C1 control characters. */
20
+ const CONTROL_CHARACTER = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/gu;
21
+ /** Directional and invisible controls that can make a displayed title deceptive. */
22
+ const DIRECTIONAL_CONTROL = /[\u200B\u200E\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFEFF]/gu;
23
+
24
+ const utf8Bytes = (s: string): number => Buffer.byteLength(s, "utf8");
25
+
26
+ /** Remove controls and produce one trimmed, whitespace-normalized line. */
27
+ function cleanTitleText(input: string): string {
28
+ return input
29
+ .replace(OSC_SEQUENCE, "")
30
+ .replace(CSI_SEQUENCE, "")
31
+ .replace(ESC_SEQUENCE, "")
32
+ .replace(CONTROL_CHARACTER, "")
33
+ .replace(DIRECTIONAL_CONTROL, "")
34
+ .replace(/\s+/gu, " ")
35
+ .trim();
36
+ }
37
+
38
+ /** Truncate to a UTF-8 byte budget without splitting a Unicode code point.
39
+ * 1:1 from deepseek-harness `truncateTitleUtf8`. */
40
+ export function truncateTitleUtf8(input: string, maxBytes: number): string {
41
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) throw new Error("maxBytes must be a positive integer");
42
+ if (utf8Bytes(input) <= maxBytes) return input;
43
+ let used = 0;
44
+ let output = "";
45
+ for (const character of input) {
46
+ const bytes = utf8Bytes(character);
47
+ if (used + bytes > maxBytes) break;
48
+ output += character;
49
+ used += bytes;
50
+ }
51
+ return output;
52
+ }
53
+
54
+ /**
55
+ * Normalize one accepted title and enforce its UTF-8 byte budget.
56
+ * deepseek-harness `normalizeSessionTitle` plus pi's quote/bracket/trailing-
57
+ * punctuation stripping (a strict superset — harmless when the model obeys
58
+ * the prompt, valuable when it does not).
59
+ * @returns the terminal-safe one-line title, or null when nothing survives.
60
+ */
61
+ export function normalizeSessionTitle(raw: string, maxBytes: number): string | null {
62
+ if (!raw) return null;
63
+ let t = cleanTitleText(raw);
64
+ t = t.replace(/^["'`\u300C\u300E\uFF08(\[]+|["'`\u300D\u300F\uFF09)\].]+$/g, "").trim();
65
+ t = t.replace(/\s+/g, " ");
66
+ t = t.replace(/[.\u3002!\uFF01?\uFF1F]+$/g, "");
67
+ if (!t) return null;
68
+ t = truncateTitleUtf8(t, maxBytes).trimEnd();
69
+ return t.length > 0 ? t : null;
70
+ }
71
+
72
+ /**
73
+ * Derive the deterministic first-prompt fallback title.
74
+ * 1:1 from deepseek-harness `fallbackSessionTitle`: leading whitespace-
75
+ * delimited words within both limits. Zero LLM involvement.
76
+ */
77
+ export function fallbackSessionTitle(input: string, maxWords: number, maxBytes: number): string | null {
78
+ if (!Number.isInteger(maxWords) || maxWords <= 0) throw new Error("maxWords must be a positive integer");
79
+ const words = cleanTitleText(input).split(" ").filter(Boolean).slice(0, maxWords);
80
+ if (words.length === 0) return null;
81
+ const t = truncateTitleUtf8(words.join(" "), maxBytes).trimEnd();
82
+ return t.length > 0 ? t : null;
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Message selection — human messages only (deepseek-harness contract:
87
+ // `sessionTitleUserMessageOf` extracts user-sourced text blocks and drops
88
+ // messages that normalize to empty). system/tool messages never enter a
89
+ // title request.
9
90
  // ---------------------------------------------------------------------------
10
91
 
11
- type ContentBlock = { type?: string; text?: string };
12
92
  export type SessionEntry = { type: string; message?: { role?: string; content?: unknown } };
13
93
 
14
94
  const extractText = (content: unknown): string[] => {
@@ -17,94 +97,162 @@ const extractText = (content: unknown): string[] => {
17
97
  const parts: string[] = [];
18
98
  for (const part of content) {
19
99
  if (part && typeof part === "object") {
20
- const b = part as ContentBlock;
100
+ const b = part as { type?: string; text?: string };
21
101
  if (b.type === "text" && typeof b.text === "string") parts.push(b.text);
22
102
  }
23
103
  }
24
104
  return parts;
25
105
  };
26
106
 
27
- const truncate = (s: string, max: number): string => (s.length <= max ? s : `${s.slice(0, max)}\u2026`);
107
+ /** Code-point-safe per-message truncation with an ellipsis marker. */
108
+ const truncate = (s: string, max: number): string => (Array.from(s).length <= max ? s : `${Array.from(s).slice(0, max).join("")}\u2026`);
109
+
110
+ /** Default input policy: window + per-message cap + total UTF-8 budget. */
111
+ export const TITLE_INPUT_DEFAULTS = { maxMessages: 8, maxCharsPerMessage: 600, maxInputBytes: 16384 } as const;
28
112
 
29
- export function buildConversationText(
113
+ /**
114
+ * Eligible human messages for a title request, in order: first message plus
115
+ * the most recent tail (pi's window; deepseek-harness feeds either the first
116
+ * message or all of them, with a deployment-level byte budget — pi is a
117
+ * terminal extension, so oversize inputs narrow the window from the oldest
118
+ * non-first message instead of failing the request).
119
+ */
120
+ export function buildTitleMessages(
30
121
  entries: SessionEntry[],
31
- opts: { maxMessages?: number; maxCharsPerMessage?: number } = {},
32
- ): string {
33
- const maxMessages = opts.maxMessages ?? 8;
34
- const maxCharsPerMessage = opts.maxCharsPerMessage ?? 600;
122
+ opts: { maxMessages?: number; maxCharsPerMessage?: number; maxInputBytes?: number } = {},
123
+ ): string[] {
124
+ const maxMessages = opts.maxMessages ?? TITLE_INPUT_DEFAULTS.maxMessages;
125
+ const maxCharsPerMessage = opts.maxCharsPerMessage ?? TITLE_INPUT_DEFAULTS.maxCharsPerMessage;
126
+ const maxInputBytes = opts.maxInputBytes ?? TITLE_INPUT_DEFAULTS.maxInputBytes;
35
127
  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 "";
128
+ .filter((e) => e.type === "message" && e.message?.role === "user")
129
+ .map((e) => truncate(extractText(e.message!.content).join("\n").trim(), maxCharsPerMessage))
130
+ .filter((t) => cleanTitleText(t).length > 0);
131
+ if (msgs.length === 0) return [];
43
132
  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");
133
+ // Enforce the framed-input byte budget by dropping the oldest non-first
134
+ // messages (deepseek-harness throws on overflow; a terminal extension
135
+ // narrows instead).
136
+ while (selected.length > 1 && utf8Bytes(frameTitleMessages(selected)) > maxInputBytes) {
137
+ selected.splice(1, 1);
138
+ }
139
+ return selected;
47
140
  }
48
141
 
49
142
  // ---------------------------------------------------------------------------
50
- // cleanTitle — strip wrapping quotes/brackets, collapse whitespace, truncate
143
+ // Prompt layer — 1:1 deepseek-harness session-title-llm texts: a stable
144
+ // language-aware system instruction and a JSON-framed user payload, so
145
+ // untrusted text cannot forge structural delimiters. Target counts are pi's
146
+ // defaults (deepseek-harness leaves them to deployment configuration).
51
147
  // ---------------------------------------------------------------------------
52
148
 
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;
149
+ export const TITLE_TARGET_WORDS = 6;
150
+ export const TITLE_TARGET_CJK_CHARACTERS = 18;
151
+
152
+ /** Prompt style: "concise" = deepseek-harness wording (default); "editorial" =
153
+ * the same architecture plus pi's distinctiveness/concrete-detail rules.
154
+ * A/B on 50 local sessions (glm-5.3-flash): concise yields 96% titles at
155
+ * 512 output tokens; editorial rules induce longer chain-of-thought on
156
+ * reasoning models, so its output budget is raised to 1024 tokens. */
157
+ export type PromptStyle = "concise" | "editorial";
158
+
159
+ export const TITLE_MAX_OUTPUT_TOKENS: Record<PromptStyle, number> = { concise: 512, editorial: 1024 };
160
+
161
+ /** Stable language-aware system instruction. "concise" is deepseek-harness
162
+ * wording; "editorial" keeps the same format discipline and adds pi's
163
+ * distinctiveness rules (higher information density, higher token cost). */
164
+ export function titleSystemPrompt(style: PromptStyle = "concise"): string {
165
+ const base = [
166
+ "Create a concise title for an AI coding-assistant session from the supplied human messages.",
167
+ "Return only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.",
168
+ "Use the language of the messages.",
169
+ ];
170
+ if (style === "editorial") {
171
+ return [
172
+ ...base,
173
+ '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.',
174
+ "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.",
175
+ "Aim for roughly 15-40 characters in CJK languages, or 5-12 words in other languages.",
176
+ ].join("\n");
177
+ }
178
+ return [...base, `Aim for about ${TITLE_TARGET_WORDS} words in non-CJK languages or ${TITLE_TARGET_CJK_CHARACTERS} CJK characters.`].join("\n");
62
179
  }
63
180
 
64
- // ---------------------------------------------------------------------------
65
- // Prompt builders — first-title & auto-rename prompts
66
- // ---------------------------------------------------------------------------
181
+ /** Frame exact messages as JSON so user text cannot break structural delimiters. */
182
+ export function frameTitleMessages(messages: readonly string[]): string {
183
+ return `Generate the session title from this JSON array of human messages:\n${JSON.stringify(messages)}`;
184
+ }
67
185
 
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");
186
+ /** One model-visible title request: system instruction + JSON-framed payload. */
187
+ export interface TitleRequest {
188
+ readonly system: string;
189
+ readonly user: string;
190
+ /** Style-dependent output budget (concise 512 / editorial 1024 tokens). */
191
+ readonly maxOutputTokens: number;
85
192
  }
86
193
 
87
- export function buildAutoPrompt(
88
- currentName: string,
89
- conversationText: string,
90
- opts: { maxLength?: number } = {},
91
- ): string {
92
- const maxLength = opts.maxLength ?? 200;
93
- return [
194
+ /**
195
+ * Build a title request from the eligible human messages.
196
+ * - "first": only the first message (deepseek-harness first-prompt cadence — pi's `first` mode)
197
+ * - "all": every message in the window (deepseek-harness all-prompts cadence — pi's `follow` mode)
198
+ */
199
+ export function buildTitleRequest(mode: "first" | "all", messages: readonly string[], style: PromptStyle = "concise"): TitleRequest {
200
+ if (messages.length === 0) throw new Error("title request requires at least one human message");
201
+ const selected = mode === "first" ? [messages[0]!] : messages;
202
+ return { system: titleSystemPrompt(style), user: frameTitleMessages(selected), maxOutputTokens: TITLE_MAX_OUTPUT_TOKENS[style] };
203
+ }
204
+
205
+ /** pi-only: single-verdict KEEP/NEW fallback request for `auto` mode when no
206
+ * classifier model is available (deepseek-harness has no equivalent). */
207
+ export function buildVerdictRequest(currentName: string, messages: readonly string[], style: PromptStyle = "concise"): TitleRequest {
208
+ const system = [
94
209
  "You decide whether the session title still matches the conversation.",
95
- `- Current title: ${currentName}`,
210
+ `Current title: ${currentName}`,
96
211
  "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>",
212
+ "If it is inaccurate, outdated, or hard to tell apart from other sessions, reply with a NEW title instead:",
213
+ "- The new title is one line of plain text: no quotes, no Markdown, no code, no explanation.",
214
+ "- Use the language of the first human message.",
215
+ ...(style === "editorial"
216
+ ? [
217
+ '- Distinctiveness first: never use generic labels ("bug fix", "code review") as the headline; carry the concrete module, error, or identifier.',
218
+ "- Aim for roughly 15-40 characters in CJK languages, or 5-12 words in other languages.",
219
+ ]
220
+ : [`- Aim for about ${TITLE_TARGET_WORDS} words in non-CJK languages or ${TITLE_TARGET_CJK_CHARACTERS} CJK characters.`]),
107
221
  ].join("\n");
222
+ return { system, user: frameTitleMessages(messages), maxOutputTokens: TITLE_MAX_OUTPUT_TOKENS[style] };
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Creation-time prefix — pi's session list sorts by last-modified, so an
227
+ // edited old session resurfaces at the top and its age becomes invisible.
228
+ // Prefixing the creation time (parsed from the session file name) keeps it
229
+ // discoverable: "yyyy-mm-dd hh:mm - <title>".
230
+ // ---------------------------------------------------------------------------
231
+
232
+ /** Parse the session's creation timestamp from its file name
233
+ * (`2026-10-01T10-06-10-085Z_<uuid>.jsonl`). Best effort: null when the name
234
+ * does not carry a parseable stamp. */
235
+ export function sessionCreationTime(sessionFile?: string): Date | null {
236
+ if (!sessionFile) return null;
237
+ const m = basename(sessionFile).match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-\d{3}Z_/);
238
+ if (!m) return null;
239
+ const d = new Date(Date.UTC(+m[1]!, +m[2]! - 1, +m[3]!, +m[4]!, +m[5]!, +m[6]!));
240
+ return Number.isNaN(d.getTime()) ? null : d;
241
+ }
242
+
243
+ /** Local-timezone "yyyy-mm-dd hh:mm" (24h). */
244
+ export function formatCreationPrefix(d: Date): string {
245
+ const p = (n: number): string => String(n).padStart(2, "0");
246
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
247
+ }
248
+
249
+ /** Prefix the creation time: "yyyy-mm-dd hh:mm - title". Best effort: the
250
+ * title is returned unchanged when the stamp is unavailable or disabled. */
251
+ export function withCreationTime(title: string, sessionFile: string | undefined, enabled: boolean): string {
252
+ if (!enabled) return title;
253
+ const d = sessionCreationTime(sessionFile);
254
+ if (!d) return title;
255
+ return `${formatCreationPrefix(d)} - ${title}`;
108
256
  }
109
257
 
110
258
  // ---------------------------------------------------------------------------
@@ -112,74 +260,91 @@ export function buildAutoPrompt(
112
260
  // ---------------------------------------------------------------------------
113
261
 
114
262
  export interface SessionNameConfig {
115
- mode: "first" | "auto";
263
+ mode: "first" | "auto" | "follow";
264
+ /** System-prompt style: "concise" (deepseek-harness wording, default) or
265
+ * "editorial" (adds pi's distinctiveness rules; higher output budget). */
266
+ prompt: PromptStyle;
116
267
  enabled: boolean;
268
+ /** Prefix the session's creation time onto every set title
269
+ * ("yyyy-mm-dd hh:mm - title"), because the resume list sorts by
270
+ * last-modified. Default true. */
271
+ appendCreationTime: boolean;
272
+ /** Maximum UTF-8 bytes in an accepted title (byte budget, deepseek-harness
273
+ * semantics: 200 bytes ≈ 200 ASCII chars or ~66 CJK characters). */
117
274
  maxLength: number;
118
- model?: { provider: string; id: string };
119
275
  }
120
276
 
121
- const DEFAULT_CONFIG: SessionNameConfig = { mode: "first", enabled: true, maxLength: 200 };
277
+ const DEFAULT_CONFIG: SessionNameConfig = { mode: "follow", prompt: "concise", enabled: true, appendCreationTime: true, maxLength: 200 };
122
278
 
123
279
  export function loadConfig(cwd: string, env: Record<string, string | undefined> = process.env): SessionNameConfig {
124
280
  let fileCfg: Partial<SessionNameConfig> = {};
125
- const file = join(cwd, CONFIG_DIR_NAME, "session-name.json");
281
+ const file = join(cwd, CONFIG_DIR_NAME, "agent", "session-name.json");
126
282
  try {
127
283
  fileCfg = JSON.parse(readFileSync(file, "utf8")) as Partial<SessionNameConfig>;
128
284
  } catch {
129
285
  // missing or malformed config — ignore, fall back to defaults
130
286
  }
131
287
  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;
288
+ if (env.PI_SESSION_NAME_MODE === "first" || env.PI_SESSION_NAME_MODE === "auto" || env.PI_SESSION_NAME_MODE === "follow") {
289
+ cfg.mode = env.PI_SESSION_NAME_MODE;
290
+ }
291
+ if (env.PI_SESSION_NAME_PROMPT === "concise" || env.PI_SESSION_NAME_PROMPT === "editorial") cfg.prompt = env.PI_SESSION_NAME_PROMPT;
133
292
  if (env.PI_SESSION_NAME_ENABLED === "false") cfg.enabled = false;
293
+ if (env.PI_SESSION_NAME_TIMESTAMP === "false") cfg.appendCreationTime = false;
134
294
  if (env.PI_SESSION_NAME_MAX_LENGTH) {
135
295
  const n = Number(env.PI_SESSION_NAME_MAX_LENGTH);
136
296
  if (Number.isFinite(n) && n > 0) cfg.maxLength = n;
137
297
  }
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
298
  return cfg;
142
299
  }
143
300
 
144
301
  // ---------------------------------------------------------------------------
145
- // ModelAuth + resolveModelAndAuth
302
+ // generateTitle — model call via ctx.modelRegistry (auth resolved at request
303
+ // time). deepseek-harness discipline: system/user split, output-token cap,
304
+ // end-to-end deadline composed with the caller's signal, and finish-reason
305
+ // checking (anything but "stop" is a failure, not a title).
146
306
  // ---------------------------------------------------------------------------
147
307
 
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 };
308
+ const TITLE_TIMEOUT_MS = 20_000;
309
+
310
+ /** Compose optional signals (caller abort + timeout) into one abort signal. */
311
+ function composeSignals(signals: Array<AbortSignal | undefined>): AbortSignal {
312
+ const controller = new AbortController();
313
+ const onAbort = () => controller.abort();
314
+ for (const s of signals) {
315
+ if (!s) continue;
316
+ if (s.aborted) {
317
+ controller.abort();
318
+ return controller.signal;
319
+ }
320
+ s.addEventListener("abort", onAbort, { once: true });
321
+ }
322
+ return controller.signal;
161
323
  }
162
324
 
163
- // ---------------------------------------------------------------------------
164
- // generateTitle — call LLM to produce a short title
165
- // ---------------------------------------------------------------------------
166
-
167
325
  export async function generateTitle(
168
- prompt: string,
169
- auth: ModelAuth,
170
- completeFn: typeof complete = complete,
326
+ request: TitleRequest,
327
+ model: Model<any>,
328
+ ctx: ExtensionContext,
171
329
  signal?: AbortSignal,
172
330
  ): Promise<string> {
173
- const response = await completeFn(
174
- auth.model,
331
+ const now = Date.now();
332
+ const response = await ctx.modelRegistry.complete(
333
+ model,
175
334
  {
176
335
  messages: [
177
- { role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() },
336
+ { role: "system", content: [{ type: "text", text: request.system }], timestamp: now },
337
+ { role: "user", content: [{ type: "text", text: request.user }], timestamp: now },
178
338
  ],
179
339
  },
180
- // ctx.signal (undefined while idle) lets Esc/abort cancel the nested call.
181
- { apiKey: auth.apiKey, headers: auth.headers, signal },
340
+ {
341
+ // ctx.signal (undefined while idle) lets Esc/abort cancel the nested
342
+ // call; the deadline keeps a hung provider from blocking the next turn.
343
+ signal: composeSignals([signal, AbortSignal.timeout(TITLE_TIMEOUT_MS)]),
344
+ maxTokens: request.maxOutputTokens,
345
+ },
182
346
  );
347
+ if (response.stopReason !== "stop") return "";
183
348
  return response.content
184
349
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
185
350
  .map((c) => c.text)
@@ -187,12 +352,67 @@ export async function generateTitle(
187
352
  .trim();
188
353
  }
189
354
 
355
+ // ---------------------------------------------------------------------------
356
+ // Classifier path — auto mode's KEEP/NEW verdict as a bool classify
357
+ // ---------------------------------------------------------------------------
358
+
359
+ type ClassifierLike = Parameters<ExtensionContext["modelRegistry"]["classify"]>[0];
360
+
361
+ /** First available classifier model, or null when the host has none. */
362
+ export async function resolveClassifierModel(ctx: ExtensionContext): Promise<ClassifierLike | null> {
363
+ const registry = ctx.modelRegistry as ExtensionContext["modelRegistry"] & {
364
+ getAvailableOfType?: (type: "classifier", provider?: string) => Promise<readonly unknown[]>;
365
+ };
366
+ if (typeof registry.getAvailableOfType !== "function") return null;
367
+ try {
368
+ const available = await registry.getAvailableOfType("classifier");
369
+ return (available[0] as ClassifierLike | undefined) ?? null;
370
+ } catch {
371
+ return null;
372
+ }
373
+ }
374
+
375
+ /** Auto-mode KEEP/NEW verdict via a bool classifier. Returns null when the
376
+ * classifier is unusable — the caller falls back to the verdict request. */
377
+ export async function classifyKeep(
378
+ ctx: ExtensionContext,
379
+ classifier: ClassifierLike,
380
+ currentName: string,
381
+ messages: readonly string[],
382
+ ): Promise<boolean | null> {
383
+ try {
384
+ const result = await ctx.modelRegistry.classify(classifier, {
385
+ state: { currentTitle: currentName },
386
+ questions: {
387
+ keep: {
388
+ type: "bool",
389
+ instructions:
390
+ `Decide whether the session title still matches the conversation. Current title: "${currentName}". ` +
391
+ "It is KEEP (true) only if the title still accurately describes the conversation's current main task.\n\n" +
392
+ frameTitleMessages(messages),
393
+ criteria: {
394
+ true: "The title still accurately describes the conversation's current main task",
395
+ false: "The title is inaccurate or outdated for the conversation's current main task",
396
+ },
397
+ },
398
+ },
399
+ });
400
+ if (result.stopReason !== "stop") return null;
401
+ const answer = result.answers.keep;
402
+ if (answer?.type !== "bool") return null;
403
+ return answer.probability >= 0.5;
404
+ } catch {
405
+ return null; // caller falls back to the verdict request
406
+ }
407
+ }
408
+
190
409
  // ---------------------------------------------------------------------------
191
410
  // Pi extension entry point
192
411
  // ---------------------------------------------------------------------------
193
412
 
194
413
  export default function (pi: ExtensionAPI): void {
195
- let manuallyLocked = false;
414
+ let manuallyLocked = false; // user pinned: /rename or an external session_info_changed
415
+ let inheritedTitle = false; // session_start found an existing title (resume)
196
416
  let inFlight = false;
197
417
  let lastAutoName: string | undefined;
198
418
  let cfg: SessionNameConfig | null = null;
@@ -201,7 +421,8 @@ export default function (pi: ExtensionAPI): void {
201
421
  inFlight = false;
202
422
  lastAutoName = undefined;
203
423
  cfg = null;
204
- manuallyLocked = !!pi.getSessionName();
424
+ manuallyLocked = false;
425
+ inheritedTitle = !!pi.getSessionName();
205
426
  });
206
427
 
207
428
  pi.on("session_info_changed", (event) => {
@@ -212,24 +433,59 @@ export default function (pi: ExtensionAPI): void {
212
433
  pi.on("agent_settled", async (_event, ctx) => {
213
434
  if (cfg === null) cfg = loadConfig(ctx.cwd);
214
435
  if (!cfg.enabled || manuallyLocked || inFlight) return;
436
+ // first/auto never touch a resumed session's inherited title (pi's API
437
+ // carries no title source, so any existing name is treated as pinned);
438
+ // follow treats it as the last revision and keeps tracking
439
+ // (deepseek-harness all-prompts cadence). /rename still pins either way.
440
+ if (inheritedTitle && cfg.mode !== "follow") return;
441
+ inheritedTitle = false;
215
442
 
216
443
  const currentName = pi.getSessionName();
217
444
  if (cfg.mode === "first" && (lastAutoName !== undefined || currentName)) return;
218
445
 
219
446
  inFlight = true;
220
447
  try {
221
- const auth = await resolveModelAndAuth(ctx, cfg);
222
- if (!auth) return;
223
-
224
- const text = buildConversationText(ctx.sessionManager.getBranch());
225
- if (!text.trim()) return;
448
+ // Title generation always uses the current session model; auto mode's
449
+ // KEEP/NEW verdict still prefers a classifier model when present.
450
+ const model = ctx.model ?? null;
451
+ if (!model) return;
452
+
453
+ const messages = buildTitleMessages(ctx.sessionManager.getBranch());
454
+ if (messages.length === 0) return;
455
+ const first = messages[0]!;
456
+
457
+ // Generated text, then normalize; on ANY failure — provider error, abort,
458
+ // timeout, empty or malformed output — the deterministic first-message
459
+ // fallback (deepseek-harness discipline) still names the session instead
460
+ // of leaving it untitled.
461
+ const attempt = async (request: TitleRequest): Promise<string> => {
462
+ try {
463
+ return await generateTitle(request, model, ctx, ctx.signal);
464
+ } catch {
465
+ return "";
466
+ }
467
+ };
468
+ const finalize = (generated: string): string | null =>
469
+ normalizeSessionTitle(generated, cfg!.maxLength) ?? fallbackSessionTitle(first, 8, cfg!.maxLength);
226
470
 
227
471
  let title: string | null;
228
472
  if (cfg.mode === "first" || !currentName) {
229
- title = cleanTitle(await generateTitle(buildFirstPrompt(text, cfg), auth, complete, ctx.signal), cfg.maxLength);
473
+ title = finalize(await attempt(buildTitleRequest("first", messages, cfg.prompt)));
474
+ } else if (cfg.mode === "follow") {
475
+ title = finalize(await attempt(buildTitleRequest("all", messages, cfg.prompt)));
230
476
  } else {
231
- const verdict = await generateTitle(buildAutoPrompt(currentName, text, cfg), auth, complete, ctx.signal);
232
- title = /^keep$/i.test(verdict.trim()) ? null : cleanTitle(verdict, cfg.maxLength);
477
+ // auto: classifier KEEP/NEW when available; otherwise the single
478
+ // complete() verdict (KEEP text or a new title).
479
+ const classifier = await resolveClassifierModel(ctx);
480
+ const keep = classifier ? await classifyKeep(ctx, classifier, currentName, messages) : null;
481
+ if (keep === true) {
482
+ return; // title still matches — no generation call
483
+ } else if (keep === false) {
484
+ title = finalize(await attempt(buildTitleRequest("all", messages, cfg.prompt)));
485
+ } else {
486
+ const verdict = await attempt(buildVerdictRequest(currentName, messages, cfg.prompt));
487
+ title = /^keep$/i.test(verdict.trim()) ? null : finalize(verdict);
488
+ }
233
489
  }
234
490
  if (!title) return;
235
491
 
@@ -237,10 +493,12 @@ export default function (pi: ExtensionAPI): void {
237
493
  // may have fired while generateTitle was in flight.
238
494
  if (manuallyLocked || pi.getSessionName() !== currentName) return;
239
495
 
240
- lastAutoName = title;
241
- pi.setSessionName(title);
496
+ const named = withCreationTime(title, safeSessionFile(ctx), cfg.appendCreationTime);
497
+ lastAutoName = named;
498
+ pi.setSessionName(named);
242
499
  } catch {
243
- // silent: failure does not block the session; first mode retries next round, auto mode skips this round
500
+ // silent: failure does not block the session; the fallback above
501
+ // already covered naming, so simply skip this round
244
502
  } finally {
245
503
  inFlight = false;
246
504
  }
@@ -255,40 +513,54 @@ export default function (pi: ExtensionAPI): void {
255
513
  const name = args.trim();
256
514
 
257
515
  if (name) {
258
- const cleaned = cleanTitle(name, cfg.maxLength);
516
+ const cleaned = normalizeSessionTitle(name, cfg.maxLength);
259
517
  if (!cleaned) {
260
518
  ctx.ui.notify("Invalid name", "warning");
261
519
  return;
262
520
  }
263
- lastAutoName = cleaned;
264
- pi.setSessionName(cleaned);
521
+ const named = withCreationTime(cleaned, safeSessionFile(ctx), cfg.appendCreationTime);
522
+ lastAutoName = named;
523
+ pi.setSessionName(named);
265
524
  ctx.ui.notify(`Renamed to: ${cleaned}`, "info");
266
525
  return;
267
526
  }
268
527
 
269
528
  // no argument → generate a name from the conversation
270
- const auth = await resolveModelAndAuth(ctx, cfg);
271
- if (!auth) {
272
- ctx.ui.notify("Cannot generate a name: model unavailable or no API key", "warning");
529
+ const model = ctx.model ?? null;
530
+ if (!model) {
531
+ ctx.ui.notify("Cannot generate a name: model unavailable", "warning");
273
532
  return;
274
533
  }
275
- const text = buildConversationText(ctx.sessionManager.getBranch());
276
- if (!text.trim()) {
534
+ const messages = buildTitleMessages(ctx.sessionManager.getBranch());
535
+ if (messages.length === 0) {
277
536
  ctx.ui.notify("No conversation to generate a name from yet", "warning");
278
537
  return;
279
538
  }
539
+ const request = buildTitleRequest("first", messages, cfg.prompt);
540
+ let generated = "";
280
541
  try {
281
- const title = cleanTitle(await generateTitle(buildFirstPrompt(text, cfg), auth, complete, ctx.signal), cfg.maxLength);
282
- if (!title) {
283
- ctx.ui.notify("Could not generate a name from the model response", "warning");
284
- return;
285
- }
286
- lastAutoName = title;
287
- pi.setSessionName(title);
288
- ctx.ui.notify(`Renamed to: ${title}`, "info");
542
+ generated = await generateTitle(request, model, ctx, ctx.signal);
289
543
  } catch {
290
- ctx.ui.notify("Failed to generate a name", "error");
544
+ // deterministic fallback below still names the session
545
+ }
546
+ const title = normalizeSessionTitle(generated, cfg.maxLength) ?? fallbackSessionTitle(messages[0]!, 8, cfg.maxLength);
547
+ if (!title) {
548
+ ctx.ui.notify("Could not generate a name from the model response", "warning");
549
+ return;
291
550
  }
551
+ const named = withCreationTime(title, safeSessionFile(ctx), cfg.appendCreationTime);
552
+ lastAutoName = named;
553
+ pi.setSessionName(named);
554
+ ctx.ui.notify(`Renamed to: ${named}`, "info");
292
555
  },
293
556
  });
294
557
  }
558
+
559
+ /** Best-effort session file path (mock hosts may not implement it). */
560
+ function safeSessionFile(ctx: ExtensionContext): string | undefined {
561
+ try {
562
+ return ctx.sessionManager.getSessionFile();
563
+ } catch {
564
+ return undefined;
565
+ }
566
+ }
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.5",
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",
@@ -43,12 +43,12 @@
43
43
  "typecheck": "tsc"
44
44
  },
45
45
  "peerDependencies": {
46
- "@earendil-works/pi-coding-agent": ">=0.84.1",
47
- "@earendil-works/pi-ai": ">=0.84.1"
46
+ "@earendil-works/pi-coding-agent": ">=0.99.0",
47
+ "@earendil-works/pi-ai": ">=0.99.0"
48
48
  },
49
49
  "devDependencies": {
50
- "@earendil-works/pi-coding-agent": "0.84.1",
51
- "@earendil-works/pi-ai": "0.84.1",
50
+ "@earendil-works/pi-coding-agent": "0.99.2",
51
+ "@earendil-works/pi-ai": "0.99.2",
52
52
  "@types/node": "22.19.19",
53
53
  "typescript": "5.9.3",
54
54
  "vitest": "3.2.7"