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.
- package/CHANGELOG.md +92 -0
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/doc_to_md.ts +471 -0
- package/extension-config.ts +49 -0
- package/fetch.ts +541 -0
- package/package.json +89 -0
- package/scripts/pdf_to_md.py +30 -0
- package/session-name.ts +295 -0
- package/sword-header.ts +86 -0
- package/types/turndown-plugin-gfm.d.ts +8 -0
package/session-name.ts
ADDED
|
@@ -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
|
+
}
|
package/sword-header.ts
ADDED
|
@@ -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
|
+
}
|