gentle-pi 0.2.8 → 0.3.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.
@@ -1,14 +1,14 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { watch } from "node:fs";
2
3
  import {
3
- existsSync,
4
- mkdirSync,
5
- readFileSync,
6
- readdirSync,
7
- renameSync,
8
- statSync,
9
- watch,
10
- writeFileSync,
11
- } from "node:fs";
4
+ access,
5
+ mkdir,
6
+ readFile,
7
+ readdir,
8
+ rename,
9
+ stat,
10
+ writeFile,
11
+ } from "node:fs/promises";
12
12
  import { homedir } from "node:os";
13
13
  import { basename, join, normalize, relative, sep } from "node:path";
14
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -26,6 +26,14 @@ const NO_SKILL_REGISTRY_ENV = "GENTLE_PI_NO_SKILL_REGISTRY";
26
26
  const LEGACY_PROJECT_REGISTRY_REL_PATH = ".pi/extensions/skill-registry.ts";
27
27
  const LEGACY_PROJECT_REGISTRY_DISABLED_REL_PATH =
28
28
  ".pi/extensions/skill-registry.ts.disabled";
29
+ async function pathExists(path: string): Promise<boolean> {
30
+ try {
31
+ await access(path);
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
29
37
 
30
38
  interface SkillEntry {
31
39
  name: string;
@@ -75,15 +83,15 @@ function projectSkillDirs(cwd: string): string[] {
75
83
  ];
76
84
  }
77
85
 
78
- function findSkillFiles(root: string): string[] {
79
- if (!existsSync(root)) return [];
86
+ async function findSkillFiles(root: string): Promise<string[]> {
87
+ if (!(await pathExists(root))) return [];
80
88
  const out: string[] = [];
81
89
  const stack: string[] = [root];
82
90
  while (stack.length > 0) {
83
91
  const dir = stack.pop()!;
84
92
  let entries;
85
93
  try {
86
- entries = readdirSync(dir, { withFileTypes: true });
94
+ entries = await readdir(dir, { withFileTypes: true });
87
95
  } catch {
88
96
  continue;
89
97
  }
@@ -205,22 +213,22 @@ function comparablePath(path: string): string {
205
213
  return clean.length > 1 ? clean.replace(/[\\/]+$/, "") : clean;
206
214
  }
207
215
 
208
- function uniqueExistingDirs(dirs: string[]): string[] {
216
+ async function uniqueExistingDirs(dirs: string[]): Promise<string[]> {
209
217
  const seen = new Set<string>();
210
218
  const out: string[] = [];
211
219
  for (const dir of dirs) {
212
220
  const clean = comparablePath(dir);
213
- if (seen.has(clean) || !existsSync(clean)) continue;
221
+ if (seen.has(clean) || !(await pathExists(clean))) continue;
214
222
  seen.add(clean);
215
223
  out.push(clean);
216
224
  }
217
225
  return out;
218
226
  }
219
227
 
220
- function loadSkill(file: string): SkillEntry | undefined {
228
+ async function loadSkill(file: string): Promise<SkillEntry | undefined> {
221
229
  let source: string;
222
230
  try {
223
- source = readFileSync(file, "utf8");
231
+ source = await readFile(file, "utf8");
224
232
  } catch {
225
233
  return undefined;
226
234
  }
@@ -261,18 +269,17 @@ function dedupeBySkillName(entries: SkillEntry[], cwd: string): SkillEntry[] {
261
269
  return out.sort((a, b) => a.name.localeCompare(b.name));
262
270
  }
263
271
 
264
- function fingerprint(files: string[]): string {
265
- const lines = [
266
- `schema:${REGISTRY_SCHEMA_VERSION}`,
267
- ...files.map((f) => {
268
- try {
269
- const stat = statSync(f);
270
- return `${f}:${stat.mtimeMs}:${stat.size}`;
271
- } catch {
272
- return `${f}:missing`;
273
- }
274
- }),
275
- ].sort();
272
+ async function fingerprint(files: string[]): Promise<string> {
273
+ const lines: string[] = [`schema:${REGISTRY_SCHEMA_VERSION}`];
274
+ for (const file of files) {
275
+ try {
276
+ const info = await stat(file);
277
+ lines.push(`${file}:${info.mtimeMs}:${info.size}`);
278
+ } catch {
279
+ lines.push(`${file}:missing`);
280
+ }
281
+ }
282
+ lines.sort();
276
283
  return createHash("sha1").update(lines.join("\n")).digest("hex");
277
284
  }
278
285
 
@@ -321,11 +328,11 @@ interface RegenResult {
321
328
  reason: string;
322
329
  }
323
330
 
324
- function ensureAtlIgnored(cwd: string): void {
331
+ async function ensureAtlIgnored(cwd: string): Promise<void> {
325
332
  const gitignorePath = join(cwd, ".gitignore");
326
333
  let existing = "";
327
- if (existsSync(gitignorePath)) {
328
- existing = readFileSync(gitignorePath, "utf8");
334
+ if (await pathExists(gitignorePath)) {
335
+ existing = await readFile(gitignorePath, "utf8");
329
336
  }
330
337
  const hasAtlIgnore = existing
331
338
  .split("\n")
@@ -334,7 +341,7 @@ function ensureAtlIgnored(cwd: string): void {
334
341
  if (hasAtlIgnore) return;
335
342
  const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
336
343
  const header = existing.includes("# Local Pi runtime state") ? "" : "# Local Pi runtime state\n";
337
- writeFileSync(gitignorePath, `${existing}${prefix}${header}${ATL_IGNORE_ENTRY}\n`);
344
+ await writeFile(gitignorePath, `${existing}${prefix}${header}${ATL_IGNORE_ENTRY}\n`);
338
345
  }
339
346
 
340
347
  function isGeneratedLegacyProjectRegistry(source: string): boolean {
@@ -349,65 +356,84 @@ function isGeneratedLegacyProjectRegistry(source: string): boolean {
349
356
  );
350
357
  }
351
358
 
352
- function nextLegacyDisabledPath(cwd: string): string {
359
+ async function nextLegacyDisabledPath(cwd: string): Promise<string> {
353
360
  const base = join(cwd, LEGACY_PROJECT_REGISTRY_DISABLED_REL_PATH);
354
- if (!existsSync(base)) return base;
361
+ if (!(await pathExists(base))) return base;
355
362
  for (let i = 1; i < 100; i++) {
356
363
  const candidate = `${base}.${i}`;
357
- if (!existsSync(candidate)) return candidate;
364
+ if (!(await pathExists(candidate))) return candidate;
358
365
  }
359
366
  return `${base}.${Date.now()}`;
360
367
  }
361
368
 
362
- function quarantineLegacyProjectRegistry(cwd: string): boolean {
369
+ async function quarantineLegacyProjectRegistry(cwd: string): Promise<boolean> {
363
370
  const legacyPath = join(cwd, LEGACY_PROJECT_REGISTRY_REL_PATH);
364
- if (!existsSync(legacyPath)) return false;
371
+ if (!(await pathExists(legacyPath))) return false;
365
372
  let source = "";
366
373
  try {
367
- source = readFileSync(legacyPath, "utf8");
374
+ source = await readFile(legacyPath, "utf8");
368
375
  } catch {
369
376
  return false;
370
377
  }
371
378
  if (!isGeneratedLegacyProjectRegistry(source)) return false;
372
- const disabledPath = nextLegacyDisabledPath(cwd);
379
+ const disabledPath = await nextLegacyDisabledPath(cwd);
373
380
  try {
374
- renameSync(legacyPath, disabledPath);
381
+ await rename(legacyPath, disabledPath);
375
382
  return true;
376
383
  } catch {
377
384
  return false;
378
385
  }
379
386
  }
380
387
 
381
- function regenerateRegistry(cwd: string, force: boolean): RegenResult {
382
- const existingDirs = uniqueExistingDirs([...projectSkillDirs(cwd), ...userSkillDirs()]);
383
- const files = existingDirs.flatMap(findSkillFiles);
388
+ async function regenerateRegistry(
389
+ cwd: string,
390
+ force: boolean,
391
+ ): Promise<RegenResult> {
392
+ const existingDirs = await uniqueExistingDirs([
393
+ ...projectSkillDirs(cwd),
394
+ ...userSkillDirs(),
395
+ ]);
396
+ const files: string[] = [];
397
+ for (const dir of existingDirs) {
398
+ files.push(...(await findSkillFiles(dir)));
399
+ }
384
400
  const cachePath = join(cwd, CACHE_REL_PATH);
385
401
  const registryPath = join(cwd, REGISTRY_REL_PATH);
386
- const fp = fingerprint(files);
402
+ const fp = await fingerprint(files);
387
403
  let cached: string | undefined;
388
- if (existsSync(cachePath)) {
404
+ if (await pathExists(cachePath)) {
389
405
  try {
390
- cached = (JSON.parse(readFileSync(cachePath, "utf8")) as { fingerprint?: string }).fingerprint;
406
+ cached = (
407
+ JSON.parse(await readFile(cachePath, "utf8")) as {
408
+ fingerprint?: string;
409
+ }
410
+ ).fingerprint;
391
411
  } catch {
392
412
  cached = undefined;
393
413
  }
394
414
  }
395
- if (!force && cached === fp && existsSync(registryPath)) {
415
+ if (!force && cached === fp && (await pathExists(registryPath))) {
396
416
  return { regenerated: false, skillCount: 0, reason: "cache-hit" };
397
417
  }
398
- const entries = files
399
- .map(loadSkill)
400
- .filter((e): e is SkillEntry => Boolean(e));
418
+ const entries: SkillEntry[] = [];
419
+ for (const file of files) {
420
+ const entry = await loadSkill(file);
421
+ if (entry) entries.push(entry);
422
+ }
401
423
  const deduped = dedupeBySkillName(entries, cwd);
402
424
  const sources = existingDirs.map((d) => {
403
425
  const rel = relative(cwd, d);
404
426
  return rel.startsWith("..") ? d : rel || ".";
405
427
  });
406
428
  const md = renderRegistry(cwd, sources, deduped);
407
- mkdirSync(join(cwd, ".atl"), { recursive: true });
408
- writeFileSync(registryPath, md);
409
- writeFileSync(cachePath, JSON.stringify({ fingerprint: fp }, null, 2));
410
- return { regenerated: true, skillCount: deduped.length, reason: force ? "forced" : "fingerprint-changed" };
429
+ await mkdir(join(cwd, ".atl"), { recursive: true });
430
+ await writeFile(registryPath, md);
431
+ await writeFile(cachePath, JSON.stringify({ fingerprint: fp }, null, 2));
432
+ return {
433
+ regenerated: true,
434
+ skillCount: deduped.length,
435
+ reason: force ? "forced" : "fingerprint-changed",
436
+ };
411
437
  }
412
438
 
413
439
  const watchedCwds = new Set<string>();
@@ -432,22 +458,30 @@ function shouldSkipSkillRegistryStartup(
432
458
  );
433
459
  }
434
460
 
435
- function startSkillRegistryWatcher(cwd: string, notify: (message: string) => void): void {
461
+ async function startSkillRegistryWatcher(
462
+ cwd: string,
463
+ notify: (message: string) => void,
464
+ ): Promise<void> {
436
465
  if (watchedCwds.has(cwd)) return;
437
466
  watchedCwds.add(cwd);
438
- const dirs = uniqueExistingDirs([...projectSkillDirs(cwd), ...userSkillDirs()]);
467
+ const dirs = await uniqueExistingDirs([
468
+ ...projectSkillDirs(cwd),
469
+ ...userSkillDirs(),
470
+ ]);
439
471
  let timer: ReturnType<typeof setTimeout> | undefined;
440
472
  const refresh = () => {
441
473
  if (timer) clearTimeout(timer);
442
474
  timer = setTimeout(() => {
443
- try {
444
- const result = regenerateRegistry(cwd, false);
445
- if (result.regenerated) {
446
- notify(`Skill registry refreshed (${result.skillCount} skills)`);
475
+ void (async () => {
476
+ try {
477
+ const result = await regenerateRegistry(cwd, false);
478
+ if (result.regenerated) {
479
+ notify(`Skill registry refreshed (${result.skillCount} skills)`);
480
+ }
481
+ } catch {
482
+ // Keep the watcher best-effort; session_start/manual refresh surfaces detailed failures.
447
483
  }
448
- } catch {
449
- // Keep the watcher best-effort; session_start/manual refresh surfaces detailed failures.
450
- }
484
+ })();
451
485
  }, WATCH_DEBOUNCE_MS);
452
486
  };
453
487
  for (const dir of dirs) {
@@ -479,11 +513,14 @@ export default function (pi: ExtensionAPI) {
479
513
  pi.on("session_start", async (_event, ctx) => {
480
514
  if (shouldSkipSkillRegistryStartup(pi)) return;
481
515
  try {
482
- ensureAtlIgnored(ctx.cwd);
483
- const quarantinedLegacy = quarantineLegacyProjectRegistry(ctx.cwd);
484
- const result = regenerateRegistry(ctx.cwd, quarantinedLegacy);
516
+ await ensureAtlIgnored(ctx.cwd);
517
+ const quarantinedLegacy = await quarantineLegacyProjectRegistry(ctx.cwd);
518
+ const result = await regenerateRegistry(ctx.cwd, quarantinedLegacy);
485
519
  if (result.regenerated && ctx.hasUI) {
486
- ctx.ui.notify(`Skill registry refreshed (${result.skillCount} skills)`, "info");
520
+ ctx.ui.notify(
521
+ `Skill registry refreshed (${result.skillCount} skills)`,
522
+ "info",
523
+ );
487
524
  }
488
525
  if (quarantinedLegacy && ctx.hasUI) {
489
526
  ctx.ui.notify(
@@ -491,22 +528,28 @@ export default function (pi: ExtensionAPI) {
491
528
  "warning",
492
529
  );
493
530
  }
494
- startSkillRegistryWatcher(ctx.cwd, (message) => {
531
+ await startSkillRegistryWatcher(ctx.cwd, (message) => {
495
532
  if (ctx.hasUI) ctx.ui.notify(message, "info");
496
533
  });
497
534
  if (quarantinedLegacy) {
498
535
  setTimeout(() => {
499
- try {
500
- regenerateRegistry(ctx.cwd, true);
501
- } catch {
502
- // Best-effort same-session self-heal in case the stale extension already ran.
503
- }
536
+ void (async () => {
537
+ try {
538
+ await regenerateRegistry(ctx.cwd, true);
539
+ } catch {
540
+ // Best-effort same-session self-heal in case the stale extension already ran.
541
+ }
542
+ })();
504
543
  }, WATCH_DEBOUNCE_MS);
505
544
  }
506
545
  } catch (error) {
507
546
  if (ctx.hasUI) {
508
- const message = error instanceof Error ? error.message : String(error);
509
- ctx.ui.notify(`Skill registry refresh failed: ${message}`, "warning");
547
+ const message =
548
+ error instanceof Error ? error.message : String(error);
549
+ ctx.ui.notify(
550
+ `Skill registry refresh failed: ${message}`,
551
+ "warning",
552
+ );
510
553
  }
511
554
  }
512
555
  });
@@ -515,8 +558,8 @@ export default function (pi: ExtensionAPI) {
515
558
  description: "Regenerate .atl/skill-registry.md from local skill sources.",
516
559
  handler: async (_args, ctx) => {
517
560
  try {
518
- ensureAtlIgnored(ctx.cwd);
519
- const result = regenerateRegistry(ctx.cwd, true);
561
+ await ensureAtlIgnored(ctx.cwd);
562
+ const result = await regenerateRegistry(ctx.cwd, true);
520
563
  ctx.ui.notify(
521
564
  `Skill registry: ${result.skillCount} skill(s) written to ${REGISTRY_REL_PATH}`,
522
565
  "info",