@llblab/pi-actors 0.19.11 → 0.20.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.
Files changed (55) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/dist/lib/actor-inspector-tui.d.ts +55 -0
  4. package/dist/lib/actor-inspector-tui.js +559 -0
  5. package/dist/lib/actor-messages.d.ts +25 -0
  6. package/dist/lib/actor-messages.js +122 -0
  7. package/dist/lib/actor-recipe-context.d.ts +14 -0
  8. package/dist/lib/actor-recipe-context.js +79 -0
  9. package/dist/lib/actor-rooms.d.ts +81 -0
  10. package/dist/lib/actor-rooms.js +468 -0
  11. package/dist/lib/async-runs.d.ts +101 -0
  12. package/dist/lib/async-runs.js +612 -0
  13. package/dist/lib/command-templates.d.ts +70 -0
  14. package/dist/lib/command-templates.js +592 -0
  15. package/dist/lib/config.d.ts +34 -0
  16. package/dist/lib/config.js +226 -0
  17. package/dist/lib/execution.d.ts +63 -0
  18. package/dist/lib/execution.js +450 -0
  19. package/dist/lib/file-state.d.ts +6 -0
  20. package/dist/lib/file-state.js +25 -0
  21. package/dist/lib/identity.d.ts +9 -0
  22. package/dist/lib/identity.js +27 -0
  23. package/dist/lib/observability.d.ts +86 -0
  24. package/dist/lib/observability.js +534 -0
  25. package/dist/lib/output.d.ts +25 -0
  26. package/dist/lib/output.js +89 -0
  27. package/dist/lib/paths.d.ts +11 -0
  28. package/dist/lib/paths.js +33 -0
  29. package/dist/lib/prompts.d.ts +23 -0
  30. package/dist/lib/prompts.js +50 -0
  31. package/dist/lib/recipe-discovery.d.ts +50 -0
  32. package/dist/lib/recipe-discovery.js +317 -0
  33. package/dist/lib/recipe-migration.d.ts +21 -0
  34. package/dist/lib/recipe-migration.js +90 -0
  35. package/dist/lib/recipe-references.d.ts +67 -0
  36. package/dist/lib/recipe-references.js +542 -0
  37. package/dist/lib/recipe-usage.d.ts +6 -0
  38. package/dist/lib/recipe-usage.js +57 -0
  39. package/dist/lib/registry.d.ts +47 -0
  40. package/dist/lib/registry.js +222 -0
  41. package/dist/lib/runtime.d.ts +36 -0
  42. package/dist/lib/runtime.js +126 -0
  43. package/dist/lib/schema.d.ts +48 -0
  44. package/dist/lib/schema.js +355 -0
  45. package/dist/lib/temp.d.ts +10 -0
  46. package/dist/lib/temp.js +90 -0
  47. package/dist/lib/tools.d.ts +39 -0
  48. package/dist/lib/tools.js +982 -0
  49. package/lib/async-runs.ts +20 -4
  50. package/lib/paths.ts +5 -1
  51. package/package.json +5 -2
  52. package/scripts/async-runner.mjs +8 -12
  53. package/scripts/validate-recipe.mjs +9 -13
  54. package/skills/actors/SKILL.md +1 -1
  55. package/skills/swarm/SKILL.md +1 -1
@@ -0,0 +1,542 @@
1
+ /**
2
+ * Template recipe reference helpers
3
+ * Zones: registry config, async runs, path resolution
4
+ * Owns detection, loading, and recipe-layer expansion for template recipe files
5
+ */
6
+ import { existsSync, readFileSync, statSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { basename, dirname, resolve } from "node:path";
9
+ import * as CommandTemplates from "./command-templates.js";
10
+ import * as Paths from "./paths.js";
11
+ const MAX_RECIPE_FILE_BYTES = 1024 * 1024;
12
+ const MAX_RECIPE_IMPORT_DEPTH = 32;
13
+ function hasWhitespace(value) {
14
+ return /\s/.test(value);
15
+ }
16
+ export function resolveRecipePath(value, recipeRoot = Paths.getRecipeRoot()) {
17
+ const trimmed = value.trim();
18
+ const repoRoot = resolve(recipeRoot, "..");
19
+ const expanded = trimmed
20
+ .replaceAll("{repo}", repoRoot)
21
+ .replaceAll("{agent}", Paths.getAgentDir());
22
+ if (expanded.startsWith("~/"))
23
+ return resolve(homedir(), expanded.slice(2));
24
+ if (expanded.includes("/"))
25
+ return resolve(expanded);
26
+ return resolve(recipeRoot, expanded.endsWith(".json") ? expanded : `${expanded}.json`);
27
+ }
28
+ function isBareRecipeName(value) {
29
+ const trimmed = value.trim();
30
+ return Boolean(trimmed) && !trimmed.includes("/") && !trimmed.startsWith("~") && !trimmed.includes("{");
31
+ }
32
+ function recipeNameFile(value) {
33
+ const trimmed = value.trim();
34
+ return trimmed.endsWith(".json") ? trimmed : `${trimmed}.json`;
35
+ }
36
+ function resolveRecipeImportPath(value, currentRecipeRoot) {
37
+ if (!isBareRecipeName(value))
38
+ return resolveRecipePath(value, currentRecipeRoot);
39
+ const file = recipeNameFile(value);
40
+ const roots = [
41
+ Paths.getRecipeRoot(),
42
+ currentRecipeRoot,
43
+ Paths.getPackagedRecipeRoot(),
44
+ ];
45
+ const candidates = [...new Set(roots.map((root) => resolve(root, file)))];
46
+ return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
47
+ }
48
+ export function getRecipePath(value, recipeRoot = Paths.getRecipeRoot()) {
49
+ if (typeof value !== "string")
50
+ return undefined;
51
+ const trimmed = value.trim();
52
+ if (!trimmed || hasWhitespace(trimmed))
53
+ return undefined;
54
+ if (trimmed.endsWith(".json"))
55
+ return resolveRecipePath(trimmed, recipeRoot);
56
+ const path = resolveRecipePath(trimmed, recipeRoot);
57
+ if (!existsSync(path))
58
+ return undefined;
59
+ try {
60
+ const raw = JSON.parse(readFileSync(path, "utf8"));
61
+ return raw && typeof raw === "object" && Object.hasOwn(raw, "template")
62
+ ? path
63
+ : undefined;
64
+ }
65
+ catch {
66
+ return undefined;
67
+ }
68
+ }
69
+ function isImportNode(value) {
70
+ if (!isRecord(value) || Object.hasOwn(value, "template"))
71
+ return false;
72
+ return typeof value.name === "string";
73
+ }
74
+ function isValidRecipeTemplateNode(value) {
75
+ if (isImportNode(value))
76
+ return true;
77
+ if (isRecord(value)) {
78
+ if (isImportNode(value.template))
79
+ return true;
80
+ if (Array.isArray(value.template))
81
+ return isValidRecipeTemplateArray(value.template);
82
+ }
83
+ return (CommandTemplates.expandCommandTemplateConfigs(value).length > 0);
84
+ }
85
+ function isValidRecipeTemplateArray(value) {
86
+ return (value.length > 0 && value.every((item) => isValidRecipeTemplateNode(item)));
87
+ }
88
+ function normalizeRecipeTemplate(value) {
89
+ if (typeof value === "string")
90
+ return value.trim() || undefined;
91
+ if (Array.isArray(value)) {
92
+ const template = value;
93
+ return isValidRecipeTemplateArray(template) ? template : undefined;
94
+ }
95
+ if (isImportNode(value))
96
+ return value;
97
+ if (value && typeof value === "object") {
98
+ const template = value;
99
+ if (Array.isArray(template.template) &&
100
+ isValidRecipeTemplateArray(template.template))
101
+ return template;
102
+ if (isImportNode(template.template))
103
+ return template;
104
+ return CommandTemplates.expandCommandTemplateConfigs(template).length > 0
105
+ ? template
106
+ : undefined;
107
+ }
108
+ return undefined;
109
+ }
110
+ function getRecipeCommandTemplate(raw) {
111
+ const template = raw.template;
112
+ const envelope = {};
113
+ for (const key of [
114
+ "args",
115
+ "defaults",
116
+ "parallel",
117
+ "label",
118
+ "when",
119
+ "timeout",
120
+ "delay",
121
+ "output",
122
+ "retry",
123
+ "failure",
124
+ "recover",
125
+ "repeat",
126
+ ]) {
127
+ if (raw[key] !== undefined)
128
+ envelope[key] = raw[key];
129
+ }
130
+ if (Object.keys(envelope).length === 0)
131
+ return normalizeRecipeTemplate(template);
132
+ if (template && typeof template === "object" && !Array.isArray(template)) {
133
+ return normalizeRecipeTemplate({
134
+ ...envelope,
135
+ ...template,
136
+ });
137
+ }
138
+ return normalizeRecipeTemplate({ ...envelope, template });
139
+ }
140
+ export function readRawRecipeConfig(path) {
141
+ if (!existsSync(path))
142
+ return undefined;
143
+ const size = statSync(path).size;
144
+ if (size > MAX_RECIPE_FILE_BYTES) {
145
+ throw new Error(`Recipe file exceeds size limit ${MAX_RECIPE_FILE_BYTES} bytes: ${path}`);
146
+ }
147
+ try {
148
+ const raw = JSON.parse(readFileSync(path, "utf8"));
149
+ return raw && typeof raw === "object" ? raw : undefined;
150
+ }
151
+ catch {
152
+ return undefined;
153
+ }
154
+ }
155
+ export function getRecipeIdFromPath(file) {
156
+ return basename(file, ".json");
157
+ }
158
+ function readRecipeConfig(value) {
159
+ const path = getRecipePath(value);
160
+ return path ? readResolvedRecipeConfig(path) : undefined;
161
+ }
162
+ function isRecord(value) {
163
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
164
+ }
165
+ function getRecipeImports(raw) {
166
+ if (!isRecord(raw.imports))
167
+ return {};
168
+ const result = {};
169
+ for (const [alias, value] of Object.entries(raw.imports)) {
170
+ if (!/^[A-Za-z0-9_.-]+$/.test(alias))
171
+ throw new Error(`Invalid recipe import alias: ${alias}`);
172
+ if (typeof value === "string") {
173
+ result[alias] = value;
174
+ continue;
175
+ }
176
+ if (!isRecord(value))
177
+ throw new Error(`Recipe import must be a string or object: ${alias}`);
178
+ const from = typeof value.from === "string" ? value.from.trim() : "";
179
+ if (!from)
180
+ throw new Error(`Recipe import must define from: ${alias}`);
181
+ result[alias] = {
182
+ from,
183
+ ...(isRecord(value.defaults) ? { defaults: value.defaults } : {}),
184
+ ...(isRecord(value.values) ? { values: value.values } : {}),
185
+ };
186
+ }
187
+ return result;
188
+ }
189
+ function getImportFrom(value) {
190
+ return typeof value === "string" ? value : (value.from ?? "");
191
+ }
192
+ function getPathValue(source, path) {
193
+ if (!path)
194
+ return source;
195
+ let current = source;
196
+ for (const key of path.split(".")) {
197
+ if (!key)
198
+ continue;
199
+ if (!isRecord(current) || !Object.hasOwn(current, key))
200
+ return undefined;
201
+ current = current[key];
202
+ }
203
+ return current;
204
+ }
205
+ function resolveImportRef(ref, imports, allowMissing = false) {
206
+ for (const [alias, imported] of Object.entries(imports)) {
207
+ const prefix = `${alias}.`;
208
+ if (!ref.startsWith(prefix))
209
+ continue;
210
+ const rest = ref.slice(prefix.length);
211
+ const match = /^(name|file|defaults|values)(?:\.(.+))?$/.exec(rest);
212
+ if (!match)
213
+ return { matched: false, value: undefined };
214
+ const section = match[1];
215
+ if (section === "name")
216
+ return { matched: true, value: imported.name };
217
+ if (section === "file")
218
+ return { matched: true, value: imported.file };
219
+ const value = section === "defaults" ? imported.defaults : imported.values;
220
+ const resolved = getPathValue(value, match[2]);
221
+ if (resolved === undefined && !allowMissing)
222
+ throw new Error(`Unknown recipe import reference: ${ref}`);
223
+ return { matched: true, value: resolved };
224
+ }
225
+ return { matched: false, value: undefined };
226
+ }
227
+ function isFalsyImportValue(value) {
228
+ return (value === undefined ||
229
+ value === null ||
230
+ value === false ||
231
+ value === 0 ||
232
+ value === "");
233
+ }
234
+ function parseImportLiteral(value, imports) {
235
+ const trimmed = value.trim();
236
+ const quoted = trimmed.match(/^(["'])(.*)\1$/);
237
+ if (quoted)
238
+ return quoted[2];
239
+ const resolved = resolveImportRef(trimmed, imports, true);
240
+ return resolved.matched ? resolved.value : trimmed;
241
+ }
242
+ function evaluateImportExpression(content, imports) {
243
+ const trimmed = content.trim();
244
+ const ternary = trimmed.match(/^(.+?)\?([^:]*):(.*)$/);
245
+ if (ternary) {
246
+ const condition = resolveImportRef(ternary[1].trim(), imports, true);
247
+ if (!condition.matched)
248
+ return { matched: false, value: undefined };
249
+ return {
250
+ matched: true,
251
+ value: parseImportLiteral(isFalsyImportValue(condition.value) ? ternary[3] : ternary[2], imports),
252
+ };
253
+ }
254
+ const fallback = trimmed.match(/^([^=]+)=(.*)$/);
255
+ if (fallback) {
256
+ const resolved = resolveImportRef(fallback[1].trim(), imports, true);
257
+ if (!resolved.matched)
258
+ return { matched: false, value: undefined };
259
+ return {
260
+ matched: true,
261
+ value: resolved.value === undefined || resolved.value === null
262
+ ? parseImportLiteral(fallback[2], imports)
263
+ : resolved.value,
264
+ };
265
+ }
266
+ return resolveImportRef(trimmed, imports);
267
+ }
268
+ function substituteImportRefs(value, imports) {
269
+ if (typeof value === "string") {
270
+ const exact = /^\{([^{}]+)\}$/.exec(value);
271
+ if (exact) {
272
+ const resolved = evaluateImportExpression(exact[1], imports);
273
+ if (resolved.matched)
274
+ return resolved.value;
275
+ }
276
+ return value.replaceAll(/\{([^{}]+)\}/g, (token, ref) => {
277
+ const resolved = evaluateImportExpression(String(ref), imports);
278
+ return resolved.matched ? String(resolved.value ?? "") : token;
279
+ });
280
+ }
281
+ if (Array.isArray(value))
282
+ return value.map((item) => substituteImportRefs(item, imports));
283
+ if (isRecord(value)) {
284
+ const result = {};
285
+ for (const [key, child] of Object.entries(value))
286
+ result[key] = substituteImportRefs(child, imports);
287
+ return result;
288
+ }
289
+ return value;
290
+ }
291
+ function mergeDefaults(...items) {
292
+ const merged = Object.assign({}, ...items.filter(Boolean));
293
+ return Object.keys(merged).length > 0 ? merged : undefined;
294
+ }
295
+ function applyDefaultsToTemplate(template, defaults, overrides) {
296
+ const cleanOverrides = { ...overrides };
297
+ delete cleanOverrides.name;
298
+ delete cleanOverrides.values;
299
+ if (typeof template === "object" && !Array.isArray(template)) {
300
+ return {
301
+ ...template,
302
+ ...cleanOverrides,
303
+ ...(mergeDefaults(template.defaults, defaults, isRecord(cleanOverrides.defaults) ? cleanOverrides.defaults : undefined)
304
+ ? {
305
+ defaults: mergeDefaults(template.defaults, defaults, isRecord(cleanOverrides.defaults)
306
+ ? cleanOverrides.defaults
307
+ : undefined),
308
+ }
309
+ : {}),
310
+ };
311
+ }
312
+ return {
313
+ ...cleanOverrides,
314
+ ...(defaults ? { defaults } : {}),
315
+ template,
316
+ };
317
+ }
318
+ function withActorRecipeContext(value, context) {
319
+ if (typeof value === "object" && !Array.isArray(value)) {
320
+ return { ...value, actorRecipeContext: context };
321
+ }
322
+ return { actorRecipeContext: context, template: value };
323
+ }
324
+ function expandImportNodes(value, imports, options = {}) {
325
+ if (typeof value === "string")
326
+ return value;
327
+ if (Array.isArray(value)) {
328
+ return value.map((item) => expandImportNodes(item, imports, options));
329
+ }
330
+ const record = value;
331
+ const importAlias = !Object.hasOwn(record, "template") && typeof record.name === "string"
332
+ ? record.name
333
+ : undefined;
334
+ if (importAlias) {
335
+ const imported = imports[importAlias];
336
+ if (!imported)
337
+ throw new Error(`Unknown recipe import: ${importAlias}`);
338
+ const nodeDefaults = isRecord(record.defaults)
339
+ ? record.defaults
340
+ : undefined;
341
+ const nodeValues = isRecord(record.values) ? record.values : undefined;
342
+ const defaults = mergeDefaults(imported.defaults, imported.values, nodeDefaults, nodeValues);
343
+ const expanded = applyDefaultsToTemplate(imported.config.template, defaults, record);
344
+ return options.includeActorRecipeContext
345
+ ? withActorRecipeContext(expanded, {
346
+ alias: imported.alias,
347
+ file: imported.file,
348
+ name: imported.name,
349
+ path: imported.alias,
350
+ role: "import",
351
+ })
352
+ : expanded;
353
+ }
354
+ if (Array.isArray(record.template)) {
355
+ return {
356
+ ...record,
357
+ template: record.template.map((item) => expandImportNodes(item, imports, options)),
358
+ };
359
+ }
360
+ if (record.template && typeof record.template === "object") {
361
+ return {
362
+ ...record,
363
+ template: expandImportNodes(record.template, imports, options),
364
+ };
365
+ }
366
+ return value;
367
+ }
368
+ export function readResolvedRecipeConfig(file, stack = [], options = {}) {
369
+ const path = resolveRecipePath(file, stack.length > 0 ? dirname(stack.at(-1)) : Paths.getRecipeRoot());
370
+ if (stack.includes(path)) {
371
+ throw new Error(`Cyclic recipe import: ${[...stack, path].join(" -> ")}`);
372
+ }
373
+ if (stack.length >= MAX_RECIPE_IMPORT_DEPTH) {
374
+ throw new Error(`Recipe import depth exceeds limit ${MAX_RECIPE_IMPORT_DEPTH}: ${[...stack, path].join(" -> ")}`);
375
+ }
376
+ const raw = readRawRecipeConfig(path);
377
+ if (!raw || !Object.hasOwn(raw, "template"))
378
+ return undefined;
379
+ const imports = {};
380
+ for (const [alias, binding] of Object.entries(getRecipeImports(raw))) {
381
+ const importPath = resolveRecipeImportPath(getImportFrom(binding), dirname(path));
382
+ const config = readResolvedRecipeConfig(importPath, [...stack, path], options);
383
+ if (!config)
384
+ throw new Error(`Recipe import not found: ${alias}`);
385
+ const bindingDefaults = typeof binding === "string" ? undefined : binding.defaults;
386
+ const bindingValues = typeof binding === "string" ? undefined : binding.values;
387
+ imports[alias] = {
388
+ alias,
389
+ file: importPath,
390
+ name: config.name ?? alias,
391
+ config,
392
+ defaults: { ...(config.defaults ?? {}), ...(bindingDefaults ?? {}) },
393
+ values: { ...(bindingValues ?? {}) },
394
+ };
395
+ }
396
+ const substituted = substituteImportRefs(raw, imports);
397
+ const template = getRecipeCommandTemplate(substituted);
398
+ if (!template)
399
+ return undefined;
400
+ const expandedTemplate = expandImportNodes(template, imports, options);
401
+ const recipeName = getRecipeIdFromPath(path);
402
+ const templateWithContext = options.includeActorRecipeContext
403
+ ? withActorRecipeContext(expandedTemplate, {
404
+ file: path,
405
+ name: recipeName,
406
+ path: recipeName,
407
+ role: stack.length > 0 ? "import" : "entry",
408
+ })
409
+ : expandedTemplate;
410
+ return {
411
+ name: recipeName,
412
+ ...(typeof substituted.description === "string" &&
413
+ substituted.description.trim()
414
+ ? { description: substituted.description.trim() }
415
+ : {}),
416
+ ...(typeof substituted.disabled === "boolean"
417
+ ? { disabled: substituted.disabled }
418
+ : {}),
419
+ ...(substituted.async === true
420
+ ? { async: true }
421
+ : substituted.async === false
422
+ ? { async: false }
423
+ : {}),
424
+ ...(typeof substituted.state_dir === "string"
425
+ ? { state_dir: substituted.state_dir }
426
+ : {}),
427
+ ...(Object.keys(imports).length > 0
428
+ ? { imports: getRecipeImports(raw) }
429
+ : {}),
430
+ template: templateWithContext,
431
+ ...(Array.isArray(substituted.args)
432
+ ? { args: substituted.args }
433
+ : {}),
434
+ ...(isRecord(substituted.defaults)
435
+ ? { defaults: substituted.defaults }
436
+ : {}),
437
+ ...(typeof substituted.parallel === "boolean"
438
+ ? { parallel: substituted.parallel }
439
+ : {}),
440
+ ...(typeof substituted.label === "string"
441
+ ? { label: substituted.label }
442
+ : {}),
443
+ ...(typeof substituted.when === "string" ||
444
+ typeof substituted.when === "boolean"
445
+ ? { when: substituted.when }
446
+ : {}),
447
+ ...(typeof substituted.timeout === "number" ||
448
+ typeof substituted.timeout === "string"
449
+ ? { timeout: substituted.timeout }
450
+ : {}),
451
+ ...(typeof substituted.delay === "number" ||
452
+ typeof substituted.delay === "string"
453
+ ? { delay: substituted.delay }
454
+ : {}),
455
+ ...(typeof substituted.output === "string"
456
+ ? { output: substituted.output }
457
+ : {}),
458
+ ...(isRecord(substituted.artifacts)
459
+ ? {
460
+ artifacts: Object.fromEntries(Object.entries(substituted.artifacts).filter((entry) => typeof entry[1] === "string")),
461
+ }
462
+ : {}),
463
+ ...(isRecord(substituted.mailbox)
464
+ ? {
465
+ mailbox: {
466
+ ...(Array.isArray(substituted.mailbox.accepts)
467
+ ? {
468
+ accepts: substituted.mailbox.accepts.filter((value) => typeof value === "string"),
469
+ }
470
+ : {}),
471
+ ...(Array.isArray(substituted.mailbox.emits)
472
+ ? {
473
+ emits: substituted.mailbox.emits.filter((value) => typeof value === "string"),
474
+ }
475
+ : {}),
476
+ },
477
+ }
478
+ : {}),
479
+ ...(substituted.retire_when === "children_terminal"
480
+ ? { retire_when: "children_terminal" }
481
+ : {}),
482
+ ...(typeof substituted.retry === "number" ||
483
+ typeof substituted.retry === "string"
484
+ ? { retry: substituted.retry }
485
+ : {}),
486
+ ...(substituted.failure === "continue" ||
487
+ substituted.failure === "branch" ||
488
+ substituted.failure === "root"
489
+ ? { failure: substituted.failure }
490
+ : {}),
491
+ ...(substituted.recover !== undefined
492
+ ? { recover: substituted.recover }
493
+ : {}),
494
+ ...(typeof substituted.repeat === "number"
495
+ ? { repeat: substituted.repeat }
496
+ : {}),
497
+ ...(isRecord(substituted.values) ? { values: substituted.values } : {}),
498
+ ...(isRecord(substituted.usage) ? { usage: substituted.usage } : {}),
499
+ };
500
+ }
501
+ function collectRecipeContextRecords(file, stack, importPath, alias) {
502
+ const path = resolveRecipePath(file, stack.length > 0 ? dirname(stack.at(-1)) : Paths.getRecipeRoot());
503
+ if (stack.includes(path)) {
504
+ throw new Error(`Cyclic recipe import: ${[...stack, path].join(" -> ")}`);
505
+ }
506
+ if (stack.length >= MAX_RECIPE_IMPORT_DEPTH) {
507
+ throw new Error(`Recipe import depth exceeds limit ${MAX_RECIPE_IMPORT_DEPTH}: ${[...stack, path].join(" -> ")}`);
508
+ }
509
+ const raw = readRawRecipeConfig(path);
510
+ if (!raw || !Object.hasOwn(raw, "template"))
511
+ return [];
512
+ const record = {
513
+ ...(alias ? { alias } : {}),
514
+ depth: stack.length,
515
+ file: path,
516
+ import_path: importPath,
517
+ name: getRecipeIdFromPath(path),
518
+ recipe: raw,
519
+ role: stack.length === 0 ? "entry" : "import",
520
+ };
521
+ const imports = getRecipeImports(raw);
522
+ const children = Object.entries(imports).flatMap(([childAlias, binding]) => {
523
+ const importFile = resolveRecipeImportPath(getImportFrom(binding), dirname(path));
524
+ return collectRecipeContextRecords(importFile, [...stack, path], [...importPath, childAlias], childAlias);
525
+ });
526
+ return [record, ...children];
527
+ }
528
+ export function buildRecipeContextRecords(file) {
529
+ return collectRecipeContextRecords(file, [], []);
530
+ }
531
+ export function getRecipeTemplate(value) {
532
+ return readRecipeConfig(value)?.template;
533
+ }
534
+ export function isRecipeReference(value) {
535
+ return getRecipePath(value) !== undefined;
536
+ }
537
+ export function isAsyncRecipeReference(value) {
538
+ return readRecipeConfig(value)?.async === true;
539
+ }
540
+ export function isRecipeTool(template, recipe) {
541
+ return recipe !== undefined || isRecipeReference(template);
542
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Recipe usage metadata helpers
3
+ * Zones: recipe telemetry, muscle-memory cleanup evidence
4
+ * Owns lightweight launch counters for user-owned recipe files
5
+ */
6
+ export declare function recordRecipeLaunch(path: string, now?: Date): boolean;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Recipe usage metadata helpers
3
+ * Zones: recipe telemetry, muscle-memory cleanup evidence
4
+ * Owns lightweight launch counters for user-owned recipe files
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { writeJsonAtomic } from "./file-state.js";
9
+ function isRecord(value) {
10
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
11
+ }
12
+ function normalizeCalls(value) {
13
+ return typeof value === "number" && Number.isFinite(value) && value > 0
14
+ ? Math.floor(value)
15
+ : 0;
16
+ }
17
+ function stableStringify(value) {
18
+ if (Array.isArray(value))
19
+ return `[${value.map(stableStringify).join(",")}]`;
20
+ if (!isRecord(value))
21
+ return JSON.stringify(value);
22
+ return `{${Object.keys(value)
23
+ .sort()
24
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
25
+ .join(",")}}`;
26
+ }
27
+ function getRecipeFingerprint(raw) {
28
+ const { usage: _usage, ...content } = raw;
29
+ return createHash("sha256").update(stableStringify(content)).digest("hex");
30
+ }
31
+ export function recordRecipeLaunch(path, now = new Date()) {
32
+ if (!existsSync(path))
33
+ return false;
34
+ try {
35
+ const raw = JSON.parse(readFileSync(path, "utf8"));
36
+ if (!isRecord(raw))
37
+ return false;
38
+ const usage = isRecord(raw.usage) ? raw.usage : {};
39
+ const fingerprint = getRecipeFingerprint(raw);
40
+ const changed = typeof usage.fingerprint === "string" && usage.fingerprint !== fingerprint;
41
+ const nowIso = now.toISOString();
42
+ writeJsonAtomic(path, {
43
+ ...raw,
44
+ usage: {
45
+ ...usage,
46
+ calls: (changed ? 0 : normalizeCalls(usage.calls)) + 1,
47
+ last_called: nowIso,
48
+ fingerprint,
49
+ ...(changed ? { reset_at: nowIso } : {}),
50
+ },
51
+ });
52
+ return true;
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Registry mutation use-cases
3
+ * Zones: registry mutations, persistence, runtime activation
4
+ * Owns register/update/delete validation, persistence, runtime side effects, and result payloads
5
+ */
6
+ import * as Config from "./config.ts";
7
+ import * as CommandTemplates from "./command-templates.ts";
8
+ export interface RegisterToolInput {
9
+ name?: string;
10
+ description?: string;
11
+ async?: boolean;
12
+ state_dir?: string;
13
+ template?: CommandTemplates.CommandTemplateValue | null;
14
+ args?: string;
15
+ update?: boolean;
16
+ values?: Record<string, unknown>;
17
+ }
18
+ export interface RegisterToolResultDetails {
19
+ args?: string[];
20
+ async?: boolean;
21
+ config?: string;
22
+ defaults?: Record<string, string>;
23
+ recipeName?: string;
24
+ state_dir?: string;
25
+ template?: CommandTemplates.CommandTemplateValue;
26
+ templateWarnings?: string[];
27
+ tool: string;
28
+ }
29
+ export interface RegisterToolResult {
30
+ content: Array<{
31
+ type: "text";
32
+ text: string;
33
+ }>;
34
+ details: RegisterToolResultDetails;
35
+ }
36
+ export interface RegisterToolRuntimeDeps<TContext> {
37
+ configPath: string;
38
+ recipeRoot?: string;
39
+ getExternalToolConflict: (name: string) => string | undefined;
40
+ getTools: () => Map<string, Config.RegisteredTool>;
41
+ getActiveTools: () => string[];
42
+ notify: (ctx: TContext, message: string, type: "info" | "warning" | "error") => void;
43
+ registerRuntimeTool: (cfg: Config.RegisteredTool) => void;
44
+ reservedToolNames: Set<string>;
45
+ setActiveTools: (toolNames: string[]) => void;
46
+ }
47
+ export declare function executeRegisterTool<TContext>(params: unknown, ctx: TContext, deps: RegisterToolRuntimeDeps<TContext>): Promise<RegisterToolResult>;