@howaboua/pi-pet 0.1.1
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 +16 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/SECURITY.md +24 -0
- package/authoring/PET-GUIDE.md +65 -0
- package/authoring/hatch/HATCH-GUIDE.md +136 -0
- package/authoring/hatch/LICENSE.txt +201 -0
- package/authoring/hatch/NOTICE.md +5 -0
- package/authoring/hatch/pyproject.toml +8 -0
- package/authoring/hatch/references/animation-rows.md +29 -0
- package/authoring/hatch/references/final-assembly.md +67 -0
- package/authoring/hatch/references/look-directions.md +35 -0
- package/authoring/hatch/references/pet-contract.md +40 -0
- package/authoring/hatch/references/qa-rubric.md +34 -0
- package/authoring/hatch/references/reviewer-protocol.md +72 -0
- package/authoring/hatch/references/standard-assembly.md +21 -0
- package/authoring/hatch/references/visual-generation.md +59 -0
- package/authoring/hatch/scripts/approve_cardinals.py +110 -0
- package/authoring/hatch/scripts/assemble_extended_atlas.py +684 -0
- package/authoring/hatch/scripts/combine_direction_blind_verdicts.py +59 -0
- package/authoring/hatch/scripts/compose_atlas.py +171 -0
- package/authoring/hatch/scripts/compose_cardinal_anchor_strip.py +45 -0
- package/authoring/hatch/scripts/derive_running_left_from_running_right.py +164 -0
- package/authoring/hatch/scripts/despill_chroma_edges.py +312 -0
- package/authoring/hatch/scripts/extract_cardinal_anchors.py +153 -0
- package/authoring/hatch/scripts/extract_strip_frames.py +409 -0
- package/authoring/hatch/scripts/inspect_frames.py +278 -0
- package/authoring/hatch/scripts/make_contact_sheet.py +116 -0
- package/authoring/hatch/scripts/make_direction_blind_qa_sheet.py +179 -0
- package/authoring/hatch/scripts/make_direction_qa_sheet.py +172 -0
- package/authoring/hatch/scripts/measure_direction_continuity.py +201 -0
- package/authoring/hatch/scripts/prepare_pet_run.py +1207 -0
- package/authoring/hatch/scripts/render_animation_previews.py +80 -0
- package/authoring/hatch/scripts/validate_atlas.py +299 -0
- package/authoring/hatch/scripts/validate_direction_blind_verdicts.py +100 -0
- package/authoring/hatch/uv.lock +99 -0
- package/authoring/references/import-existing-pet.md +47 -0
- package/authoring/references/pet-format.md +88 -0
- package/changelog.ts +327 -0
- package/deploy/pi-pet-hyprland.conf +10 -0
- package/desktop/build.mjs +194 -0
- package/desktop/package-lock.json +887 -0
- package/desktop/package.json +16 -0
- package/dist/authoring/prepare.mjs +3 -0
- package/dist/authoring/rebuild.mjs +4 -0
- package/dist/authoring/validate.mjs +2 -0
- package/dist/desktop/main.mjs +5 -0
- package/dist/desktop/preload.cjs +1 -0
- package/dist/web/app.js +1 -0
- package/dist/web/catalog.json +1 -0
- package/dist/web/index.html +100 -0
- package/dist/web/manifest.webmanifest +16 -0
- package/dist/web/pet-icon.svg +6 -0
- package/dist/web/spritesheet.webp +0 -0
- package/dist/web/styles.css +592 -0
- package/docs/architecture.md +41 -0
- package/docs/pi-pet.png +0 -0
- package/docs/research.md +17 -0
- package/extensions/desktop-command.ts +249 -0
- package/extensions/desktop-config.ts +152 -0
- package/extensions/desktop-launcher.ts +380 -0
- package/extensions/gippity.ts +24 -0
- package/extensions/index.ts +110 -0
- package/extensions/remote-app.ts +61 -0
- package/package.json +101 -0
- package/pets/clawa/PET.md +27 -0
- package/pets/clawa/pet.json +7 -0
- package/pets/clawa/spritesheet.webp +0 -0
- package/pets/clawa/validation.json +808 -0
- package/src/desktop/attention.ts +87 -0
- package/src/desktop/bridge.ts +31 -0
- package/src/desktop/config.ts +145 -0
- package/src/desktop/cursor-provider.ts +150 -0
- package/src/desktop/main.ts +459 -0
- package/src/desktop/preload.ts +19 -0
- package/src/pet-image-size.ts +72 -0
- package/src/pet-loader.ts +278 -0
- package/src/pet-storage.ts +240 -0
- package/src/protocol/index.ts +81 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { join, resolve, sep } from "node:path";
|
|
3
|
+
import { readImageSize } from "./pet-image-size.ts";
|
|
4
|
+
import {
|
|
5
|
+
ContractError,
|
|
6
|
+
isSafeRelativeAssetPath,
|
|
7
|
+
LIMITS,
|
|
8
|
+
type PetAction,
|
|
9
|
+
type PetCatalog,
|
|
10
|
+
type PetFrame,
|
|
11
|
+
parseActionName,
|
|
12
|
+
} from "./protocol/index.ts";
|
|
13
|
+
|
|
14
|
+
const CELL = { width: 192, height: 208 } as const;
|
|
15
|
+
const STANDARD_ROWS = [
|
|
16
|
+
["idle", 0, [280, 110, 110, 140, 140, 320]],
|
|
17
|
+
["running-right", 1, [120, 120, 120, 120, 120, 120, 120, 220]],
|
|
18
|
+
["running-left", 2, [120, 120, 120, 120, 120, 120, 120, 220]],
|
|
19
|
+
["waving", 3, [140, 140, 140, 280]],
|
|
20
|
+
["jumping", 4, [140, 140, 140, 140, 280]],
|
|
21
|
+
["failed", 5, [140, 140, 140, 140, 140, 140, 140, 240]],
|
|
22
|
+
["waiting", 6, [150, 150, 150, 150, 150, 260]],
|
|
23
|
+
["running", 7, [120, 120, 120, 120, 120, 220]],
|
|
24
|
+
["review", 8, [150, 150, 150, 150, 150, 280]],
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
const ALIASES: Readonly<Record<string, string>> = Object.freeze({
|
|
28
|
+
active: "running",
|
|
29
|
+
error: "failed",
|
|
30
|
+
hello: "waving",
|
|
31
|
+
settled: "review",
|
|
32
|
+
success: "jumping",
|
|
33
|
+
thinking: "running",
|
|
34
|
+
working: "running",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export interface LoadedPet {
|
|
38
|
+
directory: string;
|
|
39
|
+
catalog: PetCatalog;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
43
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
44
|
+
throw new ContractError(`${label} must be an object.`);
|
|
45
|
+
return value as Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function integer(value: unknown, label: string, minimum: number, maximum: number): number {
|
|
49
|
+
if (!Number.isInteger(value) || (value as number) < minimum || (value as number) > maximum) {
|
|
50
|
+
throw new ContractError(`${label} must be an integer from ${minimum} to ${maximum}.`);
|
|
51
|
+
}
|
|
52
|
+
return value as number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readJson(path: string, maximumBytes: number): Promise<unknown> {
|
|
56
|
+
const info = await stat(path);
|
|
57
|
+
if (!info.isFile() || info.size > maximumBytes) throw new ContractError(`${path} is not a bounded regular file.`);
|
|
58
|
+
return JSON.parse(await readFile(path, "utf8")) as unknown;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function atlasFrames(row: number, durations: readonly number[]): PetFrame[] {
|
|
62
|
+
return durations.map((durationMs, column) => ({
|
|
63
|
+
x: column * CELL.width,
|
|
64
|
+
y: row * CELL.height,
|
|
65
|
+
width: CELL.width,
|
|
66
|
+
height: CELL.height,
|
|
67
|
+
durationMs,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseCodexManifest(value: unknown): PetCatalog {
|
|
72
|
+
const manifest = record(value, "pet.json");
|
|
73
|
+
const allowed = new Set(["id", "displayName", "description", "spriteVersionNumber", "spritesheetPath"]);
|
|
74
|
+
for (const key of Object.keys(manifest))
|
|
75
|
+
if (!allowed.has(key)) throw new ContractError(`pet.json has unknown field: ${key}.`);
|
|
76
|
+
const id = parseActionName(manifest["id"], "pet id");
|
|
77
|
+
if (
|
|
78
|
+
typeof manifest["displayName"] !== "string" ||
|
|
79
|
+
!manifest["displayName"].trim() ||
|
|
80
|
+
manifest["displayName"].length > 80
|
|
81
|
+
) {
|
|
82
|
+
throw new ContractError("pet.json displayName must contain 1-80 characters.");
|
|
83
|
+
}
|
|
84
|
+
if (
|
|
85
|
+
typeof manifest["description"] !== "string" ||
|
|
86
|
+
!manifest["description"].trim() ||
|
|
87
|
+
manifest["description"].length > 280
|
|
88
|
+
) {
|
|
89
|
+
throw new ContractError("pet.json description must contain 1-280 characters.");
|
|
90
|
+
}
|
|
91
|
+
if (manifest["spriteVersionNumber"] !== 2) throw new ContractError("Only Codex spriteVersionNumber 2 is supported.");
|
|
92
|
+
if (typeof manifest["spritesheetPath"] !== "string" || !isSafeRelativeAssetPath(manifest["spritesheetPath"])) {
|
|
93
|
+
throw new ContractError("pet.json spritesheetPath must be a safe relative path.");
|
|
94
|
+
}
|
|
95
|
+
const actions: Record<string, PetAction> = {};
|
|
96
|
+
const directions: Record<string, PetAction> = {};
|
|
97
|
+
for (const [name, row, durations] of STANDARD_ROWS) {
|
|
98
|
+
actions[name] = { name, asset: manifest["spritesheetPath"], frames: atlasFrames(row, durations), loop: true };
|
|
99
|
+
}
|
|
100
|
+
for (let index = 0; index < 16; index += 1) {
|
|
101
|
+
const degrees = index * 22.5;
|
|
102
|
+
const name = `look-${String(degrees).padStart(3, "0").replace(".5", "_5")}`;
|
|
103
|
+
const row = index < 8 ? 9 : 10;
|
|
104
|
+
const column = index % 8;
|
|
105
|
+
directions[name] = {
|
|
106
|
+
name,
|
|
107
|
+
asset: manifest["spritesheetPath"],
|
|
108
|
+
frames: [{ x: column * CELL.width, y: row * CELL.height, ...CELL, durationMs: 1_000 }],
|
|
109
|
+
loop: true,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
schemaVersion: 1,
|
|
114
|
+
id,
|
|
115
|
+
displayName: manifest["displayName"].trim(),
|
|
116
|
+
description: manifest["description"].trim(),
|
|
117
|
+
defaultAction: "idle",
|
|
118
|
+
canvas: { ...CELL },
|
|
119
|
+
actions,
|
|
120
|
+
aliases: { ...ALIASES },
|
|
121
|
+
directions,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseOverlayAction(name: string, value: unknown): PetAction {
|
|
126
|
+
const input = record(value, `action ${name}`);
|
|
127
|
+
const allowed = new Set(["asset", "frames", "loop", "next"]);
|
|
128
|
+
for (const key of Object.keys(input))
|
|
129
|
+
if (!allowed.has(key)) throw new ContractError(`action ${name} has unknown field: ${key}.`);
|
|
130
|
+
if (typeof input["asset"] !== "string" || !isSafeRelativeAssetPath(input["asset"])) {
|
|
131
|
+
throw new ContractError(`action ${name} asset must be a safe relative path.`);
|
|
132
|
+
}
|
|
133
|
+
if (!Array.isArray(input["frames"]) || input["frames"].length === 0 || input["frames"].length > LIMITS.frameCount) {
|
|
134
|
+
throw new ContractError(`action ${name} must have 1-${LIMITS.frameCount} frames.`);
|
|
135
|
+
}
|
|
136
|
+
const frames = input["frames"].map((rawFrame, index) => {
|
|
137
|
+
const frame = record(rawFrame, `action ${name} frame ${index}`);
|
|
138
|
+
const keys = new Set(["x", "y", "width", "height", "durationMs"]);
|
|
139
|
+
for (const key of Object.keys(frame))
|
|
140
|
+
if (!keys.has(key)) throw new ContractError(`action ${name} frame ${index} has unknown field: ${key}.`);
|
|
141
|
+
const width = integer(frame["width"], "frame width", 1, 4_096);
|
|
142
|
+
const height = integer(frame["height"], "frame height", 1, 4_096);
|
|
143
|
+
if (width * height > LIMITS.decodedPixels) throw new ContractError(`action ${name} frame ${index} is too large.`);
|
|
144
|
+
return {
|
|
145
|
+
x: integer(frame["x"], "frame x", 0, 32_768),
|
|
146
|
+
y: integer(frame["y"], "frame y", 0, 32_768),
|
|
147
|
+
width,
|
|
148
|
+
height,
|
|
149
|
+
durationMs: integer(frame["durationMs"], "frame durationMs", 16, 60_000),
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
if (typeof input["loop"] !== "boolean") throw new ContractError(`action ${name} loop must be a boolean.`);
|
|
153
|
+
const action: PetAction = { name, asset: input["asset"], frames, loop: input["loop"] };
|
|
154
|
+
if (input["next"] !== undefined) action.next = parseActionName(input["next"], `action ${name} next`);
|
|
155
|
+
return action;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function readOverlay(directory: string): Promise<Record<string, unknown> | undefined> {
|
|
159
|
+
const path = join(directory, "pet.pi.json");
|
|
160
|
+
try {
|
|
161
|
+
return record(await readJson(path, LIMITS.manifestBytes), "pet.pi.json");
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function assertOverlayKeys(overlay: Record<string, unknown>): void {
|
|
169
|
+
const allowed = new Set(["schemaVersion", "defaultAction", "actions", "aliases"]);
|
|
170
|
+
for (const key of Object.keys(overlay))
|
|
171
|
+
if (!allowed.has(key)) throw new ContractError(`pet.pi.json has unknown field: ${key}.`);
|
|
172
|
+
if (overlay["schemaVersion"] !== 1) throw new ContractError("pet.pi.json schemaVersion must be 1.");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function applyOverlayActions(value: unknown, catalog: PetCatalog): void {
|
|
176
|
+
if (value === undefined) return;
|
|
177
|
+
for (const [rawName, actionValue] of Object.entries(record(value, "pet.pi.json actions"))) {
|
|
178
|
+
const name = parseActionName(rawName, "action name");
|
|
179
|
+
catalog.actions[name] = parseOverlayAction(name, actionValue);
|
|
180
|
+
delete catalog.aliases[name];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function applyOverlayAliases(value: unknown, catalog: PetCatalog): void {
|
|
185
|
+
if (value === undefined) return;
|
|
186
|
+
for (const [rawName, rawTarget] of Object.entries(record(value, "pet.pi.json aliases"))) {
|
|
187
|
+
const name = parseActionName(rawName, "alias name");
|
|
188
|
+
if (catalog.actions[name]) throw new ContractError(`Alias ${name} conflicts with an action of the same name.`);
|
|
189
|
+
catalog.aliases[name] = parseActionName(rawTarget, `alias ${name} target`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function validateCatalogLinks(catalog: PetCatalog): void {
|
|
194
|
+
if (!catalog.actions[catalog.defaultAction])
|
|
195
|
+
throw new ContractError(`Unknown defaultAction: ${catalog.defaultAction}.`);
|
|
196
|
+
for (const action of Object.values(catalog.actions)) {
|
|
197
|
+
if (action.next !== undefined && !catalog.actions[action.next])
|
|
198
|
+
throw new ContractError(`Action ${action.name} has unknown next action: ${action.next}.`);
|
|
199
|
+
}
|
|
200
|
+
for (const [alias, target] of Object.entries(catalog.aliases)) {
|
|
201
|
+
if (!catalog.actions[target]) throw new ContractError(`Alias ${alias} has unknown target action: ${target}.`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function assertCatalogSize(catalog: PetCatalog): void {
|
|
206
|
+
if (Buffer.byteLength(JSON.stringify(catalog)) > LIMITS.catalogBytes) {
|
|
207
|
+
throw new ContractError(`Generated pet catalog exceeds ${LIMITS.catalogBytes} bytes.`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function applyOverlay(directory: string, catalog: PetCatalog): Promise<void> {
|
|
212
|
+
const overlay = await readOverlay(directory);
|
|
213
|
+
if (!overlay) return;
|
|
214
|
+
assertOverlayKeys(overlay);
|
|
215
|
+
applyOverlayActions(overlay["actions"], catalog);
|
|
216
|
+
applyOverlayAliases(overlay["aliases"], catalog);
|
|
217
|
+
if (overlay["defaultAction"] !== undefined)
|
|
218
|
+
catalog.defaultAction = parseActionName(overlay["defaultAction"], "defaultAction");
|
|
219
|
+
validateCatalogLinks(catalog);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function assertAsset(root: string, asset: string): Promise<{ width: number; height: number }> {
|
|
223
|
+
const resolved = await realpath(join(root, asset));
|
|
224
|
+
if (resolved !== root && !resolved.startsWith(`${root}${sep}`))
|
|
225
|
+
throw new ContractError(`Asset escapes pet directory: ${asset}.`);
|
|
226
|
+
const info = await stat(resolved);
|
|
227
|
+
if (!info.isFile() || info.size === 0 || info.size > LIMITS.assetBytes)
|
|
228
|
+
throw new ContractError(`Asset is not a bounded regular file: ${asset}.`);
|
|
229
|
+
const dimensions = await readImageSize(resolved);
|
|
230
|
+
if (dimensions.width * dimensions.height > LIMITS.decodedPixels)
|
|
231
|
+
throw new ContractError(`Asset has too many decoded pixels: ${asset}.`);
|
|
232
|
+
return dimensions;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function assertFramesInside(asset: string, actions: PetAction[], width: number, height: number): void {
|
|
236
|
+
for (const action of actions) {
|
|
237
|
+
for (const frame of action.frames) {
|
|
238
|
+
if (frame.x + frame.width > width || frame.y + frame.height > height) {
|
|
239
|
+
throw new ContractError(`Action ${action.name} has a frame outside ${asset}.`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function assertAssets(directory: string, catalog: PetCatalog, codexSpritesheet: string): Promise<void> {
|
|
246
|
+
const root = await realpath(directory);
|
|
247
|
+
const visualEntries = [...Object.values(catalog.actions), ...Object.values(catalog.directions)];
|
|
248
|
+
const assets = new Set(visualEntries.map((action) => action.asset));
|
|
249
|
+
for (const asset of assets) {
|
|
250
|
+
const dimensions = await assertAsset(root, asset);
|
|
251
|
+
if (asset === codexSpritesheet && (dimensions.width !== 1_536 || dimensions.height !== 2_288)) {
|
|
252
|
+
throw new ContractError(`Codex v2 spritesheet must be 1536x2288; got ${dimensions.width}x${dimensions.height}.`);
|
|
253
|
+
}
|
|
254
|
+
assertFramesInside(
|
|
255
|
+
asset,
|
|
256
|
+
visualEntries.filter((candidate) => candidate.asset === asset),
|
|
257
|
+
dimensions.width,
|
|
258
|
+
dimensions.height,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function loadPet(petsRoot: string, petId: string): Promise<LoadedPet> {
|
|
264
|
+
const safeId = parseActionName(petId, "active pet");
|
|
265
|
+
const root = await realpath(resolve(petsRoot));
|
|
266
|
+
const directory = await realpath(join(root, safeId));
|
|
267
|
+
if (directory !== root && !directory.startsWith(`${root}${sep}`))
|
|
268
|
+
throw new ContractError("Active pet escapes the pets directory.");
|
|
269
|
+
const manifest = await readJson(join(directory, "pet.json"), LIMITS.manifestBytes);
|
|
270
|
+
const catalog = parseCodexManifest(manifest);
|
|
271
|
+
if (catalog.id !== safeId)
|
|
272
|
+
throw new ContractError(`Active pet directory ${safeId} does not match manifest id ${catalog.id}.`);
|
|
273
|
+
await applyOverlay(directory, catalog);
|
|
274
|
+
assertCatalogSize(catalog);
|
|
275
|
+
const codexSpritesheet = record(manifest, "pet.json")["spritesheetPath"] as string;
|
|
276
|
+
await assertAssets(directory, catalog, codexSpritesheet);
|
|
277
|
+
return { directory, catalog };
|
|
278
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { copyFileSync, lstatSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { cp, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { MAX_PET_DEVICES, parseDeviceName } from "./desktop/config.ts";
|
|
7
|
+
import { loadPet } from "./pet-loader.ts";
|
|
8
|
+
import { LIMITS, type PetCatalog, parseActionName } from "./protocol/index.ts";
|
|
9
|
+
|
|
10
|
+
const CONFIG_BYTES = 16 * 1024;
|
|
11
|
+
const WEB_SHELL_FILES = ["app.js", "index.html", "manifest.webmanifest", "pet-icon.svg", "styles.css"] as const;
|
|
12
|
+
|
|
13
|
+
export interface PetStorageConfig {
|
|
14
|
+
schemaVersion: 1;
|
|
15
|
+
activePet: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RepositoryPetConfig {
|
|
19
|
+
schemaVersion: 1;
|
|
20
|
+
pet?: string;
|
|
21
|
+
devices?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PetRuntime {
|
|
25
|
+
catalog: PetCatalog;
|
|
26
|
+
root: string;
|
|
27
|
+
source: "bundled" | "user";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PetRuntimeResolution {
|
|
31
|
+
runtime: PetRuntime;
|
|
32
|
+
warnings: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function piAgentDirectory(env: NodeJS.ProcessEnv = process.env, home = homedir()): string {
|
|
36
|
+
return env["PI_CODING_AGENT_DIR"]?.trim() || join(home, ".pi", "agent");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function petDataDirectory(env: NodeJS.ProcessEnv = process.env, home = homedir()): string {
|
|
40
|
+
return join(piAgentDirectory(env, home), "pi-pet");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parsePetStorageConfig(value: unknown): PetStorageConfig {
|
|
44
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Pi Pet config must be an object.");
|
|
45
|
+
const input = value as Record<string, unknown>;
|
|
46
|
+
const unknown = Object.keys(input).find((key) => !["schemaVersion", "activePet"].includes(key));
|
|
47
|
+
if (unknown) throw new Error(`Pi Pet config has unknown field: ${unknown}.`);
|
|
48
|
+
if (input["schemaVersion"] !== 1) throw new Error("Unsupported Pi Pet config schemaVersion.");
|
|
49
|
+
return { schemaVersion: 1, activePet: parseActionName(input["activePet"], "active pet") };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseRepositoryPetConfig(value: unknown): RepositoryPetConfig {
|
|
53
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
54
|
+
throw new Error("Repository Pi Pet config must be an object.");
|
|
55
|
+
const input = value as Record<string, unknown>;
|
|
56
|
+
const unknown = Object.keys(input).find((key) => !["schemaVersion", "pet", "devices"].includes(key));
|
|
57
|
+
if (unknown) throw new Error(`Repository Pi Pet config has unknown field: ${unknown}.`);
|
|
58
|
+
if (input["schemaVersion"] !== 1) throw new Error("Unsupported repository Pi Pet config schemaVersion.");
|
|
59
|
+
const pet = input["pet"] === undefined ? undefined : parseActionName(input["pet"], "repository pet");
|
|
60
|
+
let devices: string[] | undefined;
|
|
61
|
+
if (input["devices"] !== undefined) {
|
|
62
|
+
if (!Array.isArray(input["devices"]) || input["devices"].length > MAX_PET_DEVICES) {
|
|
63
|
+
throw new Error(`Repository Pi Pet devices must be an array of at most ${MAX_PET_DEVICES} names.`);
|
|
64
|
+
}
|
|
65
|
+
devices = input["devices"].map(parseDeviceName);
|
|
66
|
+
if (new Set(devices).size !== devices.length) throw new Error("Repository Pi Pet devices must be unique.");
|
|
67
|
+
}
|
|
68
|
+
if (pet === undefined && devices === undefined)
|
|
69
|
+
throw new Error("Repository Pi Pet config must select a pet, devices, or both.");
|
|
70
|
+
return { schemaVersion: 1, ...(pet ? { pet } : {}), ...(devices ? { devices } : {}) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function readRepositoryPetConfig(projectConfigPath: string): RepositoryPetConfig | undefined {
|
|
74
|
+
try {
|
|
75
|
+
const info = statSync(projectConfigPath);
|
|
76
|
+
if (!info.isFile() || info.size > CONFIG_BYTES)
|
|
77
|
+
throw new Error(`Repository Pi Pet config must be bounded: ${projectConfigPath}`);
|
|
78
|
+
return parseRepositoryPetConfig(JSON.parse(readFileSync(projectConfigPath, "utf8")) as unknown);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function writeRepositoryPetConfig(config: RepositoryPetConfig, projectConfigPath: string): Promise<void> {
|
|
86
|
+
const normalized = parseRepositoryPetConfig(config);
|
|
87
|
+
await mkdir(dirname(projectConfigPath), { recursive: true });
|
|
88
|
+
const temporary = `${projectConfigPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
89
|
+
try {
|
|
90
|
+
await writeFile(temporary, `${JSON.stringify(normalized, null, 2)}\n`, { flag: "wx" });
|
|
91
|
+
await rename(temporary, projectConfigPath);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
await rm(temporary, { force: true });
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function readPetStorageConfig(dataRoot = petDataDirectory()): Promise<PetStorageConfig | undefined> {
|
|
99
|
+
try {
|
|
100
|
+
const path = join(dataRoot, "config.json");
|
|
101
|
+
const info = await stat(path);
|
|
102
|
+
if (!info.isFile() || info.size > CONFIG_BYTES)
|
|
103
|
+
throw new Error(`Pi Pet config must be a bounded regular file: ${path}`);
|
|
104
|
+
return parsePetStorageConfig(JSON.parse(await readFile(path, "utf8")) as unknown);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function writePetStorageConfig(config: PetStorageConfig, dataRoot = petDataDirectory()): Promise<void> {
|
|
112
|
+
const normalized = parsePetStorageConfig(config);
|
|
113
|
+
const path = join(dataRoot, "config.json");
|
|
114
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
115
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
116
|
+
try {
|
|
117
|
+
await rm(temporary, { force: true });
|
|
118
|
+
await writeFile(temporary, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
119
|
+
await rename(temporary, path);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
await rm(temporary, { force: true });
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function prepareUserPet(
|
|
127
|
+
packageRoot: string,
|
|
128
|
+
petId: string,
|
|
129
|
+
dataRoot = petDataDirectory(),
|
|
130
|
+
): Promise<string> {
|
|
131
|
+
const id = parseActionName(petId, "pet id");
|
|
132
|
+
const source = join(packageRoot, "pets", id);
|
|
133
|
+
const destination = join(dataRoot, "pets", id);
|
|
134
|
+
let exists = true;
|
|
135
|
+
try {
|
|
136
|
+
const info = lstatSync(destination);
|
|
137
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
138
|
+
throw new Error(`User pet path is not a directory: ${destination}`);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
141
|
+
exists = false;
|
|
142
|
+
}
|
|
143
|
+
if (exists) {
|
|
144
|
+
await loadPet(join(dataRoot, "pets"), id);
|
|
145
|
+
return destination;
|
|
146
|
+
}
|
|
147
|
+
await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
|
|
148
|
+
const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;
|
|
149
|
+
try {
|
|
150
|
+
await cp(source, temporary, { recursive: true, errorOnExist: true, force: false });
|
|
151
|
+
await rename(temporary, destination);
|
|
152
|
+
return destination;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
await rm(temporary, { recursive: true, force: true });
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function readCatalog(path: string): PetCatalog {
|
|
160
|
+
const info = statSync(path);
|
|
161
|
+
if (!info.isFile() || info.size > LIMITS.catalogBytes) throw new Error(`Pi Pet catalog must be bounded: ${path}`);
|
|
162
|
+
const catalog = JSON.parse(readFileSync(path, "utf8")) as PetCatalog;
|
|
163
|
+
const id = parseActionName(catalog.id, "catalog pet id");
|
|
164
|
+
if (
|
|
165
|
+
catalog.schemaVersion !== 1 ||
|
|
166
|
+
!catalog.actions ||
|
|
167
|
+
typeof catalog.actions !== "object" ||
|
|
168
|
+
!catalog.actions[catalog.defaultAction]
|
|
169
|
+
) {
|
|
170
|
+
throw new Error(`Pi Pet catalog is invalid: ${path}`);
|
|
171
|
+
}
|
|
172
|
+
return { ...catalog, id };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function syncWebShell(packageRoot: string, destination: string): void {
|
|
176
|
+
mkdirSync(destination, { recursive: true, mode: 0o700 });
|
|
177
|
+
for (const file of WEB_SHELL_FILES) {
|
|
178
|
+
const target = join(destination, file);
|
|
179
|
+
try {
|
|
180
|
+
if (lstatSync(target).isSymbolicLink()) throw new Error(`Pi Pet web shell cannot overwrite a symlink: ${target}`);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
183
|
+
}
|
|
184
|
+
copyFileSync(join(packageRoot, "dist", "web", file), target);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function loadUserPetRuntime(packageRoot: string, petId: string, dataRoot: string): PetRuntime {
|
|
189
|
+
const root = join(dataRoot, "web", petId);
|
|
190
|
+
syncWebShell(packageRoot, root);
|
|
191
|
+
const catalog = readCatalog(join(root, "catalog.json"));
|
|
192
|
+
if (catalog.id !== petId) throw new Error(`Selected pet ${petId} does not match catalog ${catalog.id}.`);
|
|
193
|
+
return { catalog, root, source: "user" };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function readGlobalPet(dataRoot: string): string | undefined {
|
|
197
|
+
const configPath = join(dataRoot, "config.json");
|
|
198
|
+
try {
|
|
199
|
+
const info = statSync(configPath);
|
|
200
|
+
if (!info.isFile() || info.size > CONFIG_BYTES) throw new Error(`Pi Pet config must be bounded: ${configPath}`);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
return parsePetStorageConfig(JSON.parse(readFileSync(configPath, "utf8")) as unknown).activePet;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function loadBundledPetRuntime(packageRoot: string): PetRuntime {
|
|
211
|
+
const root = join(packageRoot, "dist", "web");
|
|
212
|
+
return { catalog: readCatalog(join(root, "catalog.json")), root, source: "bundled" };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function resolvePetRuntime(
|
|
216
|
+
packageRoot: string,
|
|
217
|
+
projectConfigPath: string,
|
|
218
|
+
dataRoot = petDataDirectory(),
|
|
219
|
+
): PetRuntimeResolution {
|
|
220
|
+
const warnings: string[] = [];
|
|
221
|
+
let repositoryPet: string | undefined;
|
|
222
|
+
try {
|
|
223
|
+
repositoryPet = readRepositoryPetConfig(projectConfigPath)?.pet;
|
|
224
|
+
if (repositoryPet) return { runtime: loadUserPetRuntime(packageRoot, repositoryPet, dataRoot), warnings };
|
|
225
|
+
} catch (error) {
|
|
226
|
+
warnings.push(`Repository pet unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const globalPet = readGlobalPet(dataRoot);
|
|
231
|
+
if (globalPet && globalPet !== repositoryPet) {
|
|
232
|
+
return { runtime: loadUserPetRuntime(packageRoot, globalPet, dataRoot), warnings };
|
|
233
|
+
}
|
|
234
|
+
} catch (error) {
|
|
235
|
+
warnings.push(`Global pet unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
236
|
+
}
|
|
237
|
+
return { runtime: loadBundledPetRuntime(packageRoot), warnings };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export const petWebShellFiles = WEB_SHELL_FILES;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export const ACTION_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
2
|
+
const RESERVED_ACTION_NAMES = new Set(["__proto__", "constructor"]);
|
|
3
|
+
|
|
4
|
+
export const LIMITS = Object.freeze({
|
|
5
|
+
actionName: 64,
|
|
6
|
+
noteText: 280,
|
|
7
|
+
manifestBytes: 131_072,
|
|
8
|
+
catalogBytes: 262_144,
|
|
9
|
+
assetBytes: 16 * 1024 * 1024,
|
|
10
|
+
frameCount: 64,
|
|
11
|
+
decodedPixels: 16_000_000,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export interface PetFrame {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
durationMs: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PetAction {
|
|
23
|
+
name: string;
|
|
24
|
+
asset: string;
|
|
25
|
+
frames: PetFrame[];
|
|
26
|
+
loop: boolean;
|
|
27
|
+
next?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PetCatalog {
|
|
31
|
+
schemaVersion: 1;
|
|
32
|
+
id: string;
|
|
33
|
+
displayName: string;
|
|
34
|
+
description: string;
|
|
35
|
+
defaultAction: string;
|
|
36
|
+
canvas: { width: number; height: number };
|
|
37
|
+
actions: Record<string, PetAction>;
|
|
38
|
+
aliases: Record<string, string>;
|
|
39
|
+
directions: Record<string, PetAction>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PetState {
|
|
43
|
+
schemaVersion: 1;
|
|
44
|
+
pet: string;
|
|
45
|
+
revision: number;
|
|
46
|
+
action: string;
|
|
47
|
+
note?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class ContractError extends Error {
|
|
51
|
+
override readonly name = "ContractError";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function boundedString(value: unknown, label: string, max: number): string {
|
|
55
|
+
if (typeof value !== "string") throw new ContractError(`${label} must be a string.`);
|
|
56
|
+
const normalized = value.trim();
|
|
57
|
+
if (!normalized) throw new ContractError(`${label} cannot be empty.`);
|
|
58
|
+
if (normalized.length > max) throw new ContractError(`${label} exceeds ${max} characters.`);
|
|
59
|
+
return normalized;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseActionName(value: unknown, label = "action"): string {
|
|
63
|
+
const action = boundedString(value, label, LIMITS.actionName);
|
|
64
|
+
if (action === "list") throw new ContractError(`${label} cannot use the reserved pet_show discovery name list.`);
|
|
65
|
+
if (RESERVED_ACTION_NAMES.has(action))
|
|
66
|
+
throw new ContractError(`${label} cannot use a reserved object property name.`);
|
|
67
|
+
if (!ACTION_NAME_PATTERN.test(action)) {
|
|
68
|
+
throw new ContractError(`${label} must use lowercase letters, numbers, dots, underscores, or hyphens.`);
|
|
69
|
+
}
|
|
70
|
+
return action;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseNote(value: unknown): string | undefined {
|
|
74
|
+
return value === undefined ? undefined : boundedString(value, "note", LIMITS.noteText);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function isSafeRelativeAssetPath(value: string): boolean {
|
|
78
|
+
if (!value || value.includes("\0") || value.includes("\\") || value.startsWith("/")) return false;
|
|
79
|
+
const parts = value.split("/");
|
|
80
|
+
return parts.every((part) => part !== "" && part !== "." && part !== "..");
|
|
81
|
+
}
|