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,87 @@
1
+ import { extname, isAbsolute, normalize, sep } from "node:path";
2
+
3
+ export type SpriteState = "idle" | "thinking" | "working" | "success" | "error";
4
+ export const SPRITE_STATES: SpriteState[] = ["idle", "thinking", "working", "success", "error"];
5
+
6
+ export interface PetManifest {
7
+ id: string;
8
+ name: string;
9
+ version?: string;
10
+ author?: string;
11
+ description?: string;
12
+ personality?: string;
13
+ sprites: Partial<Record<SpriteState, string>>;
14
+ frame?: { width?: number; height?: number };
15
+ }
16
+
17
+ const VALID_ASSET_EXTENSIONS = new Set([".png", ".webp", ".gif", ".jpg", ".jpeg"]);
18
+ const MAX_PERSONALITY_LENGTH = 2000;
19
+
20
+ export function normalizePetId(value: string): string {
21
+ return value
22
+ .trim()
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9]+/gu, "-")
25
+ .replace(/^-+|-+$/gu, "")
26
+ .slice(0, 80);
27
+ }
28
+
29
+ function asRecord(value: unknown): Record<string, unknown> {
30
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("pet.json must be an object");
31
+ return value as Record<string, unknown>;
32
+ }
33
+
34
+ function safeRelativeAsset(path: string): string {
35
+ if (!path.trim()) throw new Error("sprite path cannot be empty");
36
+ if (isAbsolute(path)) throw new Error("sprite paths must be relative");
37
+ const normalized = normalize(path);
38
+ if (normalized === ".." || normalized.startsWith(`..${sep}`) || normalized.includes(`${sep}..${sep}`)) {
39
+ throw new Error("sprite paths must stay inside the pet folder");
40
+ }
41
+ if (!VALID_ASSET_EXTENSIONS.has(extname(normalized).toLowerCase())) {
42
+ throw new Error("sprite files must be png, webp, gif, jpg, or jpeg");
43
+ }
44
+ return normalized;
45
+ }
46
+
47
+ function safePersonality(value: unknown): string | undefined {
48
+ if (typeof value !== "string") return undefined;
49
+ const personality = value.trim();
50
+ if (!personality) return undefined;
51
+ if (personality.length > MAX_PERSONALITY_LENGTH) {
52
+ throw new Error(`pet.json personality must be ${MAX_PERSONALITY_LENGTH} characters or fewer`);
53
+ }
54
+ return personality;
55
+ }
56
+
57
+ export function parsePetManifest(raw: unknown): PetManifest {
58
+ const data = asRecord(raw);
59
+ const codexId = typeof data.id === "string" ? data.id : "";
60
+ const id = normalizePetId(codexId);
61
+ if (!id) throw new Error("pet.json missing valid id");
62
+ const name =
63
+ (typeof data.name === "string" && data.name.trim()) ||
64
+ (typeof data.displayName === "string" && data.displayName.trim()) ||
65
+ id;
66
+ const sprites: Partial<Record<SpriteState, string>> = {};
67
+ if (data.sprites && typeof data.sprites === "object" && !Array.isArray(data.sprites)) {
68
+ for (const state of SPRITE_STATES) {
69
+ const value = (data.sprites as Record<string, unknown>)[state];
70
+ if (typeof value === "string") sprites[state] = safeRelativeAsset(value);
71
+ }
72
+ }
73
+ if (typeof data.spritesheetPath === "string") {
74
+ sprites.idle = safeRelativeAsset(data.spritesheetPath);
75
+ }
76
+ if (!sprites.idle) throw new Error("pet.json must define sprites.idle or spritesheetPath");
77
+ return {
78
+ id,
79
+ name,
80
+ version: typeof data.version === "string" ? data.version : undefined,
81
+ author: typeof data.author === "string" ? data.author : undefined,
82
+ description: typeof data.description === "string" ? data.description : undefined,
83
+ personality: safePersonality(data.personality),
84
+ sprites,
85
+ frame: data.frame && typeof data.frame === "object" ? (data.frame as PetManifest["frame"]) : undefined,
86
+ };
87
+ }
@@ -0,0 +1,8 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ export function spriteHome(): string {
5
+ return process.env.PI_SPRITE_HOME || join(homedir(), ".pi", "agent", "pi-sprite");
6
+ }
7
+ export const statePath = () => join(spriteHome(), "state.json");
8
+ export const petsDir = () => join(spriteHome(), "pets");
@@ -0,0 +1,133 @@
1
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { extname, join } from "node:path";
3
+ import { downloadToBuffer, parseSafeDownloadUrl } from "./download.ts";
4
+ import { type InstalledPet, importPetFolder, loadPet } from "./loader.ts";
5
+ import { petsDir } from "./paths.ts";
6
+
7
+ const DEFAULT_PETDEX_MANIFEST_URL = "https://petdex.crafter.run/api/manifest";
8
+
9
+ function petdexManifestUrl(): string {
10
+ return process.env.PI_SPRITE_PETDEX_MANIFEST_URL || DEFAULT_PETDEX_MANIFEST_URL;
11
+ }
12
+
13
+ export interface GalleryPet {
14
+ id: string;
15
+ displayName: string;
16
+ description?: string;
17
+ source: "petdex";
18
+ kind?: string;
19
+ submittedBy?: string;
20
+ petJsonUrl: string;
21
+ spritesheetUrl: string;
22
+ zipUrl?: string | null;
23
+ installed: boolean;
24
+ }
25
+
26
+ interface PetdexManifestPet {
27
+ slug: string;
28
+ displayName: string;
29
+ kind?: string;
30
+ submittedBy?: string;
31
+ petJsonUrl: string;
32
+ spritesheetUrl: string;
33
+ zipUrl?: string | null;
34
+ }
35
+
36
+ function clean(value: unknown): string {
37
+ return typeof value === "string" ? value.trim() : "";
38
+ }
39
+
40
+ function allowLocalhostHttpAssets(): boolean {
41
+ const manifestUrl = parseSafeDownloadUrl(petdexManifestUrl(), { allowLocalhostHttp: true });
42
+ return manifestUrl.protocol === "http:";
43
+ }
44
+
45
+ async function download(url: string): Promise<Buffer> {
46
+ return await downloadToBuffer(url, { allowLocalhostHttp: allowLocalhostHttpAssets() });
47
+ }
48
+
49
+ async function manifestPets(): Promise<PetdexManifestPet[]> {
50
+ const manifest = await downloadToBuffer(petdexManifestUrl(), {
51
+ maxBytes: 2 * 1024 * 1024,
52
+ allowLocalhostHttp: Boolean(process.env.PI_SPRITE_PETDEX_MANIFEST_URL),
53
+ });
54
+ const raw = JSON.parse(manifest.toString("utf8")) as { pets?: unknown[] };
55
+ if (!Array.isArray(raw.pets)) throw new Error("Petdex manifest response is invalid");
56
+ return raw.pets
57
+ .filter((pet): pet is Record<string, unknown> => Boolean(pet && typeof pet === "object" && !Array.isArray(pet)))
58
+ .map((pet) => ({
59
+ slug: clean(pet.slug),
60
+ displayName: clean(pet.displayName),
61
+ kind: clean(pet.kind) || undefined,
62
+ submittedBy: clean(pet.submittedBy) || undefined,
63
+ petJsonUrl: clean(pet.petJsonUrl),
64
+ spritesheetUrl: clean(pet.spritesheetUrl),
65
+ zipUrl: clean(pet.zipUrl) || null,
66
+ }))
67
+ .filter((pet) => pet.slug && pet.displayName && pet.petJsonUrl && pet.spritesheetUrl);
68
+ }
69
+
70
+ function toGalleryPet(pet: PetdexManifestPet): GalleryPet {
71
+ return {
72
+ id: pet.slug,
73
+ displayName: pet.displayName,
74
+ source: "petdex",
75
+ kind: pet.kind,
76
+ submittedBy: pet.submittedBy,
77
+ petJsonUrl: pet.petJsonUrl,
78
+ spritesheetUrl: pet.spritesheetUrl,
79
+ zipUrl: pet.zipUrl,
80
+ installed: Boolean(loadPet(pet.slug)),
81
+ };
82
+ }
83
+
84
+ export async function listPetdexPets(query = ""): Promise<GalleryPet[]> {
85
+ const q = query.toLowerCase().trim();
86
+ return (await manifestPets())
87
+ .filter((pet) => !q || `${pet.slug} ${pet.displayName} ${pet.kind ?? ""}`.toLowerCase().includes(q))
88
+ .slice(0, 30)
89
+ .map(toGalleryPet);
90
+ }
91
+
92
+ export async function getPetdexPet(slug: string): Promise<GalleryPet | undefined> {
93
+ const normalizedSlug = slug.toLowerCase().trim();
94
+ const pet = (await manifestPets()).find((entry) => entry.slug.toLowerCase() === normalizedSlug);
95
+ return pet ? toGalleryPet(pet) : undefined;
96
+ }
97
+
98
+ function spriteNameFromUrl(url: string): string {
99
+ const ext = extname(new URL(url).pathname).toLowerCase();
100
+ return ext === ".png" ? "spritesheet.png" : "spritesheet.webp";
101
+ }
102
+
103
+ export async function installPetdexPet(slug: string): Promise<InstalledPet> {
104
+ const pet = await getPetdexPet(slug);
105
+ if (!pet) throw new Error(`Petdex pet not found: ${slug}`);
106
+ const allowLocalhostHttp = allowLocalhostHttpAssets();
107
+ parseSafeDownloadUrl(pet.petJsonUrl, { allowLocalhostHttp });
108
+ parseSafeDownloadUrl(pet.spritesheetUrl, { allowLocalhostHttp });
109
+ const tmp = join(petsDir(), `.petdex-${pet.id}-${Date.now()}`);
110
+ rmSync(tmp, { recursive: true, force: true });
111
+ mkdirSync(tmp, { recursive: true });
112
+ try {
113
+ const [petJson, spritesheet] = await Promise.all([download(pet.petJsonUrl), download(pet.spritesheetUrl)]);
114
+ const spriteName = spriteNameFromUrl(pet.spritesheetUrl);
115
+ let manifest: Record<string, unknown>;
116
+ try {
117
+ manifest = JSON.parse(petJson.toString("utf8")) as Record<string, unknown>;
118
+ } catch {
119
+ manifest = {};
120
+ }
121
+ manifest.id = pet.id;
122
+ manifest.displayName = pet.displayName;
123
+ manifest.name = pet.displayName;
124
+ manifest.description =
125
+ typeof manifest.description === "string" ? manifest.description : `${pet.displayName} from Petdex.`;
126
+ manifest.spritesheetPath = spriteName;
127
+ writeFileSync(join(tmp, "pet.json"), `${JSON.stringify(manifest, null, 2)}\n`);
128
+ writeFileSync(join(tmp, spriteName), spritesheet);
129
+ return importPetFolder(tmp);
130
+ } finally {
131
+ if (existsSync(tmp)) rmSync(tmp, { recursive: true, force: true });
132
+ }
133
+ }