pi-quiver 4.0.1 → 4.1.0
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 +4 -0
- package/README.md +13 -4
- package/extensions/session-name.ts +346 -39
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ Published to npm as `pi-quiver` (`pi install npm:pi-quiver`). Pushing a
|
|
|
8
8
|
via OIDC trusted publishing. The release helper at
|
|
9
9
|
`.agents/skills/release/scripts/release.sh` cuts the tag; CI publishes.
|
|
10
10
|
|
|
11
|
+
## v4.1.0 - 2026-08-14
|
|
12
|
+
|
|
13
|
+
- **`session-name`: configurable naming policy** (#5). New `sessionAutoName` keys: `rules` (house conventions appended to the naming prompt, later rules win), `deny` (literal case-insensitive phrases stripped from every name, loose interior whitespace so `"acme corp"` also catches `AcmeCorp`), and `revisitFirstTurn`/`revisitEveryTurns` (re-derive the name once those round-trip counts are crossed; default `0` = off, each revisit is one short LLM call). Machine-generated names are replaced when stale; human-set names only get a non-blocking suggestion notification. Name provenance and cadence persist across resume. Revisits run detached and only after the agent has fully settled, so automated multi-turn runs (chains, workflows) are never renamed or delayed mid-flight.
|
|
14
|
+
|
|
11
15
|
## v4.0.1 - 2026-08-04
|
|
12
16
|
|
|
13
17
|
- **Trimmed `fetch`/`doc_to_md` system-prompt guidelines that duplicated the tool descriptions** (~200 tokens). The two additive bits (degraded-result fidelity warning, grep-the-spilled-file guidance) moved into the tool descriptions; `promptGuidelines` dropped from both tools.
|
package/README.md
CHANGED
|
@@ -64,7 +64,7 @@ A 300 KB changelog page never touches your context window - you get a preview an
|
|
|
64
64
|
| --- | --- | --- |
|
|
65
65
|
| `extensions/fetch.ts` | `fetch` | Retrieve URLs over HTTP(S). HTML -> Markdown (Readability extraction, Turndown conversion). Binary saved untouched to a temp file. GitHub issue/PR/repo/actions-run URLs auto-route through `gh` (falls back to HTTP). Same size gate as `doc_to_md`. |
|
|
66
66
|
| `extensions/doc_to_md.ts` | `doc_to_md` | Convert a local PDF/DOCX/PPTX to Markdown. High-fidelity via `pymupdf4llm` (run through `uv`); degraded pure-JS fallback (`unpdf`) when `uv`/Python is unavailable or conversion times out. DOCX/PPTX convert via LibreOffice first. |
|
|
67
|
-
| `extensions/session-name.ts` | `/session-name` | Manual + opt-in automatic session naming,
|
|
67
|
+
| `extensions/session-name.ts` | `/session-name` | Manual + opt-in automatic session naming, naming rules and deny list, long-session revisits, and Ghostty tab rename. OFF by default. |
|
|
68
68
|
| `extensions/sword-header.ts` | `/builtin-header` | Themed ASCII startup header replacing pi's default logo. OFF by default. |
|
|
69
69
|
| `extensions/fast-mode.ts` | `/fast` | Inject Anthropic fast-mode (`speed: "fast"` + `anthropic-beta: fast-mode-2026-02-01`) into every Claude Opus 4.8 / Opus 5 request, any thinking level. `--fast` flag + `/fast [on\|off\|status]`. OFF by default. |
|
|
70
70
|
| `extensions/provider-stall-watchdog.ts` | - | Opt-in provider-stall recovery, in two tiers: a pre-first-event deadline (`firstEventMs`, 20s) on every provider request in every mode, and the mid-stream pair (warn at 2 min, recover at 4 min) in TUI runs only. Policy D offers each stall to Pi's retry loop until the stall retry budget (`maxStallRetries`, default = `retry.maxRetries`) is exhausted. OFF by default. |
|
|
@@ -141,14 +141,23 @@ These extensions are opt-in via `settings.json` (project `.pi/settings.json` ove
|
|
|
141
141
|
|
|
142
142
|
```jsonc
|
|
143
143
|
{
|
|
144
|
-
"sessionAutoName": {
|
|
145
|
-
|
|
144
|
+
"sessionAutoName": {
|
|
145
|
+
"enabled": false,
|
|
146
|
+
"ghosttyTab": true,
|
|
147
|
+
"rules": [],
|
|
148
|
+
"deny": [],
|
|
149
|
+
"revisitFirstTurn": 0,
|
|
150
|
+
"revisitEveryTurns": 0
|
|
151
|
+
}, // or boolean shorthand
|
|
152
|
+
"swordHeader": false, // or { "enabled": true }
|
|
146
153
|
"fastMode": false, // or { "enabled": true }
|
|
147
154
|
"providerStallWatchdog": false // or { "enabled": true }
|
|
148
155
|
}
|
|
149
156
|
```
|
|
150
157
|
|
|
151
|
-
`sessionAutoName.enabled` makes one extra short LLM call per session (once, after the first turn) to title it; `false` (default) makes no model calls. `
|
|
158
|
+
`sessionAutoName.enabled` makes one extra short LLM call per session (once, after the first turn) to title it; `false` (default) makes no model calls. `rules` appends house conventions to the naming prompt (later rules win when they conflict with the built-ins). Literal, case-insensitive `deny` phrases are stripped from every name; whitespace inside a phrase is loose, so `"acme corp"` also catches `AcmeCorp`. `revisitFirstTurn` re-evaluates the name once that many model round trips have completed, while `revisitEveryTurns` does so at every multiple; both default to `0` (off) because each revisit costs another short LLM call. For example, `10` and `100` mark round trips 10, 100, 200, 300. Revisits only run when the agent has fully settled (idle, nothing queued) - an automated multi-turn run such as a subagent chain is never renamed or delayed mid-flight; cadence points it crossed fire once, at the settle. A machine-generated name is replaced when stale. A name set by a human is never overwritten: the extension strongly prefers it, and announces a suggestion only when the work has clearly moved on. Counts come from the persisted transcript, so they survive resume.
|
|
159
|
+
|
|
160
|
+
`fastMode` only affects `claude-opus-4-8` and `claude-opus-5` requests on Anthropic's `anthropic-messages` API; enabling it opts into premium fast-mode pricing. `--fast` forces it on for one launch; `/fast on|off` toggles live. Proxy providers (opencode, cloudflare-ai-gateway) are excluded. `fastMode`'s header injection needs the `before_provider_headers` hook (pi bundling `@earendil-works/pi-coding-agent` >= 0.80.5); on older pi the beta header is silently not sent. See [doc/fetch.md](doc/fetch.md) and [doc/doc-to-md.md](doc/doc-to-md.md) for the ingestion tools' full reference; session-name/sword-header behavior above is complete.
|
|
152
161
|
|
|
153
162
|
`pi-ai` prices every fast request at standard rates - it has no `usage.speed` support and no request-level pricing modifier - so `fastMode` corrects the reported cost itself: a `message_end` handler scales all four `usage.cost` components by `FAST_MODE_COST_MULTIPLIER` (2x) and returns the corrected message. Persisted session JSONL and pi's own native cost display are always exact, since they're written from this corrected message. pi-cohort's live `Σ$` reflects the correction only when pi-quiver's `message_end` handler runs before pi-cohort's - best-effort, depending on extension load order - and is reconciled on pi-cohort's next `session_start` regardless. The upstream fix (teaching `pi-ai`'s `Usage`/`calculateCost` about `usage.speed`) is the better long-term path and is tracked separately.
|
|
154
163
|
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
* - /session-name [name] : manually set or show the session name.
|
|
5
5
|
* - Auto-naming : after the first agent turn, derive a concise name
|
|
6
6
|
* from the conversation (unless one is already set).
|
|
7
|
+
* - Revisiting : re-derive the name later in a long session, once
|
|
8
|
+
* the work has revealed what it actually is.
|
|
9
|
+
* - Deny list : keep chosen words out of every name, whatever set
|
|
10
|
+
* it - the model, /session-name, or pi's builtin.
|
|
7
11
|
* - Ghostty tab rename : whenever the name changes, rename the current
|
|
8
12
|
* Ghostty tab - but only if the active terminal is
|
|
9
13
|
* really Ghostty.
|
|
@@ -13,8 +17,20 @@
|
|
|
13
17
|
* /session-name command always works.
|
|
14
18
|
*
|
|
15
19
|
* Config (settings.json, project overrides global). Defaults shown:
|
|
16
|
-
* "sessionAutoName": {
|
|
17
|
-
*
|
|
20
|
+
* "sessionAutoName": {
|
|
21
|
+
* "enabled": false,
|
|
22
|
+
* "ghosttyTab": true,
|
|
23
|
+
* "rules": [], // extra naming rules appended to the prompt
|
|
24
|
+
* "deny": [], // phrases stripped from any name
|
|
25
|
+
* "revisitFirstTurn": 0, // re-derive once at this round trip (0 = off)
|
|
26
|
+
* "revisitEveryTurns": 0 // and at every multiple of this (0 = off)
|
|
27
|
+
* }
|
|
28
|
+
* Boolean shorthand: "sessionAutoName": true // enables naming + tab sync
|
|
29
|
+
*
|
|
30
|
+
* Revisiting is opt-in and costs one short LLM call each time it fires, so
|
|
31
|
+
* both cadence knobs default to 0. `revisitFirstTurn: 10` with
|
|
32
|
+
* `revisitEveryTurns: 100` fires at round trips 10, 100, 200, 300 - early
|
|
33
|
+
* once, because the first turn rarely knows what the session is, then rarely.
|
|
18
34
|
*/
|
|
19
35
|
|
|
20
36
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -33,8 +49,30 @@ async function loadComplete(): Promise<CompleteFn> {
|
|
|
33
49
|
return (await import("@earendil-works/pi-ai/compat")).complete;
|
|
34
50
|
}
|
|
35
51
|
|
|
36
|
-
type Config = {
|
|
37
|
-
|
|
52
|
+
type Config = {
|
|
53
|
+
enabled: boolean;
|
|
54
|
+
ghosttyTab: boolean;
|
|
55
|
+
rules: string[];
|
|
56
|
+
deny: string[];
|
|
57
|
+
revisitFirstTurn: number;
|
|
58
|
+
revisitEveryTurns: number;
|
|
59
|
+
};
|
|
60
|
+
const DEFAULT_CONFIG: Config = {
|
|
61
|
+
enabled: false,
|
|
62
|
+
ghosttyTab: true,
|
|
63
|
+
rules: [],
|
|
64
|
+
deny: [],
|
|
65
|
+
revisitFirstTurn: 0,
|
|
66
|
+
revisitEveryTurns: 0,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const stringList = (v: unknown): string[] | undefined =>
|
|
70
|
+
Array.isArray(v) && v.every((s) => typeof s === "string")
|
|
71
|
+
? v.map((s) => s.trim()).filter(Boolean)
|
|
72
|
+
: undefined;
|
|
73
|
+
|
|
74
|
+
const turnCount = (v: unknown): number | undefined =>
|
|
75
|
+
typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : undefined;
|
|
38
76
|
|
|
39
77
|
export function coerce(raw: unknown): Partial<Config> | undefined {
|
|
40
78
|
if (raw === undefined) return undefined;
|
|
@@ -44,6 +82,14 @@ export function coerce(raw: unknown): Partial<Config> | undefined {
|
|
|
44
82
|
const out: Partial<Config> = {};
|
|
45
83
|
if (typeof o.enabled === "boolean") out.enabled = o.enabled;
|
|
46
84
|
if (typeof o.ghosttyTab === "boolean") out.ghosttyTab = o.ghosttyTab;
|
|
85
|
+
const rules = stringList(o.rules);
|
|
86
|
+
if (rules) out.rules = rules;
|
|
87
|
+
const deny = stringList(o.deny);
|
|
88
|
+
if (deny) out.deny = deny;
|
|
89
|
+
const first = turnCount(o.revisitFirstTurn);
|
|
90
|
+
if (first !== undefined) out.revisitFirstTurn = first;
|
|
91
|
+
const every = turnCount(o.revisitEveryTurns);
|
|
92
|
+
if (every !== undefined) out.revisitEveryTurns = every;
|
|
47
93
|
return out;
|
|
48
94
|
}
|
|
49
95
|
return undefined;
|
|
@@ -80,7 +126,11 @@ export function stripSkillBodies(text: string): string {
|
|
|
80
126
|
);
|
|
81
127
|
}
|
|
82
128
|
|
|
83
|
-
export function buildConversationText(
|
|
129
|
+
export function buildConversationText(
|
|
130
|
+
ctx: ExtensionContext,
|
|
131
|
+
maxChars = 4000,
|
|
132
|
+
mostRecent = false,
|
|
133
|
+
): string {
|
|
84
134
|
const sections: string[] = [];
|
|
85
135
|
for (const entry of ctx.sessionManager.getEntries()) {
|
|
86
136
|
const e = entry as { type?: string; message?: { role?: string; content?: unknown } };
|
|
@@ -96,7 +146,8 @@ export function buildConversationText(ctx: ExtensionContext, maxChars = 4000): s
|
|
|
96
146
|
if (text.length > 0) sections.push(`Result: ${text.slice(0, 400)}`);
|
|
97
147
|
}
|
|
98
148
|
}
|
|
99
|
-
|
|
149
|
+
const conversation = sections.join("\n\n");
|
|
150
|
+
return mostRecent ? conversation.slice(-maxChars) : conversation.slice(0, maxChars);
|
|
100
151
|
}
|
|
101
152
|
|
|
102
153
|
export function isGhosttyActive(
|
|
@@ -121,6 +172,91 @@ export function toTabLabel(name: string, maxWords = 4): string {
|
|
|
121
172
|
.join(" ");
|
|
122
173
|
}
|
|
123
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Compile a deny phrase to a matcher. Phrases are literal, not regex: metachars
|
|
177
|
+
* are escaped so a user can't crash naming with a bad pattern from settings.
|
|
178
|
+
* Interior whitespace matches loosely, so one entry covers the spaced and
|
|
179
|
+
* jammed spellings alike (`acme corp` catches `AcmeCorp` and `Acme Corp`).
|
|
180
|
+
* Word boundaries are added only where the phrase itself starts/ends on a word
|
|
181
|
+
* character, since `\b` next to punctuation asserts the opposite of what a
|
|
182
|
+
* reader expects.
|
|
183
|
+
*/
|
|
184
|
+
function denyMatcher(phrase: string): RegExp | undefined {
|
|
185
|
+
const escaped = phrase.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
186
|
+
if (!escaped) return undefined;
|
|
187
|
+
const body = escaped.replace(/\s+/g, "\\s*");
|
|
188
|
+
const trimmed = phrase.trim();
|
|
189
|
+
const word = /[\p{L}\p{N}_]/u;
|
|
190
|
+
const lead = word.test(trimmed[0] ?? "") ? "(?<![\\p{L}\\p{N}_])" : "";
|
|
191
|
+
const tail = word.test(trimmed.at(-1) ?? "") ? "(?![\\p{L}\\p{N}_])" : "";
|
|
192
|
+
return new RegExp(`${lead}${body}${tail}`, "giu");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Strip every deny phrase from a name and tidy the seam left behind: doubled
|
|
197
|
+
* spaces, brackets emptied by the removal, and separators stranded against each
|
|
198
|
+
* other. Structure the author intended is preserved - dropping the middle of
|
|
199
|
+
* `Fix - Acme - login` leaves `Fix - login`, not `Fix login`.
|
|
200
|
+
*
|
|
201
|
+
* Returns `Session` when stripping would empty the name. A generic fallback is
|
|
202
|
+
* less informative, but unlike either a blank or the original it actually
|
|
203
|
+
* honors the deny-list contract.
|
|
204
|
+
*/
|
|
205
|
+
export function applyDenyList(name: string, deny: string[]): string {
|
|
206
|
+
if (deny.length === 0) return name;
|
|
207
|
+
let out = name;
|
|
208
|
+
for (const phrase of deny) {
|
|
209
|
+
const re = denyMatcher(phrase);
|
|
210
|
+
if (re) out = out.replace(re, " ");
|
|
211
|
+
}
|
|
212
|
+
out = out
|
|
213
|
+
.replace(/\s+/g, " ")
|
|
214
|
+
.replace(/\(\s*\)|\[\s*\]|\{\s*\}/g, "")
|
|
215
|
+
.replace(/([-:|/])(?:\s*[-:|/])+/g, "$1")
|
|
216
|
+
.replace(/^[\s\-:|/]+|[\s\-:|/]+$/g, "")
|
|
217
|
+
.replace(/\s+/g, " ")
|
|
218
|
+
.trim();
|
|
219
|
+
return out || "Session";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Whether a revisit point lies in `(prev, n]`. Points are `revisitFirstTurn`
|
|
224
|
+
* once, then every multiple of `revisitEveryTurns` - so 10/100 gives 10, 100,
|
|
225
|
+
* 200. Interval semantics rather than exact-hit because the check only runs
|
|
226
|
+
* when the agent settles: a 30-round-trip automated run never stops exactly on
|
|
227
|
+
* round trip 10, but it does cross it. Either knob at 0 disables its own
|
|
228
|
+
* trigger.
|
|
229
|
+
*/
|
|
230
|
+
export function shouldRevisit(
|
|
231
|
+
prev: number,
|
|
232
|
+
n: number,
|
|
233
|
+
cfg: { revisitFirstTurn: number; revisitEveryTurns: number },
|
|
234
|
+
): boolean {
|
|
235
|
+
if (n <= 0 || n <= prev) return false;
|
|
236
|
+
const from = Math.max(prev, 0);
|
|
237
|
+
if (cfg.revisitFirstTurn > 0 && from < cfg.revisitFirstTurn && n >= cfg.revisitFirstTurn) {
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
return (
|
|
241
|
+
cfg.revisitEveryTurns > 0 &&
|
|
242
|
+
Math.floor(n / cfg.revisitEveryTurns) > Math.floor(from / cfg.revisitEveryTurns)
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Assistant messages so far, i.e. completed model round trips. Derived from the
|
|
248
|
+
* transcript rather than tallied in memory so a resumed session keeps counting
|
|
249
|
+
* where it left off instead of restarting the revisit cadence.
|
|
250
|
+
*/
|
|
251
|
+
export function countRoundTrips(ctx: ExtensionContext): number {
|
|
252
|
+
let n = 0;
|
|
253
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
254
|
+
const e = entry as { type?: string; message?: { role?: string } };
|
|
255
|
+
if (e.type === "message" && e.message?.role === "assistant") n++;
|
|
256
|
+
}
|
|
257
|
+
return n;
|
|
258
|
+
}
|
|
259
|
+
|
|
124
260
|
function renameGhosttyTab(label: string, enabled: boolean): void {
|
|
125
261
|
if (!enabled || !isGhosttyActive()) return;
|
|
126
262
|
// OSC 2: set window/tab title. Ghostty shows this as the tab title and
|
|
@@ -150,21 +286,41 @@ export function parseGeneratedName(raw: string): GeneratedName | undefined {
|
|
|
150
286
|
return { sessionName, tabLabel: toTabLabel(tabLabel) };
|
|
151
287
|
}
|
|
152
288
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
289
|
+
/**
|
|
290
|
+
* A revisit may conclude the existing name still fits, which is the common case
|
|
291
|
+
* and must not cost a rename.
|
|
292
|
+
*/
|
|
293
|
+
export const KEEP = "keep" as const;
|
|
156
294
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
295
|
+
/**
|
|
296
|
+
* Parse a revisit reply: either the KEEP verdict or a fresh two-line name.
|
|
297
|
+
* Checked before {@link parseGeneratedName} because a KEEP reply has no SESSION
|
|
298
|
+
* line and would otherwise be indistinguishable from a failed generation.
|
|
299
|
+
*/
|
|
300
|
+
export function parseRevisitReply(raw: string): typeof KEEP | GeneratedName | undefined {
|
|
301
|
+
if (/^\W*KEEP\W*$/i.test(raw.trim())) return KEEP;
|
|
302
|
+
return parseGeneratedName(raw);
|
|
303
|
+
}
|
|
166
304
|
|
|
167
|
-
|
|
305
|
+
type NameAuthor = "auto" | "human";
|
|
306
|
+
type NameAuthorEntry = { name: string; author: NameAuthor };
|
|
307
|
+
const NAME_AUTHOR_ENTRY = "pi-quiver.session-name-author";
|
|
308
|
+
|
|
309
|
+
type PromptOptions = { rules?: string[]; currentName?: string };
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Build the naming prompt. With `currentName` set this is a revisit: the model
|
|
313
|
+
* is told what the session is already called and given the option to keep it,
|
|
314
|
+
* because a long session usually earns its name early and churning it on every
|
|
315
|
+
* revisit would be worse than not revisiting at all.
|
|
316
|
+
*
|
|
317
|
+
* User `rules` land after the built-in ones so they win on conflict - that is
|
|
318
|
+
* the point of exposing them, since house naming conventions routinely
|
|
319
|
+
* contradict a general-purpose default.
|
|
320
|
+
*/
|
|
321
|
+
export function buildNamingPrompt(conversation: string, opts: PromptOptions = {}): string {
|
|
322
|
+
const { rules = [], currentName } = opts;
|
|
323
|
+
const lines = [
|
|
168
324
|
"Name this work session based on the concrete task being done below.",
|
|
169
325
|
"Reply with EXACTLY two lines:",
|
|
170
326
|
"SESSION: <3-6 word descriptive title>",
|
|
@@ -174,12 +330,41 @@ async function generateName(ctx: ExtensionContext): Promise<GeneratedName | unde
|
|
|
174
330
|
"- Lead with an action verb (e.g. refine, fix, add, rework).",
|
|
175
331
|
"- Preserve ticket/issue IDs (e.g. ABC-123, PROJ-42, #99) verbatim.",
|
|
176
332
|
"- No quotes, no trailing punctuation, plain ASCII.",
|
|
333
|
+
...rules.map((r) => `- ${r}`),
|
|
177
334
|
"Example -> SESSION: Refine Linear Ticket ABC-123 / TAB: Refine ABC-123",
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
335
|
+
];
|
|
336
|
+
if (currentName) {
|
|
337
|
+
lines.push(
|
|
338
|
+
"",
|
|
339
|
+
`This session is already named: ${currentName}`,
|
|
340
|
+
"That name may have been chosen by a human, so prefer keeping it.",
|
|
341
|
+
"Reply with the single word KEEP if it still describes the work below,",
|
|
342
|
+
"even loosely. Only propose a new name if the session has clearly moved",
|
|
343
|
+
"on to different work, or the rules above are now plainly violated.",
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
lines.push("", "<conversation>", conversation, "</conversation>");
|
|
347
|
+
return lines.join("\n");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function generateName(
|
|
351
|
+
ctx: ExtensionContext,
|
|
352
|
+
opts: PromptOptions = {},
|
|
353
|
+
): Promise<typeof KEEP | GeneratedName | undefined> {
|
|
354
|
+
const conversation = buildConversationText(ctx, 4000, Boolean(opts.currentName));
|
|
355
|
+
if (conversation.length < 8) return undefined;
|
|
356
|
+
|
|
357
|
+
const model = ctx.model;
|
|
358
|
+
if (!model) return undefined;
|
|
359
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
360
|
+
// ok=true with apiKey=undefined is the env-key path: the key lives in
|
|
361
|
+
// process.env (e.g. ANTHROPIC_API_KEY), not auth.json. getApiKeyAndHeaders
|
|
362
|
+
// deliberately opts out of the env fallback (includeFallback: false), so it
|
|
363
|
+
// reports no apiKey. Don't bail on that - complete() resolves the env key
|
|
364
|
+
// itself via withEnvApiKey/getEnvApiKey. Only bail when auth genuinely failed.
|
|
365
|
+
if (!auth?.ok) return undefined;
|
|
366
|
+
|
|
367
|
+
const prompt = buildNamingPrompt(conversation, opts);
|
|
183
368
|
|
|
184
369
|
const complete = await loadComplete();
|
|
185
370
|
const response = await complete(
|
|
@@ -197,11 +382,29 @@ async function generateName(ctx: ExtensionContext): Promise<GeneratedName | unde
|
|
|
197
382
|
.map((c) => c.text)
|
|
198
383
|
.join("\n");
|
|
199
384
|
|
|
200
|
-
return parseGeneratedName(raw);
|
|
385
|
+
return opts.currentName ? parseRevisitReply(raw) : parseGeneratedName(raw);
|
|
201
386
|
}
|
|
202
387
|
|
|
203
|
-
|
|
388
|
+
type NameGenerator = (
|
|
389
|
+
ctx: ExtensionContext,
|
|
390
|
+
opts?: PromptOptions,
|
|
391
|
+
) => Promise<typeof KEEP | GeneratedName | undefined>;
|
|
392
|
+
|
|
393
|
+
export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = generateName) {
|
|
204
394
|
let autoNameTried = false;
|
|
395
|
+
// Who chose the current name. A human's wording is never overwritten by a
|
|
396
|
+
// revisit - at most we suggest - so unknown provenance (a resumed session, a
|
|
397
|
+
// rename from outside this extension) is treated as human.
|
|
398
|
+
let nameAuthor: NameAuthor = "human";
|
|
399
|
+
// Round trips already acted on; the next check covers (lastRevisitAt, n].
|
|
400
|
+
let lastRevisitAt = 0;
|
|
401
|
+
let revisitInFlight = false;
|
|
402
|
+
// Last detached revisit, exposed so tests (and a curious host) can await
|
|
403
|
+
// completion of work that deliberately outlives the agent_settled handler.
|
|
404
|
+
let revisitDone: Promise<void> = Promise.resolve();
|
|
405
|
+
// The next name-change event expected from our own write. Tracking the value
|
|
406
|
+
// rather than a synchronous flag also covers hosts that emit the event later.
|
|
407
|
+
let expectedInternalName: string | null = null;
|
|
205
408
|
// The session name is the single source of truth; the tab label is derived
|
|
206
409
|
// from it. We remember the name we last reflected and the (possibly curated)
|
|
207
410
|
// label we wrote so turn_start can re-assert it - pi writes its own OS title
|
|
@@ -214,10 +417,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
214
417
|
// Adopt a name we set ourselves, keeping any curated tab label (auto-naming
|
|
215
418
|
// produces a separate TAB line that need not match the first words of the
|
|
216
419
|
// session name), then write the tab.
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
420
|
+
const recordNameAuthor = (name: string, author: NameAuthor): void => {
|
|
421
|
+
nameAuthor = author;
|
|
422
|
+
pi.appendEntry<NameAuthorEntry>(NAME_AUTHOR_ENTRY, { name, author });
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const restoredNameAuthor = (ctx: ExtensionContext, name: string): NameAuthor => {
|
|
426
|
+
const entries = ctx.sessionManager.getEntries();
|
|
427
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
428
|
+
const entry = entries[i] as {
|
|
429
|
+
type?: string;
|
|
430
|
+
customType?: string;
|
|
431
|
+
data?: Partial<NameAuthorEntry>;
|
|
432
|
+
};
|
|
433
|
+
if (entry.type !== "custom" || entry.customType !== NAME_AUTHOR_ENTRY) continue;
|
|
434
|
+
if (entry.data?.name === name && (entry.data.author === "auto" || entry.data.author === "human")) {
|
|
435
|
+
return entry.data.author;
|
|
436
|
+
}
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
return "human";
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const setName = (
|
|
443
|
+
cfg: Config,
|
|
444
|
+
name: string,
|
|
445
|
+
tabLabel?: string,
|
|
446
|
+
author?: NameAuthor,
|
|
447
|
+
): void => {
|
|
448
|
+
const clean = applyDenyList(name, cfg.deny);
|
|
449
|
+
if (author) recordNameAuthor(clean, author);
|
|
450
|
+
expectedInternalName = clean;
|
|
451
|
+
pi.setSessionName(clean);
|
|
452
|
+
lastSyncedName = clean;
|
|
453
|
+
currentTabLabel = toTabLabel(applyDenyList(tabLabel ?? clean, cfg.deny));
|
|
221
454
|
renameGhosttyTab(currentTabLabel, cfg.ghosttyTab);
|
|
222
455
|
};
|
|
223
456
|
|
|
@@ -241,8 +474,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
241
474
|
const name = args.trim();
|
|
242
475
|
if (name) {
|
|
243
476
|
autoNameTried = true; // manual name wins; don't auto-overwrite later
|
|
244
|
-
setName(loadConfig(ctx), name);
|
|
245
|
-
ctx.ui.notify(`Session named: ${
|
|
477
|
+
setName(loadConfig(ctx), name, undefined, "human");
|
|
478
|
+
ctx.ui.notify(`Session named: ${pi.getSessionName()}`, "info");
|
|
246
479
|
} else {
|
|
247
480
|
const current = pi.getSessionName();
|
|
248
481
|
ctx.ui.notify(current ? `Session: ${current}` : "No session name set", "info");
|
|
@@ -258,16 +491,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
258
491
|
// A loaded/resumed/reloaded session already carries a name; reflect it.
|
|
259
492
|
// The curated tab label is not persisted, so derive from the name.
|
|
260
493
|
autoNameTried = true;
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
renameGhosttyTab(currentTabLabel, cfg.ghosttyTab);
|
|
494
|
+
nameAuthor = restoredNameAuthor(ctx, current);
|
|
495
|
+
setName(cfg, current);
|
|
264
496
|
} else {
|
|
265
497
|
// Fresh session: clear carryover so a new name re-derives cleanly and
|
|
266
498
|
// auto-naming can run again.
|
|
267
499
|
autoNameTried = false;
|
|
500
|
+
nameAuthor = "human";
|
|
268
501
|
lastSyncedName = null;
|
|
269
502
|
currentTabLabel = null;
|
|
270
503
|
}
|
|
504
|
+
lastRevisitAt = countRoundTrips(ctx);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// The deny gate. setName cleans everything this extension writes, but pi's
|
|
508
|
+
// builtin rename and any other extension bypass it, so re-clean whatever the
|
|
509
|
+
// name ends up as. session_info_changed cannot veto a change - it only
|
|
510
|
+
// reports one - so the correction is a second write, guarded against the
|
|
511
|
+
// echo of our own.
|
|
512
|
+
pi.on("session_info_changed", async (_event, ctx) => {
|
|
513
|
+
const current = pi.getSessionName();
|
|
514
|
+
if (current === expectedInternalName) {
|
|
515
|
+
expectedInternalName = null;
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
expectedInternalName = null;
|
|
519
|
+
if (!current) return;
|
|
520
|
+
const cfg = loadConfig(ctx);
|
|
521
|
+
if (!cfg.enabled) return;
|
|
522
|
+
recordNameAuthor(current, "human");
|
|
523
|
+
if (cfg.deny.length === 0) return;
|
|
524
|
+
const clean = applyDenyList(current, cfg.deny);
|
|
525
|
+
if (clean !== current) setName(cfg, clean, undefined, "human");
|
|
271
526
|
});
|
|
272
527
|
|
|
273
528
|
// Re-assert at the start of every turn. This is the only signal we get that
|
|
@@ -283,13 +538,65 @@ export default function (pi: ExtensionAPI) {
|
|
|
283
538
|
if (!cfg.enabled) return; // off by default; opt in via settings.json
|
|
284
539
|
autoNameTried = true;
|
|
285
540
|
try {
|
|
286
|
-
const generated = await
|
|
287
|
-
if (generated && !pi.getSessionName()) {
|
|
288
|
-
setName(cfg, generated.sessionName, generated.tabLabel);
|
|
289
|
-
if (ctx.hasUI) ctx.ui.notify(`Auto-named session: ${
|
|
541
|
+
const generated = await generate(ctx, { rules: cfg.rules });
|
|
542
|
+
if (generated && generated !== KEEP && !pi.getSessionName()) {
|
|
543
|
+
setName(cfg, generated.sessionName, generated.tabLabel, "auto");
|
|
544
|
+
if (ctx.hasUI) ctx.ui.notify(`Auto-named session: ${pi.getSessionName()}`, "info");
|
|
290
545
|
}
|
|
291
546
|
} catch {
|
|
292
547
|
// best-effort; ignore failures
|
|
293
548
|
}
|
|
294
549
|
});
|
|
550
|
+
|
|
551
|
+
// Revisit. A name derived from the first turn describes the opening move,
|
|
552
|
+
// which is frequently not what the session turns out to be about - the work
|
|
553
|
+
// only reveals itself after the agent has been at it a while. Re-deriving at
|
|
554
|
+
// a couple of points catches that drift.
|
|
555
|
+
//
|
|
556
|
+
// agent_settled, not turn_end: it fires only once the run is fully idle (no
|
|
557
|
+
// retry, compaction, or queued continuation pending), so an automated
|
|
558
|
+
// multi-turn workflow - a subagent chain, a gauntlet phase - is never
|
|
559
|
+
// renamed mid-flight and never waits on a naming call. Cadence points the
|
|
560
|
+
// run crossed fire once, at the settle. The LLM call still runs detached so
|
|
561
|
+
// the freed-up UI is not held hostage by a slow provider.
|
|
562
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
563
|
+
if (revisitInFlight) return;
|
|
564
|
+
const cfg = loadConfig(ctx);
|
|
565
|
+
if (!cfg.enabled) return;
|
|
566
|
+
if (cfg.revisitFirstTurn === 0 && cfg.revisitEveryTurns === 0) return;
|
|
567
|
+
const current = pi.getSessionName();
|
|
568
|
+
if (!current) return; // unnamed; auto-naming owns that case
|
|
569
|
+
const n = countRoundTrips(ctx);
|
|
570
|
+
if (!shouldRevisit(lastRevisitAt, n, cfg)) return;
|
|
571
|
+
lastRevisitAt = n;
|
|
572
|
+
revisitInFlight = true;
|
|
573
|
+
revisitDone = (async () => {
|
|
574
|
+
try {
|
|
575
|
+
const result = await generate(ctx, { rules: cfg.rules, currentName: current });
|
|
576
|
+
if (!result || result === KEEP) return;
|
|
577
|
+
if (pi.getSessionName() !== current) return; // renamed under us mid-call
|
|
578
|
+
if (nameAuthor === "auto") {
|
|
579
|
+
setName(cfg, result.sessionName, result.tabLabel, "auto");
|
|
580
|
+
if (ctx.hasUI) ctx.ui.notify(`Renamed session: ${pi.getSessionName()}`, "info");
|
|
581
|
+
} else if (ctx.hasUI) {
|
|
582
|
+
// Human wording is theirs to change; surface the drift and stop.
|
|
583
|
+
const suggestion = applyDenyList(result.sessionName, cfg.deny);
|
|
584
|
+
ctx.ui.notify(
|
|
585
|
+
`Session name looks stale. Suggested: ${suggestion} - /session-name to apply`,
|
|
586
|
+
"info",
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
} catch {
|
|
590
|
+
// best-effort; ignore failures
|
|
591
|
+
} finally {
|
|
592
|
+
revisitInFlight = false;
|
|
593
|
+
}
|
|
594
|
+
})();
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
return { revisitSettled: () => revisitDone };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export default function (pi: ExtensionAPI) {
|
|
601
|
+
installSessionName(pi);
|
|
295
602
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-quiver",
|
|
3
|
-
"version": "4.0
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "Personal pack of Pi coding-agent extensions: context-safe fetch, doc_to_md PDF/DOCX/PPTX-to-Markdown conversion, session naming, a themed ASCII startup header, Opus 4.8 fast mode, and a provider-stall watchdog.",
|
|
5
5
|
"author": "Jacek Juraszek",
|
|
6
6
|
"license": "MIT",
|