pi-sprite 1.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +7 -0
  4. package/README.md +353 -0
  5. package/examples/README.md +15 -0
  6. package/examples/custom-pets/wumpus-template/README.md +21 -0
  7. package/examples/custom-pets/wumpus-template/pet.json +14 -0
  8. package/examples/petdex-downloads/.gitkeep +0 -0
  9. package/extensions/index.ts +104 -0
  10. package/package.json +77 -0
  11. package/skills/pi-sprite-authoring/SKILL.md +330 -0
  12. package/skills/pi-sprite-authoring/assets/wumpus-template/pet.json +14 -0
  13. package/skills/pi-sprite-authoring/references/character-cohesion-review.md +65 -0
  14. package/skills/pi-sprite-authoring/references/gpt-image-sprite-workflow.md +181 -0
  15. package/skills/pi-sprite-authoring/references/petdex-reference-to-custom-pet.md +155 -0
  16. package/skills/pi-sprite-authoring/references/wumpus-sprite-prompts.md +54 -0
  17. package/skills/pi-sprite-authoring/scripts/assemble_sprite_strip.py +98 -0
  18. package/skills/pi-sprite-authoring/scripts/create-pet-template.mjs +51 -0
  19. package/skills/pi-sprite-authoring/scripts/create_motion_strip.py +110 -0
  20. package/skills/pi-sprite-authoring/scripts/download-petdex-examples.mjs +79 -0
  21. package/skills/pi-sprite-authoring/scripts/openai_sprite_image.py +223 -0
  22. package/skills/pi-sprite-authoring/scripts/remove_sprite_background.py +207 -0
  23. package/src/agent/session-entries.ts +28 -0
  24. package/src/agent/side-completion.ts +94 -0
  25. package/src/agent/side-session-text.ts +20 -0
  26. package/src/agent/side-session-types.ts +12 -0
  27. package/src/agent/side-session.ts +107 -0
  28. package/src/btw/completion.ts +15 -0
  29. package/src/btw/format.ts +30 -0
  30. package/src/btw/index.ts +306 -0
  31. package/src/btw/prompt.ts +35 -0
  32. package/src/btw/recap.ts +56 -0
  33. package/src/btw/session.ts +37 -0
  34. package/src/btw/thread-store.ts +37 -0
  35. package/src/context/index.ts +343 -0
  36. package/src/recap/conversation.ts +22 -0
  37. package/src/recap/direct.ts +15 -0
  38. package/src/recap/format.ts +25 -0
  39. package/src/recap/generation.ts +58 -0
  40. package/src/recap/index.ts +86 -0
  41. package/src/sprite/commands.ts +205 -0
  42. package/src/sprite/download.ts +75 -0
  43. package/src/sprite/kitty-placeholder.ts +441 -0
  44. package/src/sprite/live-status-format.ts +67 -0
  45. package/src/sprite/live-status.ts +48 -0
  46. package/src/sprite/loader.ts +134 -0
  47. package/src/sprite/manifest.ts +87 -0
  48. package/src/sprite/paths.ts +8 -0
  49. package/src/sprite/petdex.ts +133 -0
  50. package/src/sprite/renderer.ts +491 -0
  51. package/src/sprite/runtime.ts +524 -0
  52. package/src/sprite/turn-status-format.ts +112 -0
  53. package/src/sprite/turn-status.ts +88 -0
  54. package/src/ui/overlay.ts +308 -0
@@ -0,0 +1,343 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+
4
+ interface ThemeLike {
5
+ fg(
6
+ color:
7
+ | "accent"
8
+ | "border"
9
+ | "borderAccent"
10
+ | "borderMuted"
11
+ | "success"
12
+ | "error"
13
+ | "warning"
14
+ | "muted"
15
+ | "dim"
16
+ | "text",
17
+ text: string,
18
+ ): string;
19
+ bg(color: "customMessageBg" | "selectedBg" | "toolPendingBg", text: string): string;
20
+ bold(text: string): string;
21
+ }
22
+
23
+ interface Category {
24
+ label: string;
25
+ tokens: number;
26
+ glyph: string;
27
+ color: "accent" | "muted" | "dim" | "warning" | "success" | "borderAccent" | "text";
28
+ detail?: string;
29
+ }
30
+ interface UsageModel {
31
+ modelLabel: string;
32
+ total: number;
33
+ window: number;
34
+ percent: number;
35
+ categories: Category[];
36
+ actualTokens: number;
37
+ estimatedTokens: number;
38
+ }
39
+
40
+ const TOKENS_PER_CHAR = 4;
41
+ const estimate = (text: string) => Math.ceil(text.length / TOKENS_PER_CHAR);
42
+
43
+ function formatTokens(n: number): string {
44
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}m`;
45
+ if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}k`;
46
+ return String(n);
47
+ }
48
+
49
+ function textFromContent(content: unknown): string {
50
+ if (typeof content === "string") return content;
51
+ if (!Array.isArray(content)) return "";
52
+ return content
53
+ .map((part) => {
54
+ if (!part || typeof part !== "object") return "";
55
+ const p = part as { type?: string; text?: string; thinking?: string; arguments?: unknown };
56
+ if (p.type === "text") return p.text ?? "";
57
+ if (p.type === "thinking") return p.thinking ?? "";
58
+ if (p.type === "toolCall") return JSON.stringify(p.arguments ?? {});
59
+ return "";
60
+ })
61
+ .join("\n");
62
+ }
63
+
64
+ export function splitSystemPromptForContextUsage(prompt: string): { systemPrompt: string; skills: string } {
65
+ const skillBlocks = prompt.match(/<available_skills>[\s\S]*?<\/available_skills>/giu) ?? [];
66
+ const skills = skillBlocks.join("\n\n");
67
+ const systemPrompt = prompt.replace(/<available_skills>[\s\S]*?<\/available_skills>/giu, "").trim();
68
+ return { systemPrompt, skills };
69
+ }
70
+
71
+ function activeContextEntries(branchEntries: any[]): any[] {
72
+ let compactionIndex = -1;
73
+ for (let i = branchEntries.length - 1; i >= 0; i--) {
74
+ if (branchEntries[i]?.type === "compaction") {
75
+ compactionIndex = i;
76
+ break;
77
+ }
78
+ }
79
+ if (compactionIndex < 0) return branchEntries;
80
+
81
+ const compaction = branchEntries[compactionIndex];
82
+ const activeEntries: any[] = [compaction];
83
+ let foundFirstKept = !compaction.firstKeptEntryId;
84
+ for (let i = 0; i < compactionIndex; i++) {
85
+ const entry = branchEntries[i];
86
+ if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true;
87
+ if (foundFirstKept) activeEntries.push(entry);
88
+ }
89
+ activeEntries.push(...branchEntries.slice(compactionIndex + 1));
90
+ return activeEntries;
91
+ }
92
+
93
+ export function buildUsage(ctx: ExtensionCommandContext, pi: ExtensionAPI): UsageModel | null {
94
+ const usage = ctx.getContextUsage();
95
+ const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow;
96
+ if (!usage || !contextWindow || usage.percent === null || usage.tokens === null) return null;
97
+
98
+ let messageTokens = 0;
99
+ let toolTokens = 0;
100
+ let customTokens = 0;
101
+ let compactionTokens = 0;
102
+ for (const entry of activeContextEntries(Array.from(ctx.sessionManager.getBranch() as Iterable<any>))) {
103
+ if (entry.type === "message") {
104
+ const role = entry.message?.role;
105
+ const tokens = estimate(textFromContent(entry.message?.content));
106
+ if (role === "toolResult" || role === "bashExecution") toolTokens += tokens;
107
+ else messageTokens += tokens;
108
+ } else if (entry.type === "custom" || entry.type === "custom_message") {
109
+ customTokens += estimate(JSON.stringify(entry.data ?? entry.message ?? entry));
110
+ } else if (entry.type === "compaction" || entry.type === "branch_summary") {
111
+ compactionTokens += estimate(entry.summary ?? "");
112
+ }
113
+ }
114
+
115
+ const splitPrompt = splitSystemPromptForContextUsage(ctx.getSystemPrompt?.() ?? "");
116
+ const systemPromptTokens = estimate(splitPrompt.systemPrompt);
117
+ const skillTokens = estimate(splitPrompt.skills);
118
+ const activeTools = typeof pi.getActiveTools === "function" ? pi.getActiveTools() : [];
119
+ const allTools = typeof pi.getAllTools === "function" ? pi.getAllTools() : [];
120
+ const activeToolDefs = allTools.filter((tool: { name?: string }) => activeTools.includes(tool.name ?? ""));
121
+ const systemToolTokens = estimate(JSON.stringify(activeToolDefs));
122
+ const accounted =
123
+ systemPromptTokens + skillTokens + systemToolTokens + messageTokens + toolTokens + customTokens + compactionTokens;
124
+ const total = usage.tokens;
125
+ const other = Math.max(0, usage.tokens - accounted);
126
+ const free = Math.max(0, contextWindow - usage.tokens);
127
+ const categories: Category[] = [
128
+ {
129
+ label: "System message",
130
+ tokens: systemPromptTokens,
131
+ glyph: "◉",
132
+ color: "muted",
133
+ detail: "Base instructions, project context, and runtime guidance.",
134
+ },
135
+ {
136
+ label: "Skills",
137
+ tokens: skillTokens,
138
+ glyph: "◉",
139
+ color: "warning",
140
+ detail: "Available skill metadata injected separately from the system message.",
141
+ },
142
+ {
143
+ label: "System tools",
144
+ tokens: systemToolTokens,
145
+ glyph: "◉",
146
+ color: "borderAccent",
147
+ detail: "Active tool schemas; MCP tools are usually loaded on demand via /mcp.",
148
+ },
149
+ { label: "Messages", tokens: messageTokens, glyph: "◉", color: "accent" },
150
+ { label: "Tool results", tokens: toolTokens, glyph: "◉", color: "success" },
151
+ {
152
+ label: "Custom entries",
153
+ tokens: customTokens,
154
+ glyph: "◉",
155
+ color: "text",
156
+ detail: "Extension entries, overlays, and other non-message session records.",
157
+ },
158
+ { label: "Compaction", tokens: compactionTokens, glyph: "◉", color: "muted" },
159
+ ];
160
+ if (other > 0) categories.push({ label: "Other", tokens: other, glyph: "◉", color: "muted" });
161
+ categories.push({ label: "Free space", tokens: free, glyph: "□", color: "dim" });
162
+ return {
163
+ modelLabel: ctx.model
164
+ ? `${ctx.model.name ?? ctx.model.id} (${formatTokens(contextWindow)} context)`
165
+ : `${formatTokens(contextWindow)} context`,
166
+ total,
167
+ window: contextWindow,
168
+ percent: Math.min(100, (total / contextWindow) * 100),
169
+ categories,
170
+ actualTokens: usage.tokens,
171
+ estimatedTokens: accounted,
172
+ };
173
+ }
174
+
175
+ function pad(line: string, width: number): string {
176
+ return `${line}${" ".repeat(Math.max(0, width - visibleWidth(line)))}`;
177
+ }
178
+
179
+ function cellColor(category: Category, theme: ThemeLike): (text: string) => string {
180
+ return (text: string) => theme.fg(category.color, text);
181
+ }
182
+
183
+ function renderGrid(model: UsageModel, width: number, theme: ThemeLike): string[] {
184
+ const cols = Math.max(12, Math.min(28, Math.floor(width / 2.2)));
185
+ const rows = 8;
186
+ const totalCells = cols * rows;
187
+ const usedCategories = model.categories.filter((category) => category.label !== "Free space" && category.tokens > 0);
188
+ const cells: string[] = [];
189
+ for (const category of usedCategories) {
190
+ const cellCount = Math.max(1, Math.round((category.tokens / model.window) * totalCells));
191
+ for (let i = 0; i < cellCount && cells.length < totalCells; i++) cells.push(cellColor(category, theme)("◉"));
192
+ }
193
+ while (cells.length < totalCells) cells.push(theme.fg("dim", "□"));
194
+ const lines: string[] = [];
195
+ for (let row = 0; row < rows; row++) lines.push(cells.slice(row * cols, (row + 1) * cols).join(" "));
196
+ return lines;
197
+ }
198
+
199
+ function bar(percent: number, width: number, theme: ThemeLike): string {
200
+ const clamped = Math.max(0, Math.min(100, percent));
201
+ const filled = Math.round((clamped / 100) * width);
202
+ return `${theme.fg("accent", "█".repeat(filled))}${theme.fg("dim", "░".repeat(Math.max(0, width - filled)))}`;
203
+ }
204
+
205
+ function row(left: string, right: string, width: number, gap = 2): string {
206
+ const rightWidth = visibleWidth(right);
207
+ const leftWidth = Math.max(0, width - rightWidth - gap);
208
+ return `${pad(truncateToWidth(left, leftWidth), leftWidth)}${" ".repeat(gap)}${truncateToWidth(right, rightWidth)}`;
209
+ }
210
+
211
+ function categoryLine(category: Category, model: UsageModel, width: number, theme: ThemeLike): string {
212
+ const pct = model.window ? (category.tokens / model.window) * 100 : 0;
213
+ const left = `${theme.fg(category.color, category.glyph)} ${theme.fg(category.color, category.label)}`;
214
+ const right = `${formatTokens(category.tokens)} (${pct.toFixed(1)}%)`;
215
+ return row(left, theme.fg(category.label === "Free space" ? "dim" : "muted", right), width);
216
+ }
217
+
218
+ function box(title: string, body: string[], width: number, theme: ThemeLike): string[] {
219
+ const inner = Math.max(12, width - 2);
220
+ const label = ` ${title} `;
221
+ const top = `${theme.fg("border", "╭─")}${theme.fg("accent", theme.bold(label))}${theme.fg("border", "─".repeat(Math.max(0, inner - visibleWidth(label) - 1)))}${theme.fg("border", "╮")}`;
222
+ const bottom = `${theme.fg("border", "╰")}${theme.fg("border", "─".repeat(inner))}${theme.fg("border", "╯")}`;
223
+ return [
224
+ top,
225
+ ...body.map(
226
+ (line) =>
227
+ `${theme.fg("borderMuted", "│")} ${pad(truncateToWidth(line, inner - 2), inner - 2)} ${theme.fg("borderMuted", "│")}`,
228
+ ),
229
+ bottom,
230
+ ];
231
+ }
232
+
233
+ function combineColumns(left: string[], right: string[], leftWidth: number, rightWidth: number): string[] {
234
+ const lines: string[] = [];
235
+ const count = Math.max(left.length, right.length);
236
+ for (let i = 0; i < count; i++) {
237
+ lines.push(`${pad(left[i] ?? "", leftWidth)} ${pad(right[i] ?? "", rightWidth)}`);
238
+ }
239
+ return lines;
240
+ }
241
+
242
+ export function renderContextOverlayLines(
243
+ model: UsageModel,
244
+ expanded: boolean,
245
+ width: number,
246
+ theme: ThemeLike,
247
+ ): string[] {
248
+ const panelWidth = Math.max(1, width);
249
+ const inner = panelWidth - 4;
250
+ const title = ` /context `;
251
+ const top = `${theme.fg("border", "╭─")}${theme.fg("accent", theme.bold(title))}${theme.fg("border", "─")}${theme.fg("text", " Context Usage ")}${theme.fg("border", "─".repeat(Math.max(0, panelWidth - visibleWidth("╭─") - visibleWidth(title) - visibleWidth("─ Context Usage ") - 1)))}${theme.fg("border", "╮")}`;
252
+ const bottom = `${theme.fg("border", "╰")}${theme.fg("border", "─".repeat(Math.max(0, panelWidth - 2)))}${theme.fg("border", "╯")}`;
253
+ const lines: string[] = [top];
254
+
255
+ const summary = `${formatTokens(model.total)}/${formatTokens(model.window)} tokens (${model.percent.toFixed(1)}%)`;
256
+ lines.push(
257
+ `${theme.fg("borderMuted", "│")} ${pad(row(theme.fg("accent", model.modelLabel), theme.fg("muted", summary), inner), inner)} ${theme.fg("borderMuted", "│")}`,
258
+ );
259
+ lines.push(
260
+ `${theme.fg("borderMuted", "│")} ${pad(bar(model.percent, Math.min(48, inner), theme), inner)} ${theme.fg("borderMuted", "│")}`,
261
+ );
262
+ lines.push(`${theme.fg("borderMuted", "│")} ${pad("", inner)} ${theme.fg("borderMuted", "│")}`);
263
+
264
+ const wide = panelWidth >= 96;
265
+ const categoryWidth = wide ? Math.min(46, Math.floor((inner - 2) * 0.42)) : inner;
266
+ const mapWidth = wide ? inner - categoryWidth - 2 : inner;
267
+ const mapBox = box("Usage map", renderGrid(model, mapWidth - 4, theme), mapWidth, theme);
268
+ const categoryRows = [...model.categories.map((category) => categoryLine(category, model, categoryWidth - 4, theme))];
269
+ const categoryBox = box("Estimated usage", categoryRows, categoryWidth, theme);
270
+ const bodyRows = wide
271
+ ? combineColumns(mapBox, categoryBox, mapWidth, categoryWidth)
272
+ : [...mapBox, "", ...categoryBox];
273
+ for (const bodyRow of bodyRows) {
274
+ lines.push(
275
+ `${theme.fg("borderMuted", "│")} ${pad(truncateToWidth(bodyRow, inner), inner)} ${theme.fg("borderMuted", "│")}`,
276
+ );
277
+ }
278
+
279
+ if (expanded) {
280
+ lines.push(`${theme.fg("borderMuted", "│")} ${pad("", inner)} ${theme.fg("borderMuted", "│")}`);
281
+ const details = [
282
+ ...model.categories
283
+ .filter((category) => category.detail)
284
+ .map(
285
+ (category) =>
286
+ `${theme.fg(category.color, category.label)} ${theme.fg("dim", "—")} ${theme.fg("muted", category.detail ?? "")}`,
287
+ ),
288
+ `${theme.fg("borderAccent", "MCP tools")} ${theme.fg("dim", "—")} ${theme.fg("muted", "/mcp, loaded on demand")}`,
289
+ `${theme.fg("accent", "Custom agents")} ${theme.fg("dim", "—")} ${theme.fg("muted", "/agents, counted when visible in context")}`,
290
+ `${theme.fg("warning", "Skills")} ${theme.fg("dim", "—")} ${theme.fg("muted", "/skills, shown separately from the system message when detected")}`,
291
+ ];
292
+ for (const line of box("Breakdown notes", details, inner, theme)) {
293
+ lines.push(
294
+ `${theme.fg("borderMuted", "│")} ${pad(truncateToWidth(line, inner), inner)} ${theme.fg("borderMuted", "│")}`,
295
+ );
296
+ }
297
+ } else {
298
+ lines.push(`${theme.fg("borderMuted", "│")} ${pad("", inner)} ${theme.fg("borderMuted", "│")}`);
299
+ lines.push(
300
+ `${theme.fg("borderMuted", "│")} ${pad(theme.fg("dim", "/context all to expand · Esc/q/Enter to close"), inner)} ${theme.fg("borderMuted", "│")}`,
301
+ );
302
+ }
303
+ lines.push(bottom);
304
+ return lines.map((line) => theme.bg("customMessageBg", pad(truncateToWidth(line, panelWidth), panelWidth)));
305
+ }
306
+
307
+ class ContextOverlay {
308
+ constructor(
309
+ private readonly model: UsageModel,
310
+ private readonly expanded: boolean,
311
+ private readonly theme: ThemeLike,
312
+ private readonly done: () => void,
313
+ ) {}
314
+ invalidate() {}
315
+ handleInput(data: string) {
316
+ if (matchesKey(data, "escape") || matchesKey(data, "q") || matchesKey(data, "enter")) this.done();
317
+ }
318
+ render(width: number): string[] {
319
+ return renderContextOverlayLines(this.model, this.expanded, width, this.theme);
320
+ }
321
+ }
322
+
323
+ export function registerContextCommand(pi: ExtensionAPI) {
324
+ const handler = async (args: string, ctx: ExtensionCommandContext) => {
325
+ const model = buildUsage(ctx, pi);
326
+ if (!model) return ctx.ui.notify("Context usage info is not available yet. Send a message first.", "warning");
327
+ await ctx.ui.custom(
328
+ (_tui, theme, _kb, done) => new ContextOverlay(model, args.trim() === "all", theme, () => done(undefined)),
329
+ {
330
+ overlay: true,
331
+ overlayOptions: { anchor: "center", width: "92%", minWidth: 78, maxHeight: "90%" },
332
+ },
333
+ );
334
+ };
335
+ pi.registerCommand("context", {
336
+ description: "Show Claude-style context usage visualization",
337
+ handler,
338
+ });
339
+ pi.registerCommand("sprite:context", {
340
+ description: "Show pi-sprite context usage visualization",
341
+ handler,
342
+ });
343
+ }
@@ -0,0 +1,22 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ function extractText(content: unknown): string {
4
+ if (typeof content === "string") return content;
5
+ if (!Array.isArray(content)) return "";
6
+ return content
7
+ .map((p) => (p?.type === "text" ? p.text : ""))
8
+ .filter(Boolean)
9
+ .join("\n");
10
+ }
11
+
12
+ export function sessionConversationText(ctx: ExtensionCommandContext): string {
13
+ const lines: string[] = [];
14
+ for (const entry of ctx.sessionManager.getBranch() as Iterable<any>) {
15
+ if (entry.type !== "message") continue;
16
+ const role = entry.message?.role;
17
+ if (role !== "user" && role !== "assistant") continue;
18
+ const text = extractText(entry.message.content).trim();
19
+ if (text) lines.push(`${role}: ${text.slice(0, 1200)}`);
20
+ }
21
+ return lines.slice(-16).join("\n\n");
22
+ }
@@ -0,0 +1,15 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { completeWithApiKeyText } from "../agent/side-completion.ts";
3
+ import { SYSTEM_PROMPT } from "./format.ts";
4
+ import type { RecapGenerationResult } from "./generation.ts";
5
+ import { recapPrompt } from "./generation.ts";
6
+
7
+ export async function completeRecapWithApiKey(
8
+ ctx: ExtensionCommandContext,
9
+ text: string,
10
+ ): Promise<RecapGenerationResult> {
11
+ const result = await completeWithApiKeyText(ctx, recapPrompt(text), { maxTokens: 500, systemPrompt: SYSTEM_PROMPT });
12
+ return result.ok
13
+ ? { ok: true, recap: result.text, source: "api-key-fallback" }
14
+ : { ok: false, message: result.message };
15
+ }
@@ -0,0 +1,25 @@
1
+ import type { OverlaySection } from "../ui/overlay.ts";
2
+
3
+ export const SYSTEM_PROMPT = `Create a short executive summary of the recent coding-session work for a human returning to the session. Be concrete and concise. Output exactly these labels with compact values: TL;DR, Recent work, Current status, Next. Keep the total under 120 words. Do not use markdown tables. Avoid exhaustive file lists; mention files or commands only when they are essential to understanding status.`;
4
+
5
+ export function recapSections(recap: string): OverlaySection[] {
6
+ const sections: OverlaySection[] = [];
7
+ let current: OverlaySection | undefined;
8
+ for (const rawLine of recap.split("\n")) {
9
+ const line = rawLine.trim();
10
+ const match = /^(TL;DR|Recent work|Current status|Next|Goal|State|Decisions|Files\/commands):\s*(.*)$/iu.exec(line);
11
+ if (match) {
12
+ current = {
13
+ title: match[1],
14
+ body: match[2] || "—",
15
+ accent: ["current status", "next"].includes(match[1].toLowerCase()) ? "success" : "accent",
16
+ };
17
+ sections.push(current);
18
+ } else if (line && current) {
19
+ current.body += `\n${line}`;
20
+ } else if (line) {
21
+ sections.push({ body: line });
22
+ }
23
+ }
24
+ return sections.length ? sections : [{ body: recap }];
25
+ }
@@ -0,0 +1,58 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { completeTextWithSideSessionFallback } from "../agent/side-completion.ts";
3
+ import type { SideCompletionRequest, SideCompletionResult } from "../agent/side-session-types.ts";
4
+ import { SYSTEM_PROMPT } from "./format.ts";
5
+
6
+ export type RecapGenerationResult =
7
+ | { ok: true; recap: string; source: "side-session" | "api-key-fallback" }
8
+ | { ok: false; message: string };
9
+
10
+ type SideSessionAdapter = (
11
+ ctx: ExtensionCommandContext,
12
+ request: SideCompletionRequest,
13
+ ) => Promise<SideCompletionResult>;
14
+
15
+ type DirectCompletionAdapter = (ctx: ExtensionCommandContext, text: string) => Promise<RecapGenerationResult>;
16
+
17
+ type RecapCompletionAdapters = {
18
+ sideSession: SideSessionAdapter;
19
+ direct: DirectCompletionAdapter;
20
+ };
21
+
22
+ type SideCompletionFailure = Extract<SideCompletionResult, { ok: false }>;
23
+
24
+ export function recapPrompt(text: string): string {
25
+ return `Current session:\n\n${text}`;
26
+ }
27
+
28
+ function sideFailureMessage(sideResult: SideCompletionFailure): string {
29
+ return `${sideResult.reason}: ${sideResult.message}`;
30
+ }
31
+
32
+ function recapFailureMessage(sideResult: SideCompletionFailure, directResult: RecapGenerationResult): string {
33
+ const directMessage = directResult.ok ? "unexpected direct fallback success" : directResult.message;
34
+ return [
35
+ "Recap could not run through the ephemeral Pi side session.",
36
+ `Side session: ${sideFailureMessage(sideResult)}`,
37
+ `Direct API-key fallback: ${directMessage}`,
38
+ "Normal chat may still work because it uses Pi's full agent harness.",
39
+ ].join("\n");
40
+ }
41
+
42
+ export async function generateRecapText(
43
+ ctx: ExtensionCommandContext,
44
+ text: string,
45
+ adapters: RecapCompletionAdapters,
46
+ ): Promise<RecapGenerationResult> {
47
+ const request = { prompt: recapPrompt(text), systemPrompt: SYSTEM_PROMPT, maxTokens: 500, timeoutMs: 120_000 };
48
+ const result = await completeTextWithSideSessionFallback(ctx, request, {
49
+ sideSession: async (sideCtx, sideRequest) =>
50
+ await adapters.sideSession(sideCtx as ExtensionCommandContext, sideRequest),
51
+ direct: async (directCtx, _prompt, _maxTokens) => {
52
+ const directResult = await adapters.direct(directCtx as ExtensionCommandContext, text);
53
+ return directResult.ok ? directResult.recap : directResult;
54
+ },
55
+ });
56
+ if (result.ok) return { ok: true, recap: result.text, source: result.source };
57
+ return { ok: false, message: recapFailureMessage(result.sideResult, { ok: false, message: result.directMessage }) };
58
+ }
@@ -0,0 +1,86 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { RECAP_ENTRY } from "../agent/session-entries.ts";
3
+ import { completeWithSideSession } from "../agent/side-session.ts";
4
+ import type { SpriteState } from "../sprite/manifest.ts";
5
+ import { createScrollableSpeechBubble, type SpriteBubblePlacement } from "../ui/overlay.ts";
6
+ import { sessionConversationText } from "./conversation.ts";
7
+ import { completeRecapWithApiKey } from "./direct.ts";
8
+ import { recapSections } from "./format.ts";
9
+ import { generateRecapText } from "./generation.ts";
10
+
11
+ type ActivityStatus = "idle" | "running" | "ready" | "error";
12
+
13
+ interface RecapHooks {
14
+ setState?: (state: SpriteState, options?: { resetMs?: number }) => void;
15
+ setRecapStatus?: (status: ActivityStatus) => void;
16
+ getBubblePlacement?: () => SpriteBubblePlacement;
17
+ getSpriteName?: () => string;
18
+ }
19
+
20
+ async function showRecap(
21
+ ctx: ExtensionCommandContext,
22
+ recap: string,
23
+ placement: SpriteBubblePlacement = { anchor: "center", tail: "none", margin: {} },
24
+ speakerName = "Sprite",
25
+ ): Promise<void> {
26
+ await ctx.ui.custom(
27
+ (_tui, theme, _kb, done) =>
28
+ createScrollableSpeechBubble(
29
+ `${speakerName} recap`,
30
+ recapSections(recap),
31
+ "↵ close · esc close · ↑/↓ scroll",
32
+ theme,
33
+ done,
34
+ {
35
+ tail: placement.tail,
36
+ maxBodyLines: 18,
37
+ minWidth: 56,
38
+ maxWidth: 94,
39
+ },
40
+ ),
41
+ {
42
+ overlay: true,
43
+ overlayOptions: {
44
+ width: "62%",
45
+ minWidth: 56,
46
+ maxHeight: "72%",
47
+ anchor: placement.anchor,
48
+ margin: placement.margin,
49
+ },
50
+ },
51
+ );
52
+ }
53
+
54
+ export { completeRecapWithApiKey, sessionConversationText };
55
+
56
+ export function registerRecapCommand(pi: ExtensionAPI, hooks: RecapHooks = {}) {
57
+ pi.registerCommand("recap", {
58
+ description: "Generate a compact session recap",
59
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
60
+ if (!ctx.model) return ctx.ui.notify("No active model selected for /recap.", "warning");
61
+ const text = sessionConversationText(ctx);
62
+ if (!text) return ctx.ui.notify("No conversation to recap yet.", "info");
63
+ hooks.setState?.("thinking");
64
+ hooks.setRecapStatus?.("running");
65
+ try {
66
+ const result = await generateRecapText(ctx, text, {
67
+ sideSession: completeWithSideSession,
68
+ direct: completeRecapWithApiKey,
69
+ });
70
+ if (!result.ok) {
71
+ hooks.setState?.("error", { resetMs: 2500 });
72
+ hooks.setRecapStatus?.("error");
73
+ return ctx.ui.notify(result.message, "error");
74
+ }
75
+ pi.appendEntry(RECAP_ENTRY, { recap: result.recap, source: result.source, timestamp: Date.now() });
76
+ hooks.setState?.("success", { resetMs: 1800 });
77
+ hooks.setRecapStatus?.("ready");
78
+ await showRecap(ctx, result.recap, hooks.getBubblePlacement?.(), hooks.getSpriteName?.());
79
+ } catch (error) {
80
+ hooks.setState?.("error", { resetMs: 2500 });
81
+ hooks.setRecapStatus?.("error");
82
+ throw error;
83
+ }
84
+ },
85
+ });
86
+ }