pi-quiver 3.0.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.
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Session naming.
3
+ *
4
+ * - /session-name [name] : manually set or show the session name.
5
+ * - Auto-naming : after the first agent turn, derive a concise name
6
+ * from the conversation (unless one is already set).
7
+ * - Ghostty tab rename : whenever the name changes, rename the current
8
+ * Ghostty tab - but only if the active terminal is
9
+ * really Ghostty.
10
+ *
11
+ * OFF BY DEFAULT. The automatic behaviors (auto-naming + tab restore on
12
+ * resume) do nothing until explicitly enabled via settings.json. The manual
13
+ * /session-name command always works.
14
+ *
15
+ * Config (settings.json, project overrides global). Defaults shown:
16
+ * "sessionAutoName": { "enabled": false, "ghosttyTab": true }
17
+ * Boolean shorthand: "sessionAutoName": true // enables everything
18
+ */
19
+
20
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
21
+ import { resolveConfig } from "./extension-config.ts";
22
+
23
+ // `complete` moved between pi-ai layouts: older builds re-export it from the
24
+ // package index, newer ones expose it only via the `/compat` subpath. A static
25
+ // import that targets one breaks at load time on the other (and a missing
26
+ // export aborts the whole extension, killing the manual command too). Resolve
27
+ // it lazily at call time, trying the index first then `/compat`.
28
+ type CompleteFn = typeof import("@earendil-works/pi-ai/compat").complete;
29
+
30
+ async function loadComplete(): Promise<CompleteFn> {
31
+ const index = (await import("@earendil-works/pi-ai")) as Record<string, unknown>;
32
+ if (typeof index.complete === "function") return index.complete as CompleteFn;
33
+ return (await import("@earendil-works/pi-ai/compat")).complete;
34
+ }
35
+
36
+ type Config = { enabled: boolean; ghosttyTab: boolean };
37
+ const DEFAULT_CONFIG: Config = { enabled: false, ghosttyTab: true };
38
+
39
+ export function coerce(raw: unknown): Partial<Config> | undefined {
40
+ if (raw === undefined) return undefined;
41
+ if (typeof raw === "boolean") return { enabled: raw, ghosttyTab: raw };
42
+ if (raw && typeof raw === "object") {
43
+ const o = raw as Record<string, unknown>;
44
+ const out: Partial<Config> = {};
45
+ if (typeof o.enabled === "boolean") out.enabled = o.enabled;
46
+ if (typeof o.ghosttyTab === "boolean") out.ghosttyTab = o.ghosttyTab;
47
+ return out;
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ function loadConfig(ctx: ExtensionContext): Config {
53
+ return resolveConfig(ctx.cwd, "sessionAutoName", DEFAULT_CONFIG, coerce);
54
+ }
55
+
56
+ type ContentBlock = { type?: string; text?: string };
57
+
58
+ function extractText(content: unknown): string {
59
+ if (typeof content === "string") return content;
60
+ if (!Array.isArray(content)) return "";
61
+ const parts: string[] = [];
62
+ for (const part of content) {
63
+ if (part && typeof part === "object") {
64
+ const block = part as ContentBlock;
65
+ if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
66
+ }
67
+ }
68
+ return parts.join("\n");
69
+ }
70
+
71
+ /**
72
+ * Skill invocations are stored as `<skill name="x" ...>...16KB body...</skill>`
73
+ * with the user's actual argument appended after the close tag. The boilerplate
74
+ * body is naming poison, so collapse it to `[skill: x] <args>` and keep the args.
75
+ */
76
+ export function stripSkillBodies(text: string): string {
77
+ return text.replace(
78
+ /<skill\s+name="([^"]+)"[^>]*>[\s\S]*?<\/skill>/g,
79
+ (_m, name) => `[skill: ${name}]`,
80
+ );
81
+ }
82
+
83
+ export function buildConversationText(ctx: ExtensionContext, maxChars = 4000): string {
84
+ const sections: string[] = [];
85
+ for (const entry of ctx.sessionManager.getEntries()) {
86
+ const e = entry as { type?: string; message?: { role?: string; content?: unknown } };
87
+ if (e.type !== "message") continue;
88
+ const role = e.message?.role;
89
+ if (role === "user" || role === "assistant") {
90
+ const text = stripSkillBodies(extractText(e.message?.content)).replace(/\s+/g, " ").trim();
91
+ if (text.length > 0) sections.push(`${role === "user" ? "User" : "Assistant"}: ${text}`);
92
+ } else if (role === "toolResult") {
93
+ // Tool results carry strong signal (e.g. the fetched ticket identifier
94
+ // and title). Include a trimmed snippet.
95
+ const text = extractText(e.message?.content).replace(/\s+/g, " ").trim();
96
+ if (text.length > 0) sections.push(`Result: ${text.slice(0, 400)}`);
97
+ }
98
+ }
99
+ return sections.join("\n\n").slice(0, maxChars);
100
+ }
101
+
102
+ export function isGhosttyActive(
103
+ env: NodeJS.ProcessEnv = process.env,
104
+ isTTY: boolean = Boolean(process.stdout.isTTY),
105
+ ): boolean {
106
+ if (!isTTY) return false;
107
+ return (
108
+ env.TERM_PROGRAM === "ghostty" ||
109
+ env.TERM === "xterm-ghostty" ||
110
+ env.GHOSTTY_RESOURCES_DIR != null ||
111
+ env.GHOSTTY_BIN_DIR != null
112
+ );
113
+ }
114
+
115
+ export function toTabLabel(name: string, maxWords = 4): string {
116
+ return name
117
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
118
+ .split(/\s+/)
119
+ .filter(Boolean)
120
+ .slice(0, maxWords)
121
+ .join(" ");
122
+ }
123
+
124
+ function renameGhosttyTab(label: string, enabled: boolean): void {
125
+ if (!enabled || !isGhosttyActive()) return;
126
+ // OSC 2: set window/tab title. Ghostty shows this as the tab title and
127
+ // replaces it entirely.
128
+ const clean = label.trim();
129
+ if (clean) process.stdout.write(`\u001b]2;${clean}\u0007`);
130
+ }
131
+
132
+ type GeneratedName = { sessionName: string; tabLabel: string };
133
+
134
+ /**
135
+ * Parse the model's two-line reply (`SESSION: ...` / `TAB: ...`). Tolerant of
136
+ * surrounding prose, casing, and quotes; caps each field and derives the tab
137
+ * label from the session name when the TAB line is missing. Returns undefined
138
+ * when there is no usable SESSION line.
139
+ */
140
+ export function parseGeneratedName(raw: string): GeneratedName | undefined {
141
+ const clean = (s: string) => s.replace(/["']/g, "").replace(/\s+/g, " ").trim();
142
+ const pick = (re: RegExp) => {
143
+ const m = raw.match(re);
144
+ return m ? clean(m[1]) : "";
145
+ };
146
+ const sessionName = pick(/SESSION:\s*(.+)/i).slice(0, 60);
147
+ let tabLabel = pick(/TAB:\s*(.+)/i).slice(0, 30);
148
+ if (!sessionName) return undefined;
149
+ if (!tabLabel) tabLabel = toTabLabel(sessionName);
150
+ return { sessionName, tabLabel: toTabLabel(tabLabel) };
151
+ }
152
+
153
+ async function generateName(ctx: ExtensionContext): Promise<GeneratedName | undefined> {
154
+ const conversation = buildConversationText(ctx);
155
+ if (conversation.length < 8) return undefined;
156
+
157
+ const model = ctx.model;
158
+ if (!model) return undefined;
159
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
160
+ // ok=true with apiKey=undefined is the env-key path: the key lives in
161
+ // process.env (e.g. ANTHROPIC_API_KEY), not auth.json. getApiKeyAndHeaders
162
+ // deliberately opts out of the env fallback (includeFallback: false), so it
163
+ // reports no apiKey. Don't bail on that - complete() resolves the env key
164
+ // itself via withEnvApiKey/getEnvApiKey. Only bail when auth genuinely failed.
165
+ if (!auth?.ok) return undefined;
166
+
167
+ const prompt = [
168
+ "Name this work session based on the concrete task being done below.",
169
+ "Reply with EXACTLY two lines:",
170
+ "SESSION: <3-6 word descriptive title>",
171
+ "TAB: <1-4 word terse label, need not be a sentence>",
172
+ "Rules:",
173
+ "- Describe the actual task, NOT the tool/skill/command used to start it.",
174
+ "- Lead with an action verb (e.g. refine, fix, add, rework).",
175
+ "- Preserve ticket/issue IDs (e.g. ABC-123, PROJ-42, #99) verbatim.",
176
+ "- No quotes, no trailing punctuation, plain ASCII.",
177
+ "Example -> SESSION: Refine Linear Ticket ABC-123 / TAB: Refine ABC-123",
178
+ "",
179
+ "<conversation>",
180
+ conversation,
181
+ "</conversation>",
182
+ ].join("\n");
183
+
184
+ const complete = await loadComplete();
185
+ const response = await complete(
186
+ model,
187
+ {
188
+ messages: [
189
+ { role: "user" as const, content: [{ type: "text" as const, text: prompt }], timestamp: Date.now() },
190
+ ],
191
+ },
192
+ { apiKey: auth.apiKey, headers: auth.headers, env: auth.env, reasoningEffort: "low" },
193
+ );
194
+
195
+ const raw = response.content
196
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
197
+ .map((c) => c.text)
198
+ .join("\n");
199
+
200
+ return parseGeneratedName(raw);
201
+ }
202
+
203
+ export default function (pi: ExtensionAPI) {
204
+ let autoNameTried = false;
205
+ // The session name is the single source of truth; the tab label is derived
206
+ // from it. We remember the name we last reflected and the (possibly curated)
207
+ // label we wrote so turn_start can re-assert it - pi writes its own OS title
208
+ // (OSC 0: `pi - <name> - <cwd>`) on every name change and session
209
+ // replacement, and that writer runs after our session_start handler. A
210
+ // per-turn re-assert wins the race and keeps the tab in sync with the name.
211
+ let lastSyncedName: string | null = null;
212
+ let currentTabLabel: string | null = null;
213
+
214
+ // Adopt a name we set ourselves, keeping any curated tab label (auto-naming
215
+ // produces a separate TAB line that need not match the first words of the
216
+ // session name), then write the tab.
217
+ const setName = (cfg: Config, name: string, tabLabel?: string): void => {
218
+ pi.setSessionName(name);
219
+ lastSyncedName = name;
220
+ currentTabLabel = toTabLabel(tabLabel ?? name);
221
+ renameGhosttyTab(currentTabLabel, cfg.ghosttyTab);
222
+ };
223
+
224
+ // Re-assert the tab from the current session name. Self-heals when the name
225
+ // changed outside setName (builtin/manual rename we didn't author): the
226
+ // curated label no longer applies, so re-derive from the name.
227
+ const syncTab = (cfg: Config): void => {
228
+ if (!cfg.enabled || !cfg.ghosttyTab) return;
229
+ const name = pi.getSessionName();
230
+ if (!name) return;
231
+ if (name !== lastSyncedName) {
232
+ lastSyncedName = name;
233
+ currentTabLabel = toTabLabel(name);
234
+ }
235
+ if (currentTabLabel) renameGhosttyTab(currentTabLabel, cfg.ghosttyTab);
236
+ };
237
+
238
+ pi.registerCommand("session-name", {
239
+ description: "Set or show session name (usage: /session-name [new name])",
240
+ handler: async (args, ctx) => {
241
+ const name = args.trim();
242
+ if (name) {
243
+ autoNameTried = true; // manual name wins; don't auto-overwrite later
244
+ setName(loadConfig(ctx), name);
245
+ ctx.ui.notify(`Session named: ${name}`, "info");
246
+ } else {
247
+ const current = pi.getSessionName();
248
+ ctx.ui.notify(current ? `Session: ${current}` : "No session name set", "info");
249
+ }
250
+ },
251
+ });
252
+
253
+ pi.on("session_start", async (_event, ctx) => {
254
+ const cfg = loadConfig(ctx);
255
+ if (!cfg.enabled) return; // off by default; opt in via settings.json
256
+ const current = pi.getSessionName();
257
+ if (current) {
258
+ // A loaded/resumed/reloaded session already carries a name; reflect it.
259
+ // The curated tab label is not persisted, so derive from the name.
260
+ autoNameTried = true;
261
+ lastSyncedName = current;
262
+ currentTabLabel = toTabLabel(current);
263
+ renameGhosttyTab(currentTabLabel, cfg.ghosttyTab);
264
+ } else {
265
+ // Fresh session: clear carryover so a new name re-derives cleanly and
266
+ // auto-naming can run again.
267
+ autoNameTried = false;
268
+ lastSyncedName = null;
269
+ currentTabLabel = null;
270
+ }
271
+ });
272
+
273
+ // Re-assert at the start of every turn. This is the only signal we get that
274
+ // fires after pi's own OS-title writer on session replacement, so it keeps
275
+ // the tab pinned to the session name and picks up external renames.
276
+ pi.on("turn_start", async (_event, ctx) => {
277
+ syncTab(loadConfig(ctx));
278
+ });
279
+
280
+ pi.on("agent_end", async (_event, ctx) => {
281
+ if (autoNameTried || pi.getSessionName()) return;
282
+ const cfg = loadConfig(ctx);
283
+ if (!cfg.enabled) return; // off by default; opt in via settings.json
284
+ autoNameTried = true;
285
+ try {
286
+ const generated = await generateName(ctx);
287
+ if (generated && !pi.getSessionName()) {
288
+ setName(cfg, generated.sessionName, generated.tabLabel);
289
+ if (ctx.hasUI) ctx.ui.notify(`Auto-named session: ${generated.sessionName}`, "info");
290
+ }
291
+ } catch {
292
+ // best-effort; ignore failures
293
+ }
294
+ });
295
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Sword header: replaces the startup logo with a hero's greatsword
3
+ * (Michael J. Penick longsword, asciiart.eu), colored by the active theme:
4
+ * hilt/grip/pommel = accent (rust), blade = text (bright steel). Art is used
5
+ * verbatim; only the coloring is ours, so it tracks whatever theme is active.
6
+ *
7
+ * OFF BY DEFAULT. The header is only installed when explicitly enabled via
8
+ * settings.json. The /builtin-header command always works to restore the
9
+ * built-in pi header at runtime.
10
+ *
11
+ * Config (settings.json, project overrides global). Default shown:
12
+ * "swordHeader": false
13
+ * Object form also accepted: "swordHeader": { "enabled": true }
14
+ */
15
+
16
+ import type { ExtensionAPI, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
17
+ import { resolveConfig } from "./extension-config.ts";
18
+
19
+ type Config = { enabled: boolean };
20
+ const DEFAULT_CONFIG: Config = { enabled: false };
21
+
22
+ export function coerce(raw: unknown): Partial<Config> | undefined {
23
+ if (raw === undefined) return undefined;
24
+ if (typeof raw === "boolean") return { enabled: raw };
25
+ if (raw && typeof raw === "object") {
26
+ const o = raw as Record<string, unknown>;
27
+ const out: Partial<Config> = {};
28
+ if (typeof o.enabled === "boolean") out.enabled = o.enabled;
29
+ return out;
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ type Seg = [token: ThemeColor, text: string];
35
+
36
+ // Each line is an array of colored segments. Blade rows split the rust hilt
37
+ // prefix from the steel blade at the guard.
38
+ export function swordLines(): Seg[][] {
39
+ return [
40
+ [["accent", " ___"]],
41
+ [["accent", " ( (("]],
42
+ [["accent", " ) ))"]],
43
+ [["accent", " .::. / /("]],
44
+ [
45
+ ["accent", " 'M .-;-.-.-.-.-.-.-.-.-/| (("],
46
+ ["text", "::::::::::::::::::::::::::::::::::::::::::::::.._"],
47
+ ],
48
+ [
49
+ ["accent", "(J ( ( ( ( ( ( ( ( ( ( ( | ))"],
50
+ ["text", " -====================================- _.>"],
51
+ ],
52
+ [
53
+ ["accent", " `P `-;-`-`-`-`-`-`-`-`-\\| (("],
54
+ ["text", "::::::::::::::::::::::::::::::::::::::::::::::''"],
55
+ ],
56
+ [["accent", " `::' \\ \\("]],
57
+ [["accent", " ) ))"]],
58
+ [["accent", " (_(("]],
59
+ ];
60
+ }
61
+
62
+ function renderSwordLines(theme: Theme): string[] {
63
+ return swordLines().map((segs) => segs.map(([tok, text]) => theme.fg(tok, text)).join(""));
64
+ }
65
+
66
+ export default function (pi: ExtensionAPI) {
67
+ pi.on("session_start", async (_event, ctx) => {
68
+ if (ctx.mode !== "tui") return;
69
+ const cfg = resolveConfig(ctx.cwd, "swordHeader", DEFAULT_CONFIG, coerce);
70
+ if (!cfg.enabled) return;
71
+ ctx.ui.setHeader((_tui, theme) => ({
72
+ render(_width: number): string[] {
73
+ return renderSwordLines(theme);
74
+ },
75
+ invalidate() {},
76
+ }));
77
+ });
78
+
79
+ pi.registerCommand("builtin-header", {
80
+ description: "Restore the built-in pi header",
81
+ handler: async (_args, ctx) => {
82
+ ctx.ui.setHeader(undefined);
83
+ ctx.ui.notify("Built-in header restored", "info");
84
+ },
85
+ });
86
+ }
@@ -0,0 +1,8 @@
1
+ declare module "turndown-plugin-gfm" {
2
+ import type TurndownService from "turndown";
3
+ export type TurndownPlugin = (service: TurndownService) => void;
4
+ export const gfm: TurndownPlugin;
5
+ export const tables: TurndownPlugin;
6
+ export const strikethrough: TurndownPlugin;
7
+ export const taskListItems: TurndownPlugin;
8
+ }