@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.
- package/CHANGELOG.md +29 -0
- package/README.md +42 -24
- package/index.ts +392 -240
- 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
|
-
- **
|
|
11
|
-
- **
|
|
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
|
-
- **
|
|
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.
|
|
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` | `"
|
|
83
|
-
| `maxLength` | `200` | `PI_SESSION_NAME_MAX_LENGTH` |
|
|
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
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 (
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
34
|
-
const maxCharsPerMessage = opts.maxCharsPerMessage ??
|
|
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" &&
|
|
37
|
-
.map((e) => (
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
//
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
):
|
|
98
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
):
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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: "
|
|
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"
|
|
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
|
-
//
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
326
|
+
request: TitleRequest,
|
|
327
|
+
model: Model<any>,
|
|
328
|
+
ctx: ExtensionContext,
|
|
276
329
|
signal?: AbortSignal,
|
|
277
330
|
): Promise<string> {
|
|
278
|
-
const
|
|
279
|
-
|
|
331
|
+
const now = Date.now();
|
|
332
|
+
const response = await ctx.modelRegistry.complete(
|
|
333
|
+
model,
|
|
280
334
|
{
|
|
281
335
|
messages: [
|
|
282
|
-
{ role: "
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
//
|
|
356
|
+
// Classifier path — auto mode's KEEP/NEW verdict as a bool classify
|
|
297
357
|
// ---------------------------------------------------------------------------
|
|
298
358
|
|
|
299
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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 =
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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;
|
|
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 =
|
|
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
|
-
|
|
382
|
-
|
|
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
|
|
389
|
-
if (!
|
|
390
|
-
ctx.ui.notify("Cannot generate a name: model unavailable
|
|
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
|
|
394
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
47
|
-
"@earendil-works/pi-ai": ">=0.
|
|
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.
|
|
51
|
-
"@earendil-works/pi-ai": "0.
|
|
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"
|