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,205 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { type InstalledPet, importPetFolder, listPets } from "./loader.ts";
3
+ import { installPetdexPet, listPetdexPets } from "./petdex.ts";
4
+ import type { SpriteAlign, SpriteSize } from "./renderer.ts";
5
+
6
+ export interface SpriteCommandRuntime {
7
+ setCommandContext(ctx: ExtensionContext): void;
8
+ statusText(): string;
9
+ selectPet(id: string): void;
10
+ importPetUrl(url: string): Promise<InstalledPet>;
11
+ show(): void;
12
+ hide(): void;
13
+ setSize(size: SpriteSize): void;
14
+ setLabel(visible: boolean): void;
15
+ setAlign(align: SpriteAlign): void;
16
+ clearTurnStatus(): void;
17
+ setTurnStatusEnabled(enabled: boolean): void;
18
+ clearLiveStatus(): void;
19
+ setLiveStatusEnabled(enabled: boolean): void;
20
+ clearNative(ctx: ExtensionContext): void;
21
+ getSpriteName(): string;
22
+ }
23
+
24
+ const SIZE_VALUES = new Set(["tiny", "small", "medium", "large"]);
25
+ const ALIGN_VALUES = new Set(["left", "right"]);
26
+
27
+ function usage(command = "/pet"): string {
28
+ return `Usage: ${command} [list|choose <id>|import <path>|import-url <url>|create [brief]|gallery|search <query>|preview <slug>|install <slug>|hide|show|size <tiny|small|medium|large>|label <on|off>|align <left|right>|turn-status <on|off|clear>|live-status <on|off|clear>|clear-native]`;
29
+ }
30
+
31
+ function requireValue(value: string, message: string): string {
32
+ if (!value) throw new Error(message);
33
+ return value;
34
+ }
35
+
36
+ function sendUserMessage(pi: ExtensionAPI, ctx: ExtensionCommandContext, message: string): void {
37
+ if (ctx.isIdle()) pi.sendUserMessage(message);
38
+ else pi.sendUserMessage(message, { deliverAs: "followUp" });
39
+ }
40
+
41
+ export function petCreatePrompt(brief: string): string {
42
+ const trimmed = brief.trim();
43
+ return [
44
+ "/skill:pi-sprite-authoring",
45
+ "",
46
+ "Help me create a custom pi-sprite pet folder that can be imported with `/pet import <absolute-folder>`.",
47
+ trimmed
48
+ ? `Initial user brief: ${trimmed}`
49
+ : "Start by asking for the pet concept, style references, and desired states.",
50
+ "Use the packaged pi-sprite-authoring workflow: direction cards when the design is open, a canonical idle anchor before other states, manifest validation, and a final import command.",
51
+ "If the user wants OpenAI/GPT image generation, explain that it requires an OpenAI API key such as `OPENAI_API_KEY`; otherwise offer prompt-only, hand-drawn/reference-driven, or local template paths that do not need that key.",
52
+ ].join("\n");
53
+ }
54
+
55
+ export function registerSpriteCommands(pi: ExtensionAPI, runtime: SpriteCommandRuntime): void {
56
+ async function handlePetCommand(args: string, ctx: ExtensionCommandContext, commandName = "/pet"): Promise<void> {
57
+ runtime.setCommandContext(ctx);
58
+ const [cmd = "", ...rest] = args.trim().split(/\s+/u).filter(Boolean);
59
+ const value = rest.join(" ").trim();
60
+ switch (cmd || "status") {
61
+ case "status":
62
+ ctx.ui.notify(runtime.statusText(), "info");
63
+ break;
64
+ case "list": {
65
+ const pets = listPets();
66
+ ctx.ui.notify(
67
+ pets.length ? pets.map((pet) => `${pet.id} - ${pet.manifest.name}`).join("\n") : "No imported pets yet.",
68
+ "info",
69
+ );
70
+ break;
71
+ }
72
+ case "choose":
73
+ runtime.selectPet(requireValue(value, `Usage: ${commandName} choose <id>`));
74
+ ctx.ui.notify(`Selected ${runtime.getSpriteName()}.`, "info");
75
+ break;
76
+ case "import": {
77
+ const pet = importPetFolder(requireValue(value, `Usage: ${commandName} import <path>`));
78
+ runtime.selectPet(pet.id);
79
+ ctx.ui.notify(`Imported and selected ${pet.manifest.name}.`, "info");
80
+ break;
81
+ }
82
+ case "import-url": {
83
+ const pet = await runtime.importPetUrl(requireValue(value, `Usage: ${commandName} import-url <url>`));
84
+ runtime.selectPet(pet.id);
85
+ ctx.ui.notify(`Imported and selected ${pet.manifest.name}.`, "info");
86
+ break;
87
+ }
88
+ case "create":
89
+ case "author":
90
+ sendUserMessage(pi, ctx, petCreatePrompt(value));
91
+ ctx.ui.notify(
92
+ "Queued pi-sprite authoring help. OpenAI/GPT image generation requires an OpenAI API key such as OPENAI_API_KEY; template, prompt-only, and manual import flows do not.",
93
+ "info",
94
+ );
95
+ break;
96
+ case "gallery":
97
+ case "search": {
98
+ const pets = await listPetdexPets(value);
99
+ ctx.ui.notify(
100
+ pets.length
101
+ ? `Petdex gallery:\n${pets.map((pet) => `${pet.id} - ${pet.displayName}${pet.installed ? " (installed)" : ""}`).join("\n")}`
102
+ : "No Petdex pets matched.",
103
+ "info",
104
+ );
105
+ break;
106
+ }
107
+ case "preview": {
108
+ const slug = requireValue(value, `Usage: ${commandName} preview <slug>`);
109
+ const matches = await listPetdexPets(slug);
110
+ const pet = matches.find((candidate) => candidate.id === slug) ?? matches[0];
111
+ if (!pet) throw new Error(`No Petdex pet found for ${slug}`);
112
+ ctx.ui.notify(
113
+ [
114
+ pet.displayName,
115
+ `id: ${pet.id}`,
116
+ pet.kind ? `kind: ${pet.kind}` : "",
117
+ pet.submittedBy ? `by: ${pet.submittedBy}` : "",
118
+ `installed: ${pet.installed ? "yes" : "no"}`,
119
+ `Install: /pet install ${pet.id}`,
120
+ ]
121
+ .filter(Boolean)
122
+ .join("\n"),
123
+ "info",
124
+ );
125
+ break;
126
+ }
127
+ case "install": {
128
+ const pet = await installPetdexPet(requireValue(value, `Usage: ${commandName} install <slug>`));
129
+ runtime.selectPet(pet.id);
130
+ ctx.ui.notify(`Installed and selected ${pet.manifest.name}.`, "info");
131
+ break;
132
+ }
133
+ case "hide":
134
+ runtime.hide();
135
+ ctx.ui.notify("pi-sprite hidden. /pet show to restore.", "info");
136
+ break;
137
+ case "show":
138
+ runtime.show();
139
+ ctx.ui.notify("pi-sprite shown.", "info");
140
+ break;
141
+ case "size": {
142
+ if (!SIZE_VALUES.has(value)) throw new Error("Usage: /pet size <tiny|small|medium|large>");
143
+ runtime.setSize(value as SpriteSize);
144
+ ctx.ui.notify(`pi-sprite size set to ${value}.`, "info");
145
+ break;
146
+ }
147
+ case "label":
148
+ if (value !== "on" && value !== "off") throw new Error("Usage: /pet label <on|off>");
149
+ runtime.setLabel(value === "on");
150
+ ctx.ui.notify(`pi-sprite label ${value === "on" ? "shown" : "hidden"}.`, "info");
151
+ break;
152
+ case "align":
153
+ if (!ALIGN_VALUES.has(value)) throw new Error("Usage: /pet align <left|right>");
154
+ runtime.setAlign(value as SpriteAlign);
155
+ ctx.ui.notify(`pi-sprite aligned ${value}.`, "info");
156
+ break;
157
+ case "turn-status":
158
+ if (value !== "on" && value !== "off" && value !== "clear") {
159
+ throw new Error("Usage: /pet turn-status <on|off|clear>");
160
+ }
161
+ if (value === "clear") {
162
+ runtime.clearTurnStatus();
163
+ ctx.ui.notify("Cleared pi-sprite turn status.", "info");
164
+ break;
165
+ }
166
+ runtime.setTurnStatusEnabled(value === "on");
167
+ ctx.ui.notify(`pi-sprite turn status ${value === "on" ? "enabled" : "disabled"}.`, "info");
168
+ break;
169
+ case "live-status":
170
+ if (value !== "on" && value !== "off" && value !== "clear") {
171
+ throw new Error("Usage: /pet live-status <on|off|clear>");
172
+ }
173
+ if (value === "clear") {
174
+ runtime.clearLiveStatus();
175
+ ctx.ui.notify("Cleared pi-sprite live status.", "info");
176
+ break;
177
+ }
178
+ runtime.setLiveStatusEnabled(value === "on");
179
+ ctx.ui.notify(`pi-sprite live status ${value === "on" ? "enabled" : "disabled"}.`, "info");
180
+ break;
181
+ case "clear-native":
182
+ runtime.clearNative(ctx);
183
+ ctx.ui.notify("Requested native terminal image cleanup. /pet show redraws the sprite.", "info");
184
+ break;
185
+ default:
186
+ ctx.ui.notify(usage(commandName), "info");
187
+ }
188
+ }
189
+
190
+ pi.registerCommand("pet", {
191
+ description:
192
+ "sprite companion: list | choose <id> | import <path> | import-url <url> | create [brief] | gallery | search <query> | preview <slug> | install <slug> | hide | show | size <tiny|small|medium|large> | label <on|off> | align <left|right> | turn-status <on|off|clear> | live-status <on|off|clear> | clear-native",
193
+ handler: async (args: string, ctx: ExtensionCommandContext) => handlePetCommand(args, ctx, "/pet"),
194
+ });
195
+ pi.registerCommand("sprite", {
196
+ description: "Alias for /pet from the pi-sprite package",
197
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
198
+ if (!args.trim()) {
199
+ ctx.ui.notify("pi-sprite uses /pet as its main command. Try /pet help, /pet create, or /pet import.", "info");
200
+ return;
201
+ }
202
+ await handlePetCommand(args, ctx, "/sprite");
203
+ },
204
+ });
205
+ }
@@ -0,0 +1,75 @@
1
+ export const DEFAULT_DOWNLOAD_MAX_BYTES = 25 * 1024 * 1024;
2
+ export const DEFAULT_DOWNLOAD_TIMEOUT_MS = 30_000;
3
+
4
+ export interface DownloadOptions {
5
+ maxBytes?: number;
6
+ timeoutMs?: number;
7
+ allowLocalhostHttp?: boolean;
8
+ }
9
+
10
+ function isLocalhost(hostname: string): boolean {
11
+ const normalized = hostname.replace(/^\[(.*)\]$/u, "$1");
12
+ return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
13
+ }
14
+
15
+ export function parseSafeDownloadUrl(urlText: string, options: DownloadOptions = {}): URL {
16
+ const url = new URL(urlText);
17
+ if (url.protocol === "https:") return url;
18
+ if (options.allowLocalhostHttp && url.protocol === "http:" && isLocalhost(url.hostname)) return url;
19
+ throw new Error("pet downloads require an https URL");
20
+ }
21
+
22
+ function contentLength(response: Response): number | undefined {
23
+ const value = response.headers.get("content-length");
24
+ if (!value) return undefined;
25
+ const parsed = Number.parseInt(value, 10);
26
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
27
+ }
28
+
29
+ async function readResponseBody(response: Response, maxBytes: number): Promise<Buffer> {
30
+ if (!response.body) {
31
+ const bytes = Buffer.from(await response.arrayBuffer());
32
+ if (bytes.length > maxBytes) throw new Error("download is too large");
33
+ return bytes;
34
+ }
35
+ const reader = response.body.getReader();
36
+ const chunks: Buffer[] = [];
37
+ let total = 0;
38
+ try {
39
+ while (true) {
40
+ const { done, value } = await reader.read();
41
+ if (done) break;
42
+ const chunk = Buffer.from(value);
43
+ total += chunk.length;
44
+ if (total > maxBytes) {
45
+ await reader.cancel("download is too large");
46
+ throw new Error("download is too large");
47
+ }
48
+ chunks.push(chunk);
49
+ }
50
+ } finally {
51
+ reader.releaseLock();
52
+ }
53
+ return Buffer.concat(chunks, total);
54
+ }
55
+
56
+ export async function downloadToBuffer(urlText: string, options: DownloadOptions = {}): Promise<Buffer> {
57
+ const maxBytes = options.maxBytes ?? DEFAULT_DOWNLOAD_MAX_BYTES;
58
+ const timeoutMs = options.timeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
59
+ const url = parseSafeDownloadUrl(urlText, options);
60
+ const controller = new AbortController();
61
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
62
+ try {
63
+ const response = await fetch(url, { headers: { accept: "*/*" }, signal: controller.signal });
64
+ if (response.url) parseSafeDownloadUrl(response.url, options);
65
+ if (!response.ok) throw new Error(`download failed for ${url.href} (${response.status})`);
66
+ const declaredLength = contentLength(response);
67
+ if (declaredLength !== undefined && declaredLength > maxBytes) throw new Error("download is too large");
68
+ return await readResponseBody(response, maxBytes);
69
+ } catch (error) {
70
+ if (error instanceof DOMException && error.name === "AbortError") throw new Error("download timed out");
71
+ throw error;
72
+ } finally {
73
+ clearTimeout(timer);
74
+ }
75
+ }