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