pi-better-openai 0.1.18 → 0.1.21

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/src/pets.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { constants, type Dirent } from "node:fs";
2
- import { access, readdir, readFile, stat } from "node:fs/promises";
2
+ import { access, lstat, readdir, readFile, realpath } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve, sep } from "node:path";
5
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
5
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
  import {
7
7
  calculateImageRows,
8
8
  getCapabilities,
@@ -12,7 +12,7 @@ import {
12
12
  truncateToWidth,
13
13
  type AutocompleteItem,
14
14
  type ImageTheme,
15
- } from "@mariozechner/pi-tui";
15
+ } from "@earendil-works/pi-tui";
16
16
  import sharp from "sharp";
17
17
  import { isRecord, type PetState } from "./config.ts";
18
18
 
@@ -24,6 +24,17 @@ const DEFAULT_CELL_HEIGHT = 208;
24
24
  const DEFAULT_SPRITESHEET_PATH = "spritesheet.webp";
25
25
  const EXPECTED_ATLAS_WIDTH = DEFAULT_CELL_WIDTH * PET_COLUMNS;
26
26
  const EXPECTED_ATLAS_HEIGHT = DEFAULT_CELL_HEIGHT * PET_ROWS;
27
+ const PET_CATALOG_CACHE_TTL_MS = 1500;
28
+ const PET_CATALOG_READ_CONCURRENCY = 4;
29
+
30
+ type ListCodexPetsOptions = { refresh?: boolean };
31
+
32
+ type PetCatalogCacheEntry = {
33
+ expiresAt: number;
34
+ pets: CodexPetPackage[];
35
+ };
36
+
37
+ const petCatalogCache = new Map<string, PetCatalogCacheEntry>();
27
38
 
28
39
  export const PET_ANIMATION_ROWS: Record<PetState, { row: number; durations: number[] }> = {
29
40
  idle: { row: 0, durations: [280, 110, 110, 140, 140, 320] },
@@ -51,8 +62,8 @@ export type CodexPetPackage = {
51
62
  export type PetFrame = {
52
63
  data?: string;
53
64
  rawRgbaData?: string;
65
+ kittyUpload?: string;
54
66
  kittyImageId?: number;
55
- kittyUploaded?: boolean;
56
67
  mimeType: "image/png";
57
68
  durationMs: number;
58
69
  widthPx: number;
@@ -105,6 +116,10 @@ function sanitizePetAssetPathField(value: unknown): string | undefined {
105
116
  return typeof value === "string" ? sanitizePetAssetPathText(value) : undefined;
106
117
  }
107
118
 
119
+ function sanitizePetSlug(value: string): string {
120
+ return sanitizePetDisplayText(value) ?? "unnamed-pet";
121
+ }
122
+
108
123
  export function codexHome(env = process.env, home = homedir()): string {
109
124
  return env.CODEX_HOME?.trim() || join(home, ".codex");
110
125
  }
@@ -132,11 +147,17 @@ function petInfoFromJson(
132
147
  return { id, name, description, spritesheetPath };
133
148
  }
134
149
 
150
+ function isPathInsideDirectory(parent: string, child: string): boolean {
151
+ const resolvedParent = resolve(parent);
152
+ const resolvedChild = resolve(child);
153
+ const prefix = resolvedParent.endsWith(sep) ? resolvedParent : `${resolvedParent}${sep}`;
154
+ return resolvedChild === resolvedParent || resolvedChild.startsWith(prefix);
155
+ }
156
+
135
157
  function resolvePetAssetPath(petDir: string, path: string): string | undefined {
136
158
  const resolvedPetDir = resolve(petDir);
137
159
  const resolved = resolve(resolvedPetDir, path);
138
- const prefix = resolvedPetDir.endsWith(sep) ? resolvedPetDir : `${resolvedPetDir}${sep}`;
139
- return resolved === resolvedPetDir || resolved.startsWith(prefix) ? resolved : undefined;
160
+ return isPathInsideDirectory(resolvedPetDir, resolved) ? resolved : undefined;
140
161
  }
141
162
 
142
163
  function formatSharpError(error: unknown): string {
@@ -156,13 +177,23 @@ async function validatePetSpritesheet(
156
177
 
157
178
  let fileStat;
158
179
  try {
159
- fileStat = await stat(resolvedSpritesheetPath);
180
+ fileStat = await lstat(resolvedSpritesheetPath);
160
181
  } catch {
161
182
  return `missing ${spritesheetPath}`;
162
183
  }
163
184
 
185
+ if (fileStat.isSymbolicLink()) return `${spritesheetPath} must not be a symlink`;
164
186
  if (!fileStat.isFile()) return `${spritesheetPath} is not a file`;
165
187
 
188
+ const realPetDir = await realpath(petDir).catch(() => undefined);
189
+ const realSpritesheetPath = await realpath(resolvedSpritesheetPath).catch(() => undefined);
190
+ if (
191
+ !realPetDir ||
192
+ !realSpritesheetPath ||
193
+ !isPathInsideDirectory(realPetDir, realSpritesheetPath)
194
+ )
195
+ return `invalid spritesheetPath outside pet folder: ${spritesheetPath}`;
196
+
166
197
  try {
167
198
  await access(resolvedSpritesheetPath, constants.R_OK);
168
199
  } catch {
@@ -181,47 +212,104 @@ async function validatePetSpritesheet(
181
212
  return undefined;
182
213
  }
183
214
 
184
- export async function listCodexPets(home = codexHome()): Promise<CodexPetPackage[]> {
215
+ function clearPetCatalogCache(home?: string): void {
216
+ if (home === undefined) {
217
+ petCatalogCache.clear();
218
+ return;
219
+ }
220
+ petCatalogCache.delete(resolve(home));
221
+ }
222
+
223
+ async function readPetDirectoryEntries(home: string): Promise<{ dir: string; entries: Dirent[] }> {
185
224
  const dir = codexPetsDir(home);
186
- let entries: Dirent[];
187
225
  try {
188
- entries = await readdir(dir, { withFileTypes: true });
226
+ return { dir, entries: await readdir(dir, { withFileTypes: true }) };
189
227
  } catch {
190
- return [];
228
+ return { dir, entries: [] };
191
229
  }
230
+ }
192
231
 
193
- const pets: CodexPetPackage[] = [];
194
- for (const entry of entries) {
195
- if (!entry.isDirectory()) continue;
196
- const petDir = join(dir, entry.name);
197
- let parsed: unknown;
198
- try {
199
- parsed = JSON.parse(await readFile(join(petDir, "pet.json"), "utf8")) as unknown;
200
- } catch {
201
- continue;
202
- }
203
- const { id, name, description, spritesheetPath } = petInfoFromJson(parsed, entry.name);
204
- const spritesheetIssue = await validatePetSpritesheet(petDir, spritesheetPath);
205
- pets.push({
206
- slug: entry.name,
207
- id,
208
- name,
209
- description,
232
+ async function readCodexPetPackage(
233
+ petsDir: string,
234
+ entry: Dirent,
235
+ options: { validateSpritesheet: boolean },
236
+ ): Promise<CodexPetPackage | undefined> {
237
+ if (!entry.isDirectory()) return undefined;
238
+ const petDir = join(petsDir, entry.name);
239
+ const slug = sanitizePetSlug(entry.name);
240
+ let parsed: unknown;
241
+ try {
242
+ parsed = JSON.parse(await readFile(join(petDir, "pet.json"), "utf8")) as unknown;
243
+ } catch (error) {
244
+ return {
245
+ slug,
246
+ name: sanitizePetDisplayText(entry.name) ?? "Unnamed pet",
210
247
  dir: petDir,
211
- spritesheetPath,
212
- hasSpritesheet: spritesheetIssue === undefined,
213
- spritesheetIssue,
214
- });
248
+ spritesheetPath: DEFAULT_SPRITESHEET_PATH,
249
+ hasSpritesheet: false,
250
+ spritesheetIssue: `invalid pet.json: ${formatSharpError(error)}`,
251
+ };
215
252
  }
216
- return pets.sort((a, b) => a.name.localeCompare(b.name));
253
+ const { id, name, description, spritesheetPath } = petInfoFromJson(parsed, entry.name);
254
+ const spritesheetIssue = options.validateSpritesheet
255
+ ? await validatePetSpritesheet(petDir, spritesheetPath)
256
+ : undefined;
257
+ return {
258
+ slug,
259
+ id,
260
+ name,
261
+ description,
262
+ dir: petDir,
263
+ spritesheetPath,
264
+ hasSpritesheet: options.validateSpritesheet && spritesheetIssue === undefined,
265
+ spritesheetIssue,
266
+ };
217
267
  }
218
268
 
219
- function petLookupKey(value: string): string {
220
- return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
269
+ async function readCodexPetPackages(
270
+ petsDir: string,
271
+ entries: Dirent[],
272
+ options: { validateSpritesheet: boolean },
273
+ ): Promise<CodexPetPackage[]> {
274
+ const pets: Array<CodexPetPackage | undefined> = Array.from({ length: entries.length });
275
+ let nextIndex = 0;
276
+ const readNext = async (): Promise<void> => {
277
+ while (nextIndex < entries.length) {
278
+ const index = nextIndex++;
279
+ pets[index] = await readCodexPetPackage(petsDir, entries[index], options);
280
+ }
281
+ };
282
+ await Promise.all(
283
+ Array.from({ length: Math.min(PET_CATALOG_READ_CONCURRENCY, entries.length) }, readNext),
284
+ );
285
+ return pets.filter((pet): pet is CodexPetPackage => pet !== undefined);
286
+ }
287
+
288
+ export async function listCodexPets(
289
+ home = codexHome(),
290
+ options: ListCodexPetsOptions = {},
291
+ ): Promise<CodexPetPackage[]> {
292
+ const cacheKey = resolve(home);
293
+ const cached = petCatalogCache.get(cacheKey);
294
+ if (!options.refresh && cached && cached.expiresAt > Date.now()) return cached.pets;
295
+
296
+ const { dir, entries } = await readPetDirectoryEntries(home);
297
+ const pets = await readCodexPetPackages(dir, entries, { validateSpritesheet: true });
298
+ pets.sort((a, b) => a.name.localeCompare(b.name));
299
+ petCatalogCache.set(cacheKey, { expiresAt: Date.now() + PET_CATALOG_CACHE_TTL_MS, pets });
300
+ return pets;
301
+ }
302
+
303
+ export function petLookupKey(value: string): string {
304
+ return value
305
+ .normalize("NFKC")
306
+ .toLowerCase()
307
+ .replace(/[^\p{L}\p{N}\p{M}]+/gu, "");
221
308
  }
222
309
 
223
310
  type PetLookupRequest = {
224
311
  hasText: boolean;
312
+ exact?: string;
225
313
  key?: string;
226
314
  };
227
315
 
@@ -229,7 +317,11 @@ function petLookupRequest(value?: string): PetLookupRequest {
229
317
  const requestedText = value?.trim();
230
318
  if (!requestedText) return { hasText: false };
231
319
 
232
- return { hasText: true, key: petLookupKey(requestedText) || undefined };
320
+ return {
321
+ hasText: true,
322
+ exact: requestedText.normalize("NFKC").toLowerCase(),
323
+ key: petLookupKey(requestedText) || undefined,
324
+ };
233
325
  }
234
326
 
235
327
  function petMatchesLookup(pet: CodexPetPackage, requested: string): boolean {
@@ -240,11 +332,35 @@ function petMatchesLookup(pet: CodexPetPackage, requested: string): boolean {
240
332
  );
241
333
  }
242
334
 
335
+ function exactPetMatches(
336
+ pets: CodexPetPackage[],
337
+ requested: string,
338
+ ): CodexPetPackage[] | undefined {
339
+ const fields: Array<(pet: CodexPetPackage) => string | undefined> = [
340
+ (pet) => pet.slug,
341
+ (pet) => pet.id,
342
+ (pet) => pet.name,
343
+ ];
344
+ for (const field of fields) {
345
+ const matches = pets.filter((pet) => field(pet)?.normalize("NFKC").toLowerCase() === requested);
346
+ if (matches.length > 0) return matches;
347
+ }
348
+ return undefined;
349
+ }
350
+
351
+ function uniquePetMatch(pets: CodexPetPackage[]): CodexPetPackage | undefined {
352
+ return pets.length === 1 ? pets[0] : undefined;
353
+ }
354
+
243
355
  export function findCodexPet(pets: CodexPetPackage[], value?: string): CodexPetPackage | undefined {
244
356
  const request = petLookupRequest(value);
245
357
  const { key } = request;
246
358
  if (!key) return undefined;
247
- return pets.find((pet) => petMatchesLookup(pet, key));
359
+ if (request.exact) {
360
+ const exactMatches = exactPetMatches(pets, request.exact);
361
+ if (exactMatches) return uniquePetMatch(exactMatches);
362
+ }
363
+ return uniquePetMatch(pets.filter((pet) => petMatchesLookup(pet, key)));
248
364
  }
249
365
 
250
366
  export function findReadyCodexPet(
@@ -256,7 +372,14 @@ export function findReadyCodexPet(
256
372
 
257
373
  const { key } = request;
258
374
  if (!key) return undefined;
259
- return pets.find((pet) => pet.hasSpritesheet && petMatchesLookup(pet, key));
375
+ if (request.exact) {
376
+ const exactMatches = exactPetMatches(pets, request.exact);
377
+ if (exactMatches) {
378
+ const exact = uniquePetMatch(exactMatches);
379
+ return exact?.hasSpritesheet ? exact : undefined;
380
+ }
381
+ }
382
+ return uniquePetMatch(pets.filter((pet) => pet.hasSpritesheet && petMatchesLookup(pet, key)));
260
383
  }
261
384
 
262
385
  function selectPet(pets: CodexPetPackage[], slug?: string): CodexPetPackage | undefined {
@@ -356,13 +479,33 @@ function kittyImageBaseForPet(slug: string): number {
356
479
  return 0x50000000 + hash * 100;
357
480
  }
358
481
 
482
+ async function findCodexPetDirect(
483
+ slug: string,
484
+ home: string,
485
+ ): Promise<CodexPetPackage | undefined> {
486
+ const request = petLookupRequest(slug);
487
+ if (!request.key) return undefined;
488
+ const { dir, entries } = await readPetDirectoryEntries(home);
489
+ const candidates = await readCodexPetPackages(dir, entries, { validateSpritesheet: false });
490
+ const candidate = findCodexPet(candidates, slug);
491
+ if (!candidate || candidate.spritesheetIssue) return candidate;
492
+ const spritesheetIssue = await validatePetSpritesheet(candidate.dir, candidate.spritesheetPath);
493
+ return {
494
+ ...candidate,
495
+ hasSpritesheet: spritesheetIssue === undefined,
496
+ spritesheetIssue,
497
+ };
498
+ }
499
+
359
500
  export async function loadCodexPet(
360
501
  slug?: string,
361
502
  home = codexHome(),
362
503
  options: { sizeCells?: number } = {},
363
504
  ): Promise<LoadedCodexPet | undefined> {
364
- const pet = selectPet(await listCodexPets(home), slug);
365
- if (!pet) return undefined;
505
+ const pet = slug
506
+ ? await findCodexPetDirect(slug, home)
507
+ : selectPet(await listCodexPets(home), slug);
508
+ if (!pet?.hasSpritesheet) return undefined;
366
509
 
367
510
  const spritesheetPath = resolvePetAssetPath(pet.dir, pet.spritesheetPath);
368
511
  if (!spritesheetPath)
@@ -422,12 +565,20 @@ export async function loadCodexPet(
422
565
  kernel: sharp.kernel.nearest,
423
566
  });
424
567
  }
568
+ const kittyImageId = useKitty ? kittyImageBase + kittyFrameOffset++ : undefined;
425
569
  const encoded = useKitty
426
- ? { rawRgbaData: (await frame.clone().ensureAlpha().raw().toBuffer()).toString("base64") }
427
- : { data: (await frame.clone().png().toBuffer()).toString("base64") };
570
+ ? {
571
+ kittyUpload: encodeKittyRawRgbaData(
572
+ (await frame.ensureAlpha().raw().toBuffer()).toString("base64"),
573
+ outputWidthPx,
574
+ outputHeightPx,
575
+ kittyImageId!,
576
+ ),
577
+ }
578
+ : { data: (await frame.png().toBuffer()).toString("base64") };
428
579
  states[state].push({
429
580
  ...encoded,
430
- kittyImageId: useKitty ? kittyImageBase + kittyFrameOffset++ : undefined,
581
+ kittyImageId,
431
582
  mimeType: "image/png",
432
583
  durationMs,
433
584
  widthPx: outputWidthPx,
@@ -484,12 +635,6 @@ function forEachCodexPetFrame(
484
635
  }
485
636
  }
486
637
 
487
- function markCodexPetKittyFramesUnuploaded(pet?: LoadedCodexPet): void {
488
- forEachCodexPetFrame(pet, (frame) => {
489
- frame.kittyUploaded = false;
490
- });
491
- }
492
-
493
638
  function codexPetKittyImageIds(pet?: LoadedCodexPet): number[] {
494
639
  const imageIds = new Set<number>();
495
640
  forEachCodexPetFrame(pet, (frame) => {
@@ -510,18 +655,15 @@ function placeKittyImage(imageId: number, columns: number, rows: number): string
510
655
  return `\x1b_Ga=p,i=${imageId},p=1,c=${columns},r=${rows},q=2,C=1\x1b\\`;
511
656
  }
512
657
 
513
- function encodeKittyRawRgba(frame: PetFrame, imageId: number): string {
514
- const rawRgbaData = frame.rawRgbaData;
658
+ function encodeKittyRawRgbaData(
659
+ rawRgbaData: string,
660
+ widthPx: number,
661
+ heightPx: number,
662
+ imageId: number,
663
+ ): string {
515
664
  if (!rawRgbaData) return "";
516
665
  const chunkSize = 4096;
517
- const params = [
518
- "a=t",
519
- "f=32",
520
- `s=${frame.widthPx}`,
521
- `v=${frame.heightPx}`,
522
- `i=${imageId}`,
523
- "q=2",
524
- ].join(",");
666
+ const params = ["a=t", "f=32", `s=${widthPx}`, `v=${heightPx}`, `i=${imageId}`, "q=2"].join(",");
525
667
  if (rawRgbaData.length <= chunkSize) {
526
668
  return `\x1b_G${params};${rawRgbaData}\x1b\\`;
527
669
  }
@@ -537,6 +679,15 @@ function encodeKittyRawRgba(frame: PetFrame, imageId: number): string {
537
679
  return chunks.join("");
538
680
  }
539
681
 
682
+ function encodeKittyRawRgba(frame: PetFrame, imageId: number): string {
683
+ return (
684
+ frame.kittyUpload ??
685
+ (frame.rawRgbaData
686
+ ? encodeKittyRawRgbaData(frame.rawRgbaData, frame.widthPx, frame.heightPx, imageId)
687
+ : "")
688
+ );
689
+ }
690
+
540
691
  export class CodexPetKittyManager {
541
692
  private previousFrameImageId: number | undefined;
542
693
  private readonly pendingCleanupImageIds = new Set<number>();
@@ -550,7 +701,6 @@ export class CodexPetKittyManager {
550
701
  invalidate(pet?: LoadedCodexPet): void {
551
702
  this.queueCleanup(pet);
552
703
  this.previousFrameImageId = undefined;
553
- markCodexPetKittyFramesUnuploaded(pet);
554
704
  }
555
705
 
556
706
  resetForResize(pet?: LoadedCodexPet): void {
@@ -592,7 +742,6 @@ export class CodexPetKittyManager {
592
742
  // occasionally invisible pet. The TUI diff still avoids writing this payload
593
743
  // again when the rendered line is unchanged.
594
744
  const upload = encodeKittyRawRgba(frame, frameImageId);
595
- frame.kittyUploaded = true;
596
745
  this.previousFrameImageId = frameImageId;
597
746
  // Recent pi-tui versions automatically free Kitty image IDs they own on changed lines.
598
747
  // Pet frames are managed here and intentionally reused across animation loops, so keep
@@ -625,7 +774,6 @@ function defaultKittyManager(placementImageId: number): CodexPetKittyManager {
625
774
  export function resetCodexPetKittyCache(pet?: LoadedCodexPet, placementImageId?: number): void {
626
775
  if (placementImageId !== undefined) defaultKittyManagers.get(placementImageId)?.invalidate(pet);
627
776
  else for (const manager of defaultKittyManagers.values()) manager.invalidate(pet);
628
- markCodexPetKittyFramesUnuploaded(pet);
629
777
  }
630
778
 
631
779
  export function renderCodexPetFrame(
@@ -773,7 +921,7 @@ export function formatCodexPetsListMessage(pets: CodexPetPackage[], home = codex
773
921
 
774
922
  async function notifyPetsList(ctx: ExtensionContext): Promise<void> {
775
923
  const home = codexHome();
776
- const pets = await listCodexPets(home);
924
+ const pets = await listCodexPets(home, { refresh: true });
777
925
  ctx.ui.notify(
778
926
  formatCodexPetsListMessage(pets, home),
779
927
  pets.some((pet) => pet.hasSpritesheet) ? "info" : "warning",
@@ -827,4 +975,5 @@ export const _petsTest = {
827
975
  findCodexPet,
828
976
  findReadyCodexPet,
829
977
  selectPet,
978
+ clearPetCatalogCache,
830
979
  };