@fyeeme/pi-session-name 1.0.4 → 1.0.6

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 +29 -0
  2. package/README.md +42 -24
  3. package/index.ts +392 -240
  4. package/package.json +5 -5
package/CHANGELOG.md CHANGED
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.6] - 2026-10-02
11
+
12
+ ### Changed
13
+
14
+ - Dev toolchain pinned to `@earendil-works/pi-coding-agent`/`pi-ai` 1.0.0 (peer floors unchanged, `>=0.99.0`); typecheck and tests pass against 1.0.0 unchanged.
15
+
16
+ ## [1.0.5] - 2026-10-01
17
+
18
+ ### Removed
19
+
20
+ - **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.
21
+ - **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.
22
+
23
+ ### Added
24
+
25
+ - 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`.
26
+ - 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.
27
+ - 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.
28
+ - 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).
29
+ - 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.
30
+
31
+ ### Changed
32
+
33
+ - **BREAKING**: the config file moved from `.pi/session-name.json` to `.pi/agent/session-name.json`.
34
+ - **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.
35
+ - **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.
36
+ - ~~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.)
37
+ - Peer dependency floor raised to `@earendil-works/pi-coding-agent >= 0.99.0`; dev toolchain pinned to 0.99.2.
38
+
10
39
  ## [1.0.4] - 2026-09-17
11
40
 
12
41
  ### Added
package/README.md CHANGED
@@ -7,18 +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
17
  - **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
17
- - **Graceful failure** — model unavailable or no API key? Stays silent, never blocks the session
18
+ - **Graceful failure** — model unavailable or model call fails? Stays silent, never blocks the session
18
19
 
19
20
  ## Prerequisites
20
21
 
21
- - [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()`)
22
23
 
23
24
  ## Installation
24
25
 
@@ -65,7 +66,7 @@ If you want to change behavior, see [Configuration](#configuration).
65
66
 
66
67
  ## Configuration
67
68
 
68
- Create `.pi/session-name.json` in your project root:
69
+ Create `.pi/agent/session-name.json` in your project root:
69
70
 
70
71
  ```json
71
72
  {
@@ -79,12 +80,13 @@ Create `.pi/session-name.json` in your project root:
79
80
 
80
81
  | Option | Default | Env override | Description |
81
82
  |--------|---------|--------------|-------------|
82
- | `mode` | `"first"` | `PI_SESSION_NAME_MODE` | `"first"` — name once; `"auto"` — re-evaluate each turn |
83
- | `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 |
84
86
  | `enabled` | `true` | `PI_SESSION_NAME_ENABLED=false` | Master switch to disable auto-naming |
85
- | `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). |
86
88
 
87
- 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.
88
90
 
89
91
  ### Environment variables only
90
92
 
@@ -94,17 +96,34 @@ If you prefer environment variables over a config file:
94
96
  export PI_SESSION_NAME_MODE=auto
95
97
  export PI_SESSION_NAME_MAX_LENGTH=150
96
98
  export PI_SESSION_NAME_ENABLED=true
97
- export PI_SESSION_NAME_MODEL_PROVIDER=openai
98
- export PI_SESSION_NAME_MODEL_ID=gpt-4o-mini
99
99
  ```
100
100
 
101
101
  ## How it works
102
102
 
103
- 1. On `agent_settled` (after the first turn completes), the extension builds a condensed text of the conversation
104
- 2. It asks the LLM (same model as the session, unless overridden) to generate a descriptive title
105
- 3. The title is set via `pi.setSessionName()`, which updates the resume picker immediately
106
- 4. In `first` mode, it stops there. In `auto` mode, each subsequent turn re-evaluates: the LLM returns either `KEEP` or a new title
107
- 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
108
127
 
109
128
  ## Smoke test
110
129
 
@@ -124,7 +143,7 @@ pi -e @fyeeme/pi-session-name
124
143
  /rename foo # → name is "foo", locked
125
144
  /rename # → generates a fresh name from the conversation
126
145
 
127
- # 5. Graceful degradation (unset model's API key)
146
+ # 5. Graceful degradation (model unavailable / model call fails)
128
147
  # → No errors, session runs normally
129
148
  ```
130
149
 
@@ -134,14 +153,13 @@ This extension exposes utilities that other extensions can import:
134
153
 
135
154
  ```typescript
136
155
  import {
137
- buildConversationText,
138
- cleanTitle,
139
- buildFirstPrompt,
140
- buildAutoPrompt,
156
+ buildTitleMessages,
157
+ normalizeSessionTitle,
158
+ fallbackSessionTitle,
159
+ buildTitleRequest,
160
+ buildVerdictRequest,
141
161
  loadConfig,
142
162
  generateTitle,
143
- parseSessionTitle,
144
- collectRecentSessionTitles,
145
163
  } from "@fyeeme/pi-session-name";
146
164
  ```
147
165
 
package/index.ts CHANGED
@@ -1,14 +1,94 @@
1
- import { readFileSync, readdirSync } from "node:fs";
1
+ import { readFileSync } from "node:fs";
2
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,274 +97,254 @@ 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`);
28
109
 
29
- export function buildConversationText(
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;
112
+
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
- // 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;
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)}`;
84
184
  }
85
185
 
86
- const TITLE_CACHE_TTL_MS = 60_000;
87
- const titleCache = new Map<string, { fetchedAt: number; entries: Array<{ file: string; name: string }> }>();
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;
192
+ }
88
193
 
89
194
  /**
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 → [].
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)
93
198
  */
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
- }
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
+ }
126
204
 
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;
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 = [
209
+ "You decide whether the session title still matches the conversation.",
210
+ `Current title: ${currentName}`,
211
+ "If the title is still accurate, reply with exactly: KEEP",
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.`]),
221
+ ].join("\n");
222
+ return { system, user: frameTitleMessages(messages), maxOutputTokens: TITLE_MAX_OUTPUT_TOKENS[style] };
139
223
  }
140
224
 
141
225
  // ---------------------------------------------------------------------------
142
- // Prompt builders — first-title & auto-rename prompts
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>".
143
230
  // ---------------------------------------------------------------------------
144
231
 
145
- export function buildFirstPrompt(
146
- conversationText: string,
147
- opts: { maxLength?: number; recentTitles?: string[] } = {},
148
- ): string {
149
- const maxLength = opts.maxLength ?? 200;
150
- const lines: string[] = [
151
- "You generate a title so the user can recognize this conversation at a glance in a session list.",
152
- "Rules:",
153
- '- Output ONLY the title text. No quotes, no parentheses, no trailing punctuation, no explanation.',
154
- "- Use the SAME language as the user's first message.",
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");
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;
162
241
  }
163
242
 
164
- export function buildAutoPrompt(
165
- currentName: string,
166
- conversationText: string,
167
- opts: { maxLength?: number; recentTitles?: string[] } = {},
168
- ): string {
169
- const maxLength = opts.maxLength ?? 200;
170
- const hasRecent = (opts.recentTitles ?? []).some((t) => t.trim().length > 0);
171
- const lines: string[] = [
172
- "You decide whether the session title still matches the conversation.",
173
- `- Current title: ${currentName}`,
174
- "If the title is still accurate, reply with exactly: KEEP",
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.",
178
- "Rules for a new title:",
179
- '- ONLY the title text. No quotes, no parentheses, no trailing punctuation, no explanation.',
180
- "- Use the SAME language as the user's first message.",
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");
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())}`;
188
247
  }
189
248
 
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
- };
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}`;
256
+ }
201
257
 
202
258
  // ---------------------------------------------------------------------------
203
259
  // SessionNameConfig + loadConfig
204
260
  // ---------------------------------------------------------------------------
205
261
 
206
262
  export interface SessionNameConfig {
207
- 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;
208
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). */
209
274
  maxLength: number;
210
- model?: { provider: string; id: string };
211
275
  }
212
276
 
213
- 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 };
214
278
 
215
279
  export function loadConfig(cwd: string, env: Record<string, string | undefined> = process.env): SessionNameConfig {
216
280
  let fileCfg: Partial<SessionNameConfig> = {};
217
- const file = join(cwd, CONFIG_DIR_NAME, "session-name.json");
281
+ const file = join(cwd, CONFIG_DIR_NAME, "agent", "session-name.json");
218
282
  try {
219
283
  fileCfg = JSON.parse(readFileSync(file, "utf8")) as Partial<SessionNameConfig>;
220
284
  } catch {
221
285
  // missing or malformed config — ignore, fall back to defaults
222
286
  }
223
287
  const cfg: SessionNameConfig = { ...DEFAULT_CONFIG, ...fileCfg };
224
- 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;
225
292
  if (env.PI_SESSION_NAME_ENABLED === "false") cfg.enabled = false;
293
+ if (env.PI_SESSION_NAME_TIMESTAMP === "false") cfg.appendCreationTime = false;
226
294
  if (env.PI_SESSION_NAME_MAX_LENGTH) {
227
295
  const n = Number(env.PI_SESSION_NAME_MAX_LENGTH);
228
296
  if (Number.isFinite(n) && n > 0) cfg.maxLength = n;
229
297
  }
230
- const provider = env.PI_SESSION_NAME_MODEL_PROVIDER;
231
- const id = env.PI_SESSION_NAME_MODEL_ID;
232
- if (provider && id) cfg.model = { provider, id };
233
298
  return cfg;
234
299
  }
235
300
 
236
301
  // ---------------------------------------------------------------------------
237
- // 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).
238
306
  // ---------------------------------------------------------------------------
239
307
 
240
- export type ModelAuth = { model: Model<any>; apiKey: string; headers: Record<string, string> | undefined };
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;
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 });
250
321
  }
251
- return out;
252
- }
253
-
254
- export async function resolveModelAndAuth(
255
- ctx: ExtensionContext,
256
- cfg: SessionNameConfig,
257
- getModelFn?: (provider: string, id: string) => Model<any> | undefined,
258
- ): Promise<ModelAuth | null> {
259
- const find = getModelFn ?? ((p: string, i: string) => ctx.modelRegistry.find(p, i) as Model<any> | undefined);
260
- const model = cfg.model ? find(cfg.model.provider, cfg.model.id) : ctx.model;
261
- if (!model) return null;
262
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
263
- if (!auth?.ok || !auth.apiKey) return null;
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) };
322
+ return controller.signal;
266
323
  }
267
324
 
268
- // ---------------------------------------------------------------------------
269
- // generateTitle — call LLM to produce a short title
270
- // ---------------------------------------------------------------------------
271
-
272
325
  export async function generateTitle(
273
- prompt: string,
274
- auth: ModelAuth,
275
- completeFn: typeof complete = complete,
326
+ request: TitleRequest,
327
+ model: Model<any>,
328
+ ctx: ExtensionContext,
276
329
  signal?: AbortSignal,
277
330
  ): Promise<string> {
278
- const response = await completeFn(
279
- auth.model,
331
+ const now = Date.now();
332
+ const response = await ctx.modelRegistry.complete(
333
+ model,
280
334
  {
281
335
  messages: [
282
- { 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 },
283
338
  ],
284
339
  },
285
- // ctx.signal (undefined while idle) lets Esc/abort cancel the nested call.
286
- { 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
+ },
287
346
  );
347
+ if (response.stopReason !== "stop") return "";
288
348
  return response.content
289
349
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
290
350
  .map((c) => c.text)
@@ -293,22 +353,66 @@ export async function generateTitle(
293
353
  }
294
354
 
295
355
  // ---------------------------------------------------------------------------
296
- // Pi extension entry point
356
+ // Classifier path — auto mode's KEEP/NEW verdict as a bool classify
297
357
  // ---------------------------------------------------------------------------
298
358
 
299
- const siblingTitles = (ctx: ExtensionContext, excludeNames: string[]): string[] => {
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;
300
367
  try {
301
- return collectRecentSessionTitles(ctx.sessionManager.getSessionDir(), {
302
- currentSessionFile: ctx.sessionManager.getSessionFile(),
303
- excludeNames,
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
+ },
304
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;
305
404
  } catch {
306
- return [];
405
+ return null; // caller falls back to the verdict request
307
406
  }
308
- };
407
+ }
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // Pi extension entry point
411
+ // ---------------------------------------------------------------------------
309
412
 
310
413
  export default function (pi: ExtensionAPI): void {
311
- 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)
312
416
  let inFlight = false;
313
417
  let lastAutoName: string | undefined;
314
418
  let cfg: SessionNameConfig | null = null;
@@ -317,7 +421,8 @@ export default function (pi: ExtensionAPI): void {
317
421
  inFlight = false;
318
422
  lastAutoName = undefined;
319
423
  cfg = null;
320
- manuallyLocked = !!pi.getSessionName();
424
+ manuallyLocked = false;
425
+ inheritedTitle = !!pi.getSessionName();
321
426
  });
322
427
 
323
428
  pi.on("session_info_changed", (event) => {
@@ -328,26 +433,59 @@ export default function (pi: ExtensionAPI): void {
328
433
  pi.on("agent_settled", async (_event, ctx) => {
329
434
  if (cfg === null) cfg = loadConfig(ctx.cwd);
330
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;
331
442
 
332
443
  const currentName = pi.getSessionName();
333
444
  if (cfg.mode === "first" && (lastAutoName !== undefined || currentName)) return;
334
445
 
335
446
  inFlight = true;
336
447
  try {
337
- const auth = await resolveModelAndAuth(ctx, cfg);
338
- if (!auth) return;
339
-
340
- const text = buildConversationText(ctx.sessionManager.getBranch());
341
- 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);
342
470
 
343
471
  let title: string | null;
344
472
  if (cfg.mode === "first" || !currentName) {
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);
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)));
347
476
  } else {
348
- const prompt = buildAutoPrompt(currentName, text, { maxLength: cfg.maxLength, recentTitles: siblingTitles(ctx, currentName ? [currentName] : []) });
349
- const verdict = await generateTitle(prompt, auth, complete, ctx.signal);
350
- 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
+ }
351
489
  }
352
490
  if (!title) return;
353
491
 
@@ -355,10 +493,12 @@ export default function (pi: ExtensionAPI): void {
355
493
  // may have fired while generateTitle was in flight.
356
494
  if (manuallyLocked || pi.getSessionName() !== currentName) return;
357
495
 
358
- lastAutoName = title;
359
- pi.setSessionName(title);
496
+ const named = withCreationTime(title, safeSessionFile(ctx), cfg.appendCreationTime);
497
+ lastAutoName = named;
498
+ pi.setSessionName(named);
360
499
  } catch {
361
- // 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
362
502
  } finally {
363
503
  inFlight = false;
364
504
  }
@@ -373,42 +513,54 @@ export default function (pi: ExtensionAPI): void {
373
513
  const name = args.trim();
374
514
 
375
515
  if (name) {
376
- const cleaned = cleanTitle(name, cfg.maxLength);
516
+ const cleaned = normalizeSessionTitle(name, cfg.maxLength);
377
517
  if (!cleaned) {
378
518
  ctx.ui.notify("Invalid name", "warning");
379
519
  return;
380
520
  }
381
- lastAutoName = cleaned;
382
- pi.setSessionName(cleaned);
521
+ const named = withCreationTime(cleaned, safeSessionFile(ctx), cfg.appendCreationTime);
522
+ lastAutoName = named;
523
+ pi.setSessionName(named);
383
524
  ctx.ui.notify(`Renamed to: ${cleaned}`, "info");
384
525
  return;
385
526
  }
386
527
 
387
528
  // no argument → generate a name from the conversation
388
- const auth = await resolveModelAndAuth(ctx, cfg);
389
- if (!auth) {
390
- 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");
391
532
  return;
392
533
  }
393
- const text = buildConversationText(ctx.sessionManager.getBranch());
394
- if (!text.trim()) {
534
+ const messages = buildTitleMessages(ctx.sessionManager.getBranch());
535
+ if (messages.length === 0) {
395
536
  ctx.ui.notify("No conversation to generate a name from yet", "warning");
396
537
  return;
397
538
  }
539
+ const request = buildTitleRequest("first", messages, cfg.prompt);
540
+ let generated = "";
398
541
  try {
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);
402
- if (!title) {
403
- ctx.ui.notify("Could not generate a name from the model response", "warning");
404
- return;
405
- }
406
- lastAutoName = title;
407
- pi.setSessionName(title);
408
- ctx.ui.notify(`Renamed to: ${title}`, "info");
542
+ generated = await generateTitle(request, model, ctx, ctx.signal);
409
543
  } catch {
410
- ctx.ui.notify("Failed to generate a name", "error");
544
+ // deterministic fallback below still names the session
411
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;
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");
412
555
  },
413
556
  });
414
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.4",
3
+ "version": "1.0.6",
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": "1.0.0",
51
+ "@earendil-works/pi-ai": "1.0.0",
52
52
  "@types/node": "22.19.19",
53
53
  "typescript": "5.9.3",
54
54
  "vitest": "3.2.7"