@llblab/pi-actors 0.14.3 → 0.16.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 (76) hide show
  1. package/AGENTS.md +5 -1
  2. package/BACKLOG.md +54 -32
  3. package/CHANGELOG.md +39 -0
  4. package/README.md +53 -61
  5. package/banner.jpg +0 -0
  6. package/docs/actor-messages.md +1 -1
  7. package/docs/async-runs.md +4 -4
  8. package/docs/command-templates.md +11 -11
  9. package/docs/recipe-library.md +7 -3
  10. package/docs/task-first-recipes.md +44 -43
  11. package/docs/template-recipes.md +45 -23
  12. package/docs/tool-registry.md +50 -42
  13. package/index.ts +34 -0
  14. package/lib/actor-messages.ts +20 -7
  15. package/lib/async-runs.ts +35 -12
  16. package/lib/command-templates.ts +6 -1
  17. package/lib/config.ts +3 -2
  18. package/lib/execution.ts +9 -5
  19. package/lib/observability.ts +20 -10
  20. package/lib/paths.ts +6 -1
  21. package/lib/prompts.ts +14 -21
  22. package/lib/recipe-discovery.ts +226 -0
  23. package/lib/recipe-migration.ts +123 -0
  24. package/lib/recipe-references.ts +45 -13
  25. package/lib/recipe-usage.ts +44 -0
  26. package/lib/registry.ts +48 -15
  27. package/lib/runtime.ts +59 -15
  28. package/lib/tools.ts +237 -65
  29. package/package.json +21 -11
  30. package/recipes/coordinator-locker.json +46 -0
  31. package/recipes/music-player.json +16 -2
  32. package/recipes/pipeline-architect-coordinator.json +11 -3
  33. package/recipes/pipeline-artifact-bundle.json +12 -3
  34. package/recipes/pipeline-artifact-report.json +9 -3
  35. package/recipes/pipeline-artifact-write.json +9 -3
  36. package/recipes/pipeline-async-run-ops.json +18 -9
  37. package/recipes/pipeline-checkpoint-continuation.json +14 -3
  38. package/recipes/pipeline-development-tasking.json +12 -3
  39. package/recipes/pipeline-docs-maintenance.json +12 -3
  40. package/recipes/pipeline-media-library.json +12 -3
  41. package/recipes/pipeline-quorum-review.json +12 -9
  42. package/recipes/pipeline-release-readiness.json +27 -9
  43. package/recipes/pipeline-release-summary.json +89 -0
  44. package/recipes/pipeline-repo-health.json +12 -3
  45. package/recipes/pipeline-research-synthesis.json +11 -3
  46. package/recipes/pipeline-review-readiness.json +12 -6
  47. package/recipes/subagent-artifact.json +9 -3
  48. package/recipes/subagent-checkpoint.json +10 -3
  49. package/recipes/subagent-conflict-report.json +11 -3
  50. package/recipes/subagent-contradiction-map.json +11 -3
  51. package/recipes/subagent-critic.json +11 -3
  52. package/recipes/subagent-evidence-map.json +11 -3
  53. package/recipes/subagent-followup.json +10 -3
  54. package/recipes/subagent-judge.json +11 -3
  55. package/recipes/subagent-merge.json +11 -3
  56. package/recipes/subagent-message.json +8 -3
  57. package/recipes/subagent-normalize.json +11 -3
  58. package/recipes/subagent-plan.json +11 -3
  59. package/recipes/subagent-prompt.json +10 -3
  60. package/recipes/subagent-quorum.json +10 -7
  61. package/recipes/subagent-review-coordinator.json +14 -6
  62. package/recipes/subagent-review.json +11 -3
  63. package/recipes/subagent-task-card.json +11 -3
  64. package/recipes/subagent-tools.json +10 -3
  65. package/recipes/subagent-verify.json +11 -3
  66. package/recipes/subagents-prompts.json +10 -3
  67. package/recipes/utility-coordinator-lock-snapshot.json +14 -0
  68. package/recipes/utility-run-ops-snapshot.json +3 -3
  69. package/recipes/utility-skill-summary.json +14 -0
  70. package/scripts/coordinator-locker.mjs +272 -0
  71. package/scripts/music-player.mjs +2 -1
  72. package/scripts/recipe-utils.mjs +239 -81
  73. package/scripts/validate-recipe.mjs +28 -10
  74. package/skills/actors/SKILL.md +301 -0
  75. package/skills/swarm/SKILL.md +451 -0
  76. package/skills/swarm/references/development-swarm.md +596 -0
package/lib/async-runs.ts CHANGED
@@ -19,8 +19,8 @@ import {
19
19
  writeFileSync,
20
20
  writeSync,
21
21
  } from "node:fs";
22
- import { basename, extname, join, resolve } from "node:path";
23
22
  import { platform } from "node:os";
23
+ import { basename, extname, join, resolve } from "node:path";
24
24
 
25
25
  import type {
26
26
  CommandTemplateFailureScope,
@@ -28,8 +28,9 @@ import type {
28
28
  } from "./command-templates.ts";
29
29
  import { substituteCommandTemplateToken } from "./command-templates.ts";
30
30
  import { writeJsonAtomic } from "./file-state.ts";
31
- import * as RecipeReferences from "./recipe-references.ts";
32
31
  import * as Paths from "./paths.ts";
32
+ import * as RecipeReferences from "./recipe-references.ts";
33
+ import * as RecipeUsage from "./recipe-usage.ts";
33
34
 
34
35
  export interface AsyncRunStartParams {
35
36
  async?: boolean;
@@ -139,8 +140,7 @@ function resolveArtifactPaths(
139
140
  function resolveRunTemplate(params: AsyncRunStartParams): {
140
141
  template: CommandTemplateValue;
141
142
  } {
142
- if (!params.template)
143
- throw new Error("spawn requires file or template.");
143
+ if (!params.template) throw new Error("spawn requires file or template.");
144
144
  const envelope: Record<string, unknown> = {};
145
145
  for (const key of [
146
146
  "args",
@@ -173,6 +173,12 @@ function resolveRecipeFile(file: string): string {
173
173
  return RecipeReferences.resolveRecipePath(file, DEFAULT_RECIPE_ROOT);
174
174
  }
175
175
 
176
+ function isMutableUsageRecipeFile(file: string): boolean {
177
+ const userRoot = resolve(DEFAULT_RECIPE_ROOT);
178
+ const resolved = resolve(file);
179
+ return resolved.startsWith(`${userRoot}/`);
180
+ }
181
+
176
182
  function readRecipeFile(file: string): AsyncRunStartParams {
177
183
  const path = resolveRecipeFile(file);
178
184
  const raw = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
@@ -227,7 +233,8 @@ function isAlive(pid: number): boolean {
227
233
  }
228
234
 
229
235
  function pidMatchesRun(pid: number, cwd: string, stateDir: string): boolean {
230
- if (platform() !== "linux" || !existsSync(`/proc/${pid}`)) return isAlive(pid);
236
+ if (platform() !== "linux" || !existsSync(`/proc/${pid}`))
237
+ return isAlive(pid);
231
238
  try {
232
239
  const procCwd = readlinkSync(`/proc/${pid}/cwd`);
233
240
  const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8");
@@ -316,6 +323,9 @@ export function startRun(
316
323
  ? resolveRecipeFile(startParams.file)
317
324
  : undefined;
318
325
  const recipe = startParams.name || getRunIdFromFile(recipeFile);
326
+ if (recipeFile && isMutableUsageRecipeFile(recipeFile)) {
327
+ RecipeUsage.recordRecipeLaunch(recipeFile);
328
+ }
319
329
  const outFd = openSync(stdout, "a");
320
330
  const errFd = openSync(stderr, "a");
321
331
  const argv = ["--experimental-strip-types", RUNNER_PATH, stateDir];
@@ -405,15 +415,23 @@ function normalizeRunOutboxEvent(
405
415
  : `${run}:${index}`;
406
416
  return {
407
417
  ...(record.body !== undefined ? { body: record.body } : {}),
408
- ...(typeof record.correlation_id === "string" ? { correlation_id: record.correlation_id } : {}),
418
+ ...(typeof record.correlation_id === "string"
419
+ ? { correlation_id: record.correlation_id }
420
+ : {}),
409
421
  ...(record.data !== undefined ? { data: record.data } : {}),
410
422
  delivery: normalizeRunOutboxDelivery(record.delivery),
411
- ...(record.metadata && typeof record.metadata === "object" && !Array.isArray(record.metadata) ? { metadata: record.metadata as Record<string, unknown> } : {}),
423
+ ...(record.metadata &&
424
+ typeof record.metadata === "object" &&
425
+ !Array.isArray(record.metadata)
426
+ ? { metadata: record.metadata as Record<string, unknown> }
427
+ : {}),
412
428
  event,
413
429
  ...(typeof record.from === "string" ? { from: record.from } : {}),
414
430
  id,
415
431
  level: normalizeRunOutboxLevel(record.level),
416
- ...(typeof record.reply_to === "string" ? { reply_to: record.reply_to } : {}),
432
+ ...(typeof record.reply_to === "string"
433
+ ? { reply_to: record.reply_to }
434
+ : {}),
417
435
  run,
418
436
  state_dir: stateDir,
419
437
  summary,
@@ -448,8 +466,9 @@ export function getRunStatus(runOrDir: string): Record<string, unknown> {
448
466
  const pid = Number(meta.pid || 0);
449
467
  const aliveOwnedRunner = Boolean(
450
468
  pid &&
451
- isAlive(pid) &&
452
- (!Array.isArray(meta.argv) || pidMatchesRun(pid, String(meta.cwd ?? ""), stateDir)),
469
+ isAlive(pid) &&
470
+ (!Array.isArray(meta.argv) ||
471
+ pidMatchesRun(pid, String(meta.cwd ?? ""), stateDir)),
453
472
  );
454
473
  const status: AsyncRunStatus = result
455
474
  ? Number(result.code ?? 0) === 0
@@ -555,7 +574,9 @@ export function appendRunOutboxEvent(
555
574
  ...(event.body !== undefined ? { body: event.body } : {}),
556
575
  ...(event.correlation_id ? { correlation_id: event.correlation_id } : {}),
557
576
  ...(event.data !== undefined ? { data: event.data } : {}),
558
- delivery: normalizeRunOutboxDelivery(event.delivery ?? (to === "coordinator" ? "followup" : "log")),
577
+ delivery: normalizeRunOutboxDelivery(
578
+ event.delivery ?? (to === "coordinator" ? "followup" : "log"),
579
+ ),
559
580
  event: type,
560
581
  from: event.from || `run:${run}`,
561
582
  level: normalizeRunOutboxLevel(event.level),
@@ -608,7 +629,9 @@ export function sendRunMessage(
608
629
  fd = openSync(controlPath, constants.O_WRONLY | constants.O_NONBLOCK);
609
630
  const bytes = writeSync(fd, payload);
610
631
  const trimmedMessage = message.trim().toLowerCase();
611
- const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(trimmedMessage);
632
+ const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(
633
+ trimmedMessage,
634
+ );
612
635
  writeFileSync(
613
636
  join(stateDir, "events.jsonl"),
614
637
  `${JSON.stringify({ bytes, event: "run.message", terminal: terminalMessage || undefined, ts: new Date().toISOString() })}\n`,
@@ -541,7 +541,12 @@ function shouldResolveEmbeddedCommandTemplateToken(
541
541
  function isFalsyCommandTemplateValue(value: unknown): boolean {
542
542
  if (value === undefined || value === null || value === false) return true;
543
543
  const normalized = String(value).trim().toLowerCase();
544
- return normalized === "" || normalized === "0" || normalized === "false" || normalized === "no";
544
+ return (
545
+ normalized === "" ||
546
+ normalized === "0" ||
547
+ normalized === "false" ||
548
+ normalized === "no"
549
+ );
545
550
  }
546
551
 
547
552
  function resolveCommandTemplateCondition(
package/lib/config.ts CHANGED
@@ -6,12 +6,12 @@
6
6
 
7
7
  import { existsSync, readFileSync } from "node:fs";
8
8
 
9
+ import type { CommandTemplateValue } from "./command-templates.ts";
10
+ import * as CommandTemplates from "./command-templates.ts";
9
11
  import { writeJsonAtomic } from "./file-state.ts";
10
12
  import { normalizeToolName } from "./identity.ts";
11
- import * as CommandTemplates from "./command-templates.ts";
12
13
  import * as RecipeReferences from "./recipe-references.ts";
13
14
  import * as Schema from "./schema.ts";
14
- import type { CommandTemplateValue } from "./command-templates.ts";
15
15
 
16
16
  export interface RegisteredTool {
17
17
  name: string;
@@ -23,6 +23,7 @@ export interface RegisteredTool {
23
23
  template?: CommandTemplateValue;
24
24
  storedArgs?: string[];
25
25
  storedDefaults?: Record<string, string>;
26
+ sourcePath?: string;
26
27
  }
27
28
 
28
29
  export interface LoadConfigResult {
package/lib/execution.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  * Owns command-template invocation execution and pi tool-result payload formatting
5
5
  */
6
6
 
7
+ import * as CommandTemplates from "./command-templates.ts";
7
8
  import type { RegisteredTool } from "./config.ts";
8
9
  import { formatFailureOutput, formatOutput, formatToolText } from "./output.ts";
9
- import * as CommandTemplates from "./command-templates.ts";
10
10
  import * as Schema from "./schema.ts";
11
11
 
12
12
  export interface ToolExecOptions {
@@ -268,9 +268,10 @@ function resolveNumericControlField(
268
268
  label: string,
269
269
  ): number | undefined {
270
270
  if (value === undefined) return undefined;
271
- const resolved = typeof value === "string"
272
- ? CommandTemplates.substituteCommandTemplateToken(value, values, label)
273
- : value;
271
+ const resolved =
272
+ typeof value === "string"
273
+ ? CommandTemplates.substituteCommandTemplateToken(value, values, label)
274
+ : value;
274
275
  if (resolved === "") return undefined;
275
276
  const numeric = Number(resolved);
276
277
  if (!Number.isFinite(numeric) || numeric < 0)
@@ -407,7 +408,10 @@ async function executeTemplateConfig(
407
408
  const controlValues = { ...(context.defaults ?? {}), ...params };
408
409
  await applyDelay(normalized.delay, controlValues, signal);
409
410
  if (
410
- !CommandTemplates.shouldRunCommandTemplateNode(normalized.when, controlValues)
411
+ !CommandTemplates.shouldRunCommandTemplateNode(
412
+ normalized.when,
413
+ controlValues,
414
+ )
411
415
  ) {
412
416
  return {
413
417
  branches: [],
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { existsSync, readdirSync, readFileSync } from "node:fs";
8
- import { basename, dirname, relative, join } from "node:path";
8
+ import { basename, dirname, join, relative } from "node:path";
9
9
 
10
10
  import * as AsyncRuns from "./async-runs.ts";
11
11
  import * as Paths from "./paths.ts";
@@ -114,7 +114,9 @@ function observeRun(stateDir: string): RunObservation | undefined {
114
114
  ...(typeof status.ownerId === "string"
115
115
  ? { ownerId: status.ownerId }
116
116
  : {}),
117
- ...(status.artifacts && typeof status.artifacts === "object" && !Array.isArray(status.artifacts)
117
+ ...(status.artifacts &&
118
+ typeof status.artifacts === "object" &&
119
+ !Array.isArray(status.artifacts)
118
120
  ? { artifacts: status.artifacts as Record<string, string> }
119
121
  : {}),
120
122
  ...(status.terminal_handled ? { terminalHandled: true } : {}),
@@ -359,7 +361,11 @@ function parseOutboxLine(
359
361
  event,
360
362
  id,
361
363
  level: normalizeOutboxLevel(raw.level),
362
- ...(raw.metadata && typeof raw.metadata === "object" && !Array.isArray(raw.metadata) ? { metadata: raw.metadata as Record<string, unknown> } : {}),
364
+ ...(raw.metadata &&
365
+ typeof raw.metadata === "object" &&
366
+ !Array.isArray(raw.metadata)
367
+ ? { metadata: raw.metadata as Record<string, unknown> }
368
+ : {}),
363
369
  run: run.run,
364
370
  stateDir: run.stateDir,
365
371
  summary,
@@ -413,7 +419,8 @@ export function shouldSendRunOutboxFollowUp(event: RunOutboxEvent): boolean {
413
419
 
414
420
  function commonDirectory(paths: string[]): string | undefined {
415
421
  if (paths.length === 0) return undefined;
416
- const split = (path: string): string[] => dirname(path).split("/").filter(Boolean);
422
+ const split = (path: string): string[] =>
423
+ dirname(path).split("/").filter(Boolean);
417
424
  const first = split(paths[0]);
418
425
  let length = first.length;
419
426
  for (const path of paths.slice(1)) {
@@ -440,7 +447,9 @@ function formatPathGroup(label: string, paths: string[]): string {
440
447
  const unique = [...new Set(paths.filter(Boolean))].slice(0, 8);
441
448
  if (unique.length === 0) return "";
442
449
  const base = commonDirectory(unique);
443
- const names = unique.map((path) => `\`${relativeName(base, path)}\``).join(", ");
450
+ const names = unique
451
+ .map((path) => `\`${relativeName(base, path)}\``)
452
+ .join(", ");
444
453
  return `\n${label}:\n- Base: ${base ? `\`${base}\`` : "current run"}\n- Files: ${names}`;
445
454
  }
446
455
 
@@ -464,7 +473,9 @@ function formatNamedArtifacts(artifacts: unknown): string {
464
473
  }
465
474
 
466
475
  function getOutboxField(event: RunOutboxEvent, key: string): unknown {
467
- return event.data && typeof event.data === "object" && !Array.isArray(event.data)
476
+ return event.data &&
477
+ typeof event.data === "object" &&
478
+ !Array.isArray(event.data)
468
479
  ? (event.data as Record<string, unknown>)[key]
469
480
  : undefined;
470
481
  }
@@ -478,7 +489,8 @@ function formatBodyPreview(body: unknown): string {
478
489
  }
479
490
 
480
491
  export function formatRunOutboxMessage(event: RunOutboxEvent): string {
481
- if (event.event === "command.done") return `Run ${event.run}: ${event.summary}`;
492
+ if (event.event === "command.done")
493
+ return `Run ${event.run}: ${event.summary}`;
482
494
  return `Run ${event.run}: ${event.summary}${formatBodyPreview(event.body)}${formatNamedArtifacts(getOutboxField(event, "artifacts"))}${formatRunFileList(getOutboxField(event, "run_files"))}`;
483
495
  }
484
496
 
@@ -491,9 +503,7 @@ export function getRunTransitionNotificationType(
491
503
  return "error";
492
504
  }
493
505
 
494
- export function shouldNotifyRunTransition(
495
- transition: RunTransition,
496
- ): boolean {
506
+ export function shouldNotifyRunTransition(transition: RunTransition): boolean {
497
507
  if (transition.terminalHandled) return false;
498
508
  return (
499
509
  transition.to === "done" ||
package/lib/paths.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  */
6
6
 
7
7
  import { homedir } from "node:os";
8
- import { join, resolve } from "node:path";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
9
10
 
10
11
  export function getAgentDir(
11
12
  env: Record<string, string | undefined> = process.env,
@@ -33,3 +34,7 @@ export function getRunStateRoot(agentDir = getAgentDir()): string {
33
34
  export function getRecipeRoot(agentDir = getAgentDir()): string {
34
35
  return join(agentDir, "recipes");
35
36
  }
37
+
38
+ export function getPackagedRecipeRoot(): string {
39
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..", "recipes");
40
+ }
package/lib/prompts.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  export const REGISTER_TOOL_DESCRIPTION =
8
8
  "Register a persistent custom tool from a command template, template recipe path, or co-located template recipe. " +
9
- "Definitions are stored in actors-tools.json across reloads. " +
9
+ "Definitions are stored as recipe files under ~/.pi/agent/recipes across reloads. " +
10
10
  "Use update=true to overwrite an existing tool, template=null/empty to delete.";
11
11
 
12
12
  export const REGISTER_TOOL_PROMPT_SNIPPET =
@@ -20,25 +20,18 @@ export const REGISTER_TOOL_GUIDELINES = [
20
20
  ];
21
21
 
22
22
  export const ONBOARDING_SYSTEM_PROMPT = `pi-actors quick model:
23
- - Local-first cybernetic tool memory: agents persist trusted local capabilities instead of repeating shell recipes.
24
- - Task = user work; template = execution graph; recipe = saved JSON; run = execution instance.
25
- - Command templates stay sync: string leaf, array sequence, object flags, parallel: true fanout.
26
- - Template flags: args/defaults, parallel, when, timeout, delay, retry, failure, recover, repeat, output; placeholders support {value??fallback} and {flag?yes:no}.
27
- - Recipes live in ~/.pi/agent/recipes/*.json and wrap templates with metadata/defaults/imports/artifacts.
28
- - Recipe imports are local variables: imports.alias -> {"name":"alias"} nodes and {alias.defaults.key} refs.
29
- - Imported recipes are definitions, not nested async runs; parent async:true creates one run.
30
- - async:true = detached lifecycle; spawn creates run actors from recipes/templates.
31
- - Actor run state lives under ~/.pi/agent/tmp/pi-actors/runs.
32
- - Use spawn/message/inspect for actor-level start/send/observe; runtime action internals are absorbed into the actor API.
33
- - Run lifecycle = state files, logs, actor messages, mailbox send, cancel/kill, compact status; do not busy-poll runs, rely on message/follow-up notifications and use message for explicit run-local commands.
34
- - Tool template may be a command template, recipe path/name, or co-located recipe.
35
- - register_tool makes compact persistent buttons; args may be typed or derived from placeholders.
36
- - For single calls or short pipelines, use foreground templates/tools.
37
- - For subagents, swarms, background music, or long fanout, prefer async recipes/runs.
38
- - Long async fanout = parent async recipe wrapping template(parallel: true) and imports; packaged fanout recipes bubble branch completion follow-ups by default.
39
- - If asked to explore pi-actors, read README.md, docs/README.md, docs/template-recipes.md, docs/async-runs.md, and recipes/.
40
- - Ambient triangles show active async commands/subagents for the launching coordinator.
41
- - After async run finish, inspect status/tail/messages before final artifacts.`;
23
+ - Local-first actor memory: persist trusted local capabilities instead of rebuilding shell recipes.
24
+ - Layers: task -> command template -> recipe/tool -> spawn -> run:<id>; tool:<name> wraps registered capabilities.
25
+ - Command templates stay sync: string leaf, array sequence, object node; flags include args/defaults, parallel, when, timeout, delay, retry, failure, recover, repeat, output.
26
+ - Placeholders support typed/default args plus {value??fallback} and {flag?yes:no}.
27
+ - Recipes live in ~/.pi/agent/recipes/*.json, own template directly, and may declare metadata/defaults/imports/mailbox/artifacts.
28
+ - Recipe imports are local variables; imported recipes are definitions, not nested async runs; parent async:true creates one run.
29
+ - Use spawn/message/inspect for actor-level start/send/observe; avoid runtime/FIFO/outbox vocabulary in public guidance.
30
+ - Run state lives under ~/.pi/agent/tmp/pi-actors/runs; inspect status/tail/messages/mailbox/files/artifacts intentionally and avoid busy-polling.
31
+ - Register_tool writes user recipe files in ~/.pi/agent/recipes; that root is the default tool set, while packaged recipes are the lower-priority standard library.
32
+ - Foreground tools/templates fit short work; async recipes/runs fit subagents, services, fanout, media, and long pipelines.
33
+ - Long fanout = parent async recipe wrapping template(parallel:true) and imports; packaged fanout recipes bubble branch completion messages.
34
+ - For deeper pi-actors guidance, inspect installed extension sources/docs/recipes; README and docs are not automatically in context.`;
42
35
 
43
36
  export const REGISTER_TOOL_PARAM_DESCRIPTIONS = {
44
37
  name: "Tool name in snake_case (e.g., 'transcribe')",
@@ -53,7 +46,7 @@ export const REGISTER_TOOL_PARAM_DESCRIPTIONS = {
53
46
  templateArray:
54
47
  "Sequential command-template composition array. Leaves may be strings or objects with template/defaults/timeout/retry/failure/recover.",
55
48
  templateNull: "Delete the tool when template is null.",
56
- args: "Optional comma-separated placeholder declarations. Usually omit because args are derived from template placeholders. Interactive shorthand defaults are accepted and normalized. Example: file,lang,model=openai-codex/gpt-5.5",
49
+ args: "Optional comma-separated placeholder declarations. Usually omit because args are derived from template placeholders. Interactive shorthand defaults are accepted and normalized. Example: file,lang,mode=fast",
57
50
  update: "Set to true to overwrite an existing tool registration.",
58
51
  values:
59
52
  "Optional default runtime placeholder values for a co-located template recipe.",
@@ -0,0 +1,226 @@
1
+ /**
2
+ * File-discovered recipe registry helpers
3
+ * Zones: recipe discovery, tool exposure, migration diagnostics
4
+ * Owns filename identity discovery across prioritized recipe roots
5
+ */
6
+
7
+ import { existsSync, readdirSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ import type { RegisteredTool } from "./config.ts";
11
+ import type { TemplateRecipeConfig } from "./recipe-references.ts";
12
+ import * as RecipeReferences from "./recipe-references.ts";
13
+ import * as Schema from "./schema.ts";
14
+
15
+ export interface DiscoveredRecipe {
16
+ id: string;
17
+ path: string;
18
+ root: string;
19
+ priority: number;
20
+ config?: TemplateRecipeConfig;
21
+ active: boolean;
22
+ shadowed: boolean;
23
+ invalid: boolean;
24
+ disabled: boolean;
25
+ tool: boolean;
26
+ mutableUsage: boolean;
27
+ diagnostics: string[];
28
+ shadows: string[];
29
+ }
30
+
31
+ export interface RecipeDiscoveryResult {
32
+ active: Map<string, DiscoveredRecipe>;
33
+ entries: DiscoveredRecipe[];
34
+ diagnostics: string[];
35
+ }
36
+
37
+ export interface RecipeDiscoverySource {
38
+ root?: string;
39
+ file?: string;
40
+ defaultTool?: boolean;
41
+ mutableUsage?: boolean;
42
+ }
43
+
44
+ function listRecipeFiles(root: string): string[] {
45
+ if (!existsSync(root)) return [];
46
+ return readdirSync(root, { withFileTypes: true })
47
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
48
+ .map((entry) => join(root, entry.name))
49
+ .sort();
50
+ }
51
+
52
+ function readDiscoveredRecipe(
53
+ root: string,
54
+ file: string,
55
+ priority: number,
56
+ defaultTool = false,
57
+ mutableUsage = false,
58
+ ): DiscoveredRecipe {
59
+ const id = RecipeReferences.getRecipeIdFromPath(file);
60
+ try {
61
+ const config = RecipeReferences.readResolvedRecipeConfig(file);
62
+ const invalid = !config;
63
+ const disabled = config?.disabled === true;
64
+ return {
65
+ id,
66
+ path: file,
67
+ root,
68
+ priority,
69
+ config,
70
+ active: false,
71
+ shadowed: false,
72
+ invalid,
73
+ disabled,
74
+ tool: (config?.tool ?? defaultTool) === true && !disabled && !invalid,
75
+ mutableUsage,
76
+ diagnostics: invalid ? [`Invalid recipe: ${file}`] : [],
77
+ shadows: [],
78
+ };
79
+ } catch (error) {
80
+ return {
81
+ id,
82
+ path: file,
83
+ root,
84
+ priority,
85
+ active: false,
86
+ shadowed: false,
87
+ invalid: true,
88
+ disabled: false,
89
+ tool: false,
90
+ mutableUsage,
91
+ diagnostics: [
92
+ `Failed to load recipe ${file}: ${error instanceof Error ? error.message : String(error)}`,
93
+ ],
94
+ shadows: [],
95
+ };
96
+ }
97
+ }
98
+
99
+ function filesForSource(
100
+ source: RecipeDiscoverySource,
101
+ ): Array<{ root: string; file: string; defaultTool: boolean; mutableUsage: boolean }> {
102
+ const defaultTool = source.defaultTool === true;
103
+ const mutableUsage = source.mutableUsage === true;
104
+ if (source.file)
105
+ return [{ root: source.root ?? source.file, file: source.file, defaultTool, mutableUsage }];
106
+ return source.root
107
+ ? listRecipeFiles(source.root).map((file) => ({
108
+ root: source.root!,
109
+ file,
110
+ defaultTool,
111
+ mutableUsage,
112
+ }))
113
+ : [];
114
+ }
115
+
116
+ export function discoverRecipeSources(
117
+ sources: RecipeDiscoverySource[],
118
+ ): RecipeDiscoveryResult {
119
+ const entries = sources.flatMap((source, priority) =>
120
+ filesForSource(source).map(({ root, file, defaultTool, mutableUsage }) =>
121
+ readDiscoveredRecipe(root, file, priority, defaultTool, mutableUsage),
122
+ ),
123
+ );
124
+ const byId = new Map<string, DiscoveredRecipe[]>();
125
+ for (const entry of entries) {
126
+ const bucket = byId.get(entry.id) ?? [];
127
+ bucket.push(entry);
128
+ byId.set(entry.id, bucket);
129
+ }
130
+
131
+ const active = new Map<string, DiscoveredRecipe>();
132
+ const diagnostics: string[] = [];
133
+ for (const [id, bucket] of byId) {
134
+ bucket.sort(
135
+ (a, b) => a.priority - b.priority || a.path.localeCompare(b.path),
136
+ );
137
+ const winner = bucket[0];
138
+ winner.active = true;
139
+ winner.shadows = bucket.slice(1).map((entry) => entry.path);
140
+ active.set(id, winner);
141
+ for (const shadow of bucket.slice(1)) shadow.shadowed = true;
142
+ if (winner.invalid)
143
+ diagnostics.push(
144
+ `Recipe ${id} is invalid and blocks lower-priority recipes`,
145
+ );
146
+ if (winner.disabled)
147
+ diagnostics.push(
148
+ `Recipe ${id} is disabled and blocks lower-priority recipes`,
149
+ );
150
+ if (winner.shadows.length > 0)
151
+ diagnostics.push(
152
+ `Recipe ${id} shadows ${winner.shadows.length} lower-priority recipe(s)`,
153
+ );
154
+ diagnostics.push(...winner.diagnostics);
155
+ }
156
+
157
+ return { active, entries, diagnostics };
158
+ }
159
+
160
+ export function discoverRecipes(roots: string[]): RecipeDiscoveryResult {
161
+ return discoverRecipeSources(roots.map((root) => ({ root })));
162
+ }
163
+
164
+ export function summarizeDiscovery(result: RecipeDiscoveryResult): Record<string, unknown> {
165
+ return {
166
+ active: [...result.active.values()].map((entry) => ({
167
+ id: entry.id,
168
+ path: entry.path,
169
+ description: entry.config?.description,
170
+ tool: entry.tool,
171
+ disabled: entry.disabled,
172
+ invalid: entry.invalid,
173
+ shadows: entry.shadows,
174
+ })).sort((a, b) => a.id.localeCompare(b.id)),
175
+ shadowed: result.entries
176
+ .filter((entry) => entry.shadowed)
177
+ .map((entry) => ({ id: entry.id, path: entry.path, shadowedBy: result.active.get(entry.id)?.path }))
178
+ .sort((a, b) => a.id.localeCompare(b.id) || a.path.localeCompare(b.path)),
179
+ invalid: result.entries
180
+ .filter((entry) => entry.invalid)
181
+ .map((entry) => ({ id: entry.id, path: entry.path, diagnostics: entry.diagnostics }))
182
+ .sort((a, b) => a.id.localeCompare(b.id)),
183
+ disabled: result.entries
184
+ .filter((entry) => entry.disabled)
185
+ .map((entry) => ({ id: entry.id, path: entry.path }))
186
+ .sort((a, b) => a.id.localeCompare(b.id)),
187
+ diagnostics: result.diagnostics,
188
+ };
189
+ }
190
+
191
+ export function toRegisteredTool(entry: DiscoveredRecipe): RegisteredTool | undefined {
192
+ if (!entry.tool || entry.invalid || entry.disabled || !entry.config) return undefined;
193
+ const cfg = entry.config;
194
+ const template = entry.path;
195
+ const description = cfg.description ?? `Execute template recipe: ${entry.id}`;
196
+ const argTemplate = cfg.template;
197
+ const argTemplateConfig =
198
+ typeof argTemplate === "object" && !Array.isArray(argTemplate)
199
+ ? {
200
+ ...argTemplate,
201
+ ...(cfg.args !== undefined ? { args: cfg.args } : {}),
202
+ defaults: { ...(argTemplate.defaults ?? {}), ...(cfg.defaults ?? {}) },
203
+ }
204
+ : { args: cfg.args, defaults: cfg.defaults ?? {}, template: argTemplate };
205
+ const argTypes = Schema.getTemplateArgTypes(argTemplateConfig);
206
+ return {
207
+ name: entry.id,
208
+ description,
209
+ template,
210
+ recipe: cfg,
211
+ args: Schema.getToolArgNames(argTemplateConfig),
212
+ defaults: Object.fromEntries(
213
+ Object.entries(cfg.defaults ?? {}).map(([key, value]) => [key, String(value)]),
214
+ ),
215
+ ...(Object.keys(argTypes).length > 0 ? { argTypes } : {}),
216
+ ...(entry.mutableUsage ? { sourcePath: entry.path } : {}),
217
+ ...(cfg.args ? { storedArgs: cfg.args } : {}),
218
+ ...(cfg.defaults
219
+ ? {
220
+ storedDefaults: Object.fromEntries(
221
+ Object.entries(cfg.defaults).map(([key, value]) => [key, String(value)]),
222
+ ),
223
+ }
224
+ : {}),
225
+ };
226
+ }