@llblab/pi-actors 0.22.2 → 0.22.4

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.
@@ -8,9 +8,9 @@ import { createHash } from "node:crypto";
8
8
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
 
11
+ import * as CommandTemplates from "./command-templates.ts";
11
12
  import type { RegisteredTool } from "./config.ts";
12
13
  import type { TemplateRecipeConfig } from "./recipe-references.ts";
13
- import * as CommandTemplates from "./command-templates.ts";
14
14
  import * as RecipeReferences from "./recipe-references.ts";
15
15
  import * as Schema from "./schema.ts";
16
16
 
@@ -61,18 +61,28 @@ function assertToolSafeRepeatConfig(
61
61
  argTypes: Record<string, { kind: string }>,
62
62
  defaults: Record<string, unknown>,
63
63
  ): void {
64
- if (typeof config === "string" || config === undefined || config === null) return;
64
+ if (typeof config === "string" || config === undefined || config === null)
65
+ return;
65
66
  if (Array.isArray(config)) {
66
- for (const step of config) assertToolSafeRepeatConfig(step, argTypes, defaults);
67
+ for (const step of config)
68
+ assertToolSafeRepeatConfig(step, argTypes, defaults);
67
69
  return;
68
70
  }
69
71
  if (typeof config !== "object") return;
70
- const node = config as { repeat?: unknown; template?: unknown; recover?: unknown };
72
+ const node = config as {
73
+ repeat?: unknown;
74
+ template?: unknown;
75
+ recover?: unknown;
76
+ };
71
77
  if (typeof node.repeat === "string") {
72
78
  const trimmed = node.repeat.trim();
73
79
  if (!/^\d+$/.test(trimmed)) {
74
80
  const match = trimmed.match(/^\{?([A-Za-z_][A-Za-z0-9_-]*)\.length\}?$/);
75
- if (!match || (argTypes[match[1]]?.kind !== "array" && !Array.isArray(defaults[match[1]])))
81
+ if (
82
+ !match ||
83
+ (argTypes[match[1]]?.kind !== "array" &&
84
+ !Array.isArray(defaults[match[1]]))
85
+ )
76
86
  throw new Error(
77
87
  "Command template repeat must be a positive integer or {array.length} with an array argument/default.",
78
88
  );
@@ -92,23 +102,30 @@ function listRecipeFiles(root: string): string[] {
92
102
  entry.name !== "legacy-tool-registry-migration-report.json",
93
103
  )
94
104
  .map((entry) => join(root, entry.name))
95
- .sort((a, b) => a.replace(/\.md$/, ".json").localeCompare(b.replace(/\.md$/, ".json")) || (a.endsWith(".json") ? -1 : 1));
105
+ .sort(
106
+ (a, b) =>
107
+ a
108
+ .replace(/\.md$/, ".json")
109
+ .localeCompare(b.replace(/\.md$/, ".json")) ||
110
+ (a.endsWith(".json") ? -1 : 1),
111
+ );
96
112
  }
97
113
 
98
114
  function getRecipeConfigDiagnostics(
99
115
  file: string,
100
116
  config: TemplateRecipeConfig | undefined,
101
117
  ): string[] {
102
- if (!config) return [`Invalid recipe: ${file}`];
118
+ if (!config) {
119
+ const reason = RecipeReferences.diagnoseRawRecipeConfigFailure(file);
120
+ return [`Invalid recipe: ${file}${reason ? `: ${reason}` : ""}`];
121
+ }
103
122
  const commandTemplateConfig =
104
123
  typeof config.template === "object" && config.template !== null
105
124
  ? config.template
106
125
  : config;
107
126
  return CommandTemplates.getCommandTemplateWarnings(
108
127
  commandTemplateConfig as CommandTemplates.CommandTemplateConfig,
109
- ).map(
110
- (warning) => `Recipe ${file}: ${warning}`,
111
- );
128
+ ).map((warning) => `Recipe ${file}: ${warning}`);
112
129
  }
113
130
 
114
131
  function readDiscoveredRecipe(
@@ -158,13 +175,23 @@ function readDiscoveredRecipe(
158
175
  }
159
176
  }
160
177
 
161
- function filesForSource(
162
- source: RecipeDiscoverySource,
163
- ): Array<{ root: string; file: string; defaultTool: boolean; mutableUsage: boolean }> {
178
+ function filesForSource(source: RecipeDiscoverySource): Array<{
179
+ root: string;
180
+ file: string;
181
+ defaultTool: boolean;
182
+ mutableUsage: boolean;
183
+ }> {
164
184
  const defaultTool = source.defaultTool === true;
165
185
  const mutableUsage = source.mutableUsage === true;
166
186
  if (source.file)
167
- return [{ root: source.root ?? source.file, file: source.file, defaultTool, mutableUsage }];
187
+ return [
188
+ {
189
+ root: source.root ?? source.file,
190
+ file: source.file,
191
+ defaultTool,
192
+ mutableUsage,
193
+ },
194
+ ];
168
195
  return source.root
169
196
  ? listRecipeFiles(source.root).map((file) => ({
170
197
  root: source.root!,
@@ -226,7 +253,9 @@ export function discoverRecipeSources(
226
253
  bucket.sort(
227
254
  (a, b) =>
228
255
  a.priority - b.priority ||
229
- a.path.replace(/\.md$/, ".json").localeCompare(b.path.replace(/\.md$/, ".json")) ||
256
+ a.path
257
+ .replace(/\.md$/, ".json")
258
+ .localeCompare(b.path.replace(/\.md$/, ".json")) ||
230
259
  (a.path.endsWith(".json") ? -1 : 1),
231
260
  );
232
261
  const winner = bucket[0];
@@ -262,14 +291,18 @@ export function discoverRecipes(roots: string[]): RecipeDiscoveryResult {
262
291
  return discoverRecipeSources(roots.map((root) => ({ root })));
263
292
  }
264
293
 
265
- function recipeUsage(config: TemplateRecipeConfig | undefined): Record<string, unknown> | undefined {
294
+ function recipeUsage(
295
+ config: TemplateRecipeConfig | undefined,
296
+ ): Record<string, unknown> | undefined {
266
297
  const usage = (config as { usage?: unknown } | undefined)?.usage;
267
298
  return usage && typeof usage === "object" && !Array.isArray(usage)
268
299
  ? (usage as Record<string, unknown>)
269
300
  : undefined;
270
301
  }
271
302
 
272
- function cleanupRecommendation(entry: DiscoveredRecipe): Record<string, unknown> | undefined {
303
+ function cleanupRecommendation(
304
+ entry: DiscoveredRecipe,
305
+ ): Record<string, unknown> | undefined {
273
306
  if (entry.invalid) {
274
307
  return {
275
308
  id: entry.id,
@@ -309,7 +342,13 @@ function cleanupRecommendation(entry: DiscoveredRecipe): Record<string, unknown>
309
342
  id: entry.id,
310
343
  path: entry.path,
311
344
  reason: "user recipe is a component, not an active tool",
312
- actions: ["keep component", "move into tool root", "merge", "delete", "archive"],
345
+ actions: [
346
+ "keep component",
347
+ "move into tool root",
348
+ "merge",
349
+ "delete",
350
+ "archive",
351
+ ],
313
352
  };
314
353
  }
315
354
  if (entry.shadows.length > 0) {
@@ -342,9 +381,78 @@ export function createRecipeIntegrityManifest(
342
381
  tool: entry.tool,
343
382
  };
344
383
  })
345
- .sort(
346
- (a, b) => a.id.localeCompare(b.id) || a.path.localeCompare(b.path),
384
+ .sort((a, b) => a.id.localeCompare(b.id) || a.path.localeCompare(b.path));
385
+ }
386
+
387
+ function diagnosticSeverity(message: string): "info" | "warning" | "error" {
388
+ if (
389
+ /invalid|failed to load|not found|cyclic|exceeds|must define|repeat must/i.test(
390
+ message,
391
+ )
392
+ ) {
393
+ return "error";
394
+ }
395
+ if (
396
+ /world-writable|group-writable|invokes bash|eval|destructive|unsafe/i.test(
397
+ message,
398
+ )
399
+ ) {
400
+ return "warning";
401
+ }
402
+ return "info";
403
+ }
404
+
405
+ function diagnosticSuggestedAction(message: string): string {
406
+ if (/must define template/i.test(message))
407
+ return "add a template field or remove the recipe";
408
+ if (/JSON|Expected|parse/i.test(message))
409
+ return "fix recipe syntax or archive the file";
410
+ if (/Markdown recipe/i.test(message))
411
+ return "fix frontmatter and add a fenced template or recipe block";
412
+ if (/cyclic/i.test(message)) return "break the import cycle";
413
+ if (/exceeds.*size/i.test(message))
414
+ return "split large prompt or data into separate files";
415
+ if (/repeat must/i.test(message))
416
+ return "use a positive repeat count or an array-typed repeat source";
417
+ if (/world-writable|group-writable/i.test(message))
418
+ return "tighten recipe root permissions";
419
+ if (/invokes bash/i.test(message))
420
+ return "audit the trusted shell boundary or move details to recipe doctor";
421
+ if (/shadows/i.test(message))
422
+ return "confirm the active override or rename one recipe";
423
+ if (/disabled/i.test(message))
424
+ return "keep disabled intentionally or delete/archive the file";
425
+ return "inspect the recipe and fix or archive it if unexpected";
426
+ }
427
+
428
+ function diagnosticDetails(
429
+ result: RecipeDiscoveryResult,
430
+ ): Array<Record<string, unknown>> {
431
+ const details: Array<Record<string, unknown>> = [];
432
+ const seen = new Set<string>();
433
+ const push = (message: string, entry?: DiscoveredRecipe): void => {
434
+ const key = `${entry?.path ?? "root"}\n${message}`;
435
+ if (seen.has(key)) return;
436
+ seen.add(key);
437
+ details.push({
438
+ ...(entry ? { id: entry.id, path: entry.path } : {}),
439
+ action: diagnosticSuggestedAction(message),
440
+ message,
441
+ severity: diagnosticSeverity(message),
442
+ });
443
+ };
444
+ for (const message of result.diagnostics) push(message);
445
+ for (const entry of result.entries) {
446
+ for (const message of entry.diagnostics) push(message, entry);
447
+ }
448
+ return details.sort((a, b) => {
449
+ const rank = { error: 0, warning: 1, info: 2 } as const;
450
+ return (
451
+ rank[a.severity as keyof typeof rank] -
452
+ rank[b.severity as keyof typeof rank] ||
453
+ String(a.message).localeCompare(String(b.message))
347
454
  );
455
+ });
348
456
  }
349
457
 
350
458
  function recommendationForEntry(
@@ -362,29 +470,49 @@ function recommendationForEntry(
362
470
  return recommendation;
363
471
  }
364
472
 
365
- export function summarizeDiscovery(result: RecipeDiscoveryResult): Record<string, unknown> {
473
+ export function summarizeDiscovery(
474
+ result: RecipeDiscoveryResult,
475
+ ): Record<string, unknown> {
366
476
  const recommendations = result.entries
367
- .map((entry) => recommendationForEntry(entry, result.active.get(entry.id)?.path))
477
+ .map((entry) =>
478
+ recommendationForEntry(entry, result.active.get(entry.id)?.path),
479
+ )
368
480
  .filter((entry): entry is Record<string, unknown> => Boolean(entry))
369
- .sort((a, b) => String(a.id).localeCompare(String(b.id)) || String(a.path).localeCompare(String(b.path)));
481
+ .sort(
482
+ (a, b) =>
483
+ String(a.id).localeCompare(String(b.id)) ||
484
+ String(a.path).localeCompare(String(b.path)),
485
+ );
370
486
  return {
371
- active: [...result.active.values()].map((entry) => ({
372
- id: entry.id,
373
- path: entry.path,
374
- description: entry.config?.description,
375
- tool: entry.tool,
376
- disabled: entry.disabled,
377
- invalid: entry.invalid,
378
- shadows: entry.shadows,
379
- ...(recipeUsage(entry.config) ? { usage: recipeUsage(entry.config) } : {}),
380
- })).sort((a, b) => a.id.localeCompare(b.id)),
487
+ active: [...result.active.values()]
488
+ .map((entry) => ({
489
+ id: entry.id,
490
+ path: entry.path,
491
+ description: entry.config?.description,
492
+ tool: entry.tool,
493
+ disabled: entry.disabled,
494
+ invalid: entry.invalid,
495
+ shadows: entry.shadows,
496
+ ...(recipeUsage(entry.config)
497
+ ? { usage: recipeUsage(entry.config) }
498
+ : {}),
499
+ }))
500
+ .sort((a, b) => a.id.localeCompare(b.id)),
381
501
  shadowed: result.entries
382
502
  .filter((entry) => entry.shadowed)
383
- .map((entry) => ({ id: entry.id, path: entry.path, shadowedBy: result.active.get(entry.id)?.path }))
503
+ .map((entry) => ({
504
+ id: entry.id,
505
+ path: entry.path,
506
+ shadowedBy: result.active.get(entry.id)?.path,
507
+ }))
384
508
  .sort((a, b) => a.id.localeCompare(b.id) || a.path.localeCompare(b.path)),
385
509
  invalid: result.entries
386
510
  .filter((entry) => entry.invalid)
387
- .map((entry) => ({ id: entry.id, path: entry.path, diagnostics: entry.diagnostics }))
511
+ .map((entry) => ({
512
+ id: entry.id,
513
+ path: entry.path,
514
+ diagnostics: entry.diagnostics,
515
+ }))
388
516
  .sort((a, b) => a.id.localeCompare(b.id)),
389
517
  disabled: result.entries
390
518
  .filter((entry) => entry.disabled)
@@ -392,12 +520,16 @@ export function summarizeDiscovery(result: RecipeDiscoveryResult): Record<string
392
520
  .sort((a, b) => a.id.localeCompare(b.id)),
393
521
  recommendations,
394
522
  diagnostics: result.diagnostics,
523
+ diagnostic_details: diagnosticDetails(result),
395
524
  integrity_manifest: createRecipeIntegrityManifest(result),
396
525
  };
397
526
  }
398
527
 
399
- export function toRegisteredTool(entry: DiscoveredRecipe): RegisteredTool | undefined {
400
- if (!entry.tool || entry.invalid || entry.disabled || !entry.config) return undefined;
528
+ export function toRegisteredTool(
529
+ entry: DiscoveredRecipe,
530
+ ): RegisteredTool | undefined {
531
+ if (!entry.tool || entry.invalid || entry.disabled || !entry.config)
532
+ return undefined;
401
533
  const cfg = entry.config;
402
534
  const template = entry.path;
403
535
  const description = cfg.description ?? `Execute template recipe: ${entry.id}`;
@@ -407,7 +539,10 @@ export function toRegisteredTool(entry: DiscoveredRecipe): RegisteredTool | unde
407
539
  ? {
408
540
  ...argTemplate,
409
541
  ...(cfg.args !== undefined ? { args: cfg.args } : {}),
410
- defaults: { ...(argTemplate.defaults ?? {}), ...(cfg.defaults ?? {}) },
542
+ defaults: {
543
+ ...(argTemplate.defaults ?? {}),
544
+ ...(cfg.defaults ?? {}),
545
+ },
411
546
  }
412
547
  : { args: cfg.args, defaults: cfg.defaults ?? {}, template: argTemplate };
413
548
  const explicitArgTypes = Object.fromEntries(
@@ -416,7 +551,11 @@ export function toRegisteredTool(entry: DiscoveredRecipe): RegisteredTool | unde
416
551
  return [parsed.arg, parsed.type];
417
552
  }),
418
553
  );
419
- assertToolSafeRepeatConfig(argTemplateConfig, explicitArgTypes, cfg.defaults ?? {});
554
+ assertToolSafeRepeatConfig(
555
+ argTemplateConfig,
556
+ explicitArgTypes,
557
+ cfg.defaults ?? {},
558
+ );
420
559
  const argTypes = Schema.getTemplateArgTypes(argTemplateConfig);
421
560
  return {
422
561
  name: entry.id,
@@ -425,7 +564,10 @@ export function toRegisteredTool(entry: DiscoveredRecipe): RegisteredTool | unde
425
564
  recipe: cfg,
426
565
  args: Schema.getToolArgNames(argTemplateConfig),
427
566
  defaults: Object.fromEntries(
428
- Object.entries(cfg.defaults ?? {}).map(([key, value]) => [key, String(value)]),
567
+ Object.entries(cfg.defaults ?? {}).map(([key, value]) => [
568
+ key,
569
+ String(value),
570
+ ]),
429
571
  ),
430
572
  ...(Object.keys(argTypes).length > 0 ? { argTypes } : {}),
431
573
  ...(entry.mutableUsage ? { sourcePath: entry.path } : {}),
@@ -433,7 +575,10 @@ export function toRegisteredTool(entry: DiscoveredRecipe): RegisteredTool | unde
433
575
  ...(cfg.defaults
434
576
  ? {
435
577
  storedDefaults: Object.fromEntries(
436
- Object.entries(cfg.defaults).map(([key, value]) => [key, String(value)]),
578
+ Object.entries(cfg.defaults).map(([key, value]) => [
579
+ key,
580
+ String(value),
581
+ ]),
437
582
  ),
438
583
  }
439
584
  : {}),
@@ -109,7 +109,12 @@ export function resolveRecipePath(
109
109
 
110
110
  function isBareRecipeName(value: string): boolean {
111
111
  const trimmed = value.trim();
112
- return Boolean(trimmed) && !trimmed.includes("/") && !trimmed.startsWith("~") && !trimmed.includes("{");
112
+ return (
113
+ Boolean(trimmed) &&
114
+ !trimmed.includes("/") &&
115
+ !trimmed.startsWith("~") &&
116
+ !trimmed.includes("{")
117
+ );
113
118
  }
114
119
 
115
120
  function recipeNameFiles(value: string): string[] {
@@ -118,8 +123,12 @@ function recipeNameFiles(value: string): string[] {
118
123
  return [`${trimmed}.json`, `${trimmed}.md`];
119
124
  }
120
125
 
121
- function resolveRecipeImportPath(value: string, currentRecipeRoot: string): string {
122
- if (!isBareRecipeName(value)) return resolveRecipePath(value, currentRecipeRoot);
126
+ function resolveRecipeImportPath(
127
+ value: string,
128
+ currentRecipeRoot: string,
129
+ ): string {
130
+ if (!isBareRecipeName(value))
131
+ return resolveRecipePath(value, currentRecipeRoot);
123
132
  const roots = [
124
133
  Paths.getRecipeRoot(),
125
134
  currentRecipeRoot,
@@ -266,11 +275,15 @@ function parseMarkdownFrontmatterObject(
266
275
  lines: string[],
267
276
  ): Record<string, unknown> | unknown[] {
268
277
  if (lines.every((line) => /^\s*-\s+/.test(line))) {
269
- return lines.map((line) => parseMarkdownScalar(line.replace(/^\s*-\s+/, "")));
278
+ return lines.map((line) =>
279
+ parseMarkdownScalar(line.replace(/^\s*-\s+/, "")),
280
+ );
270
281
  }
271
282
  const result: Record<string, unknown> = {};
272
283
  for (let index = 0; index < lines.length; index += 1) {
273
- const match = lines[index].match(/^\s{2}([A-Za-z_][A-Za-z0-9_.-]*):\s*(.*)$/);
284
+ const match = lines[index].match(
285
+ /^\s{2}([A-Za-z_][A-Za-z0-9_.-]*):\s*(.*)$/,
286
+ );
274
287
  if (!match) continue;
275
288
  if (match[2]) {
276
289
  result[match[1]] = parseMarkdownScalar(match[2]);
@@ -331,11 +344,14 @@ function parseMarkdownRecipeConfig(
331
344
  ): Record<string, unknown> | undefined {
332
345
  const lines = content.split(/\r?\n/);
333
346
  if (lines[0]?.trim() !== "---") return undefined;
334
- const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
347
+ const end = lines.findIndex(
348
+ (line, index) => index > 0 && line.trim() === "---",
349
+ );
335
350
  if (end === -1) return undefined;
336
351
  const frontmatter = parseMarkdownFrontmatter(lines.slice(1, end).join("\n"));
337
352
  const fence = findMarkdownRecipeFence(lines.slice(end + 1).join("\n"));
338
- if (!fence) return Object.hasOwn(frontmatter, "template") ? frontmatter : undefined;
353
+ if (!fence)
354
+ return Object.hasOwn(frontmatter, "template") ? frontmatter : undefined;
339
355
  const text = fence.body.trim();
340
356
  if (!text) return undefined;
341
357
  if (
@@ -351,12 +367,50 @@ function parseMarkdownRecipeConfig(
351
367
  return { ...frontmatter, ...parsed };
352
368
  return { ...frontmatter, template: parsed };
353
369
  } catch {
354
- if (fence.info.includes("json") || fence.info.includes("recipe")) return undefined;
370
+ if (fence.info.includes("json") || fence.info.includes("recipe"))
371
+ return undefined;
355
372
  }
356
373
  }
357
374
  return { ...frontmatter, template: text };
358
375
  }
359
376
 
377
+ export function diagnoseRawRecipeConfigFailure(
378
+ path: string,
379
+ ): string | undefined {
380
+ if (!existsSync(path)) return "file does not exist";
381
+ const size = statSync(path).size;
382
+ if (size > MAX_RECIPE_FILE_BYTES) {
383
+ return `file exceeds size limit ${MAX_RECIPE_FILE_BYTES} bytes`;
384
+ }
385
+ try {
386
+ const content = readFileSync(path, "utf8");
387
+ if (path.endsWith(".md")) {
388
+ const lines = content.split(/\r?\n/);
389
+ if (lines[0]?.trim() !== "---") {
390
+ return "Markdown recipe must start with frontmatter";
391
+ }
392
+ const end = lines.findIndex(
393
+ (line, index) => index > 0 && line.trim() === "---",
394
+ );
395
+ if (end === -1) return "Markdown recipe frontmatter is not closed";
396
+ const parsed = parseMarkdownRecipeConfig(content);
397
+ if (!parsed)
398
+ return "Markdown recipe has no executable recipe/template fence";
399
+ if (!Object.hasOwn(parsed, "template"))
400
+ return "recipe must define template";
401
+ return undefined;
402
+ }
403
+ const raw = JSON.parse(content) as unknown;
404
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
405
+ return "JSON recipe must be an object";
406
+ }
407
+ if (!Object.hasOwn(raw, "template")) return "recipe must define template";
408
+ return undefined;
409
+ } catch (error) {
410
+ return error instanceof Error ? error.message : String(error);
411
+ }
412
+ }
413
+
360
414
  export function readRawRecipeConfig(
361
415
  path: string,
362
416
  ): Record<string, unknown> | undefined {
@@ -593,7 +647,8 @@ function expandImportNodes(
593
647
  if (typeof value === "string") return value;
594
648
  if (Array.isArray(value)) {
595
649
  return value.map(
596
- (item) => expandImportNodes(item, imports, options) as CommandTemplateConfig,
650
+ (item) =>
651
+ expandImportNodes(item, imports, options) as CommandTemplateConfig,
597
652
  );
598
653
  }
599
654
  const record = value as Record<string, unknown>;
@@ -614,7 +669,11 @@ function expandImportNodes(
614
669
  nodeDefaults,
615
670
  nodeValues,
616
671
  );
617
- const expanded = applyDefaultsToTemplate(imported.config.template, defaults, record);
672
+ const expanded = applyDefaultsToTemplate(
673
+ imported.config.template,
674
+ defaults,
675
+ record,
676
+ );
618
677
  return options.includeActorRecipeContext
619
678
  ? withActorRecipeContext(expanded, {
620
679
  alias: imported.alias,
@@ -672,8 +731,15 @@ export function readResolvedRecipeConfig(
672
731
  if (!raw || !Object.hasOwn(raw, "template")) return undefined;
673
732
  const imports: Record<string, ImportedRecipe> = {};
674
733
  for (const [alias, binding] of Object.entries(getRecipeImports(raw))) {
675
- const importPath = resolveRecipeImportPath(getImportFrom(binding), dirname(path));
676
- const config = readResolvedRecipeConfig(importPath, [...stack, path], options);
734
+ const importPath = resolveRecipeImportPath(
735
+ getImportFrom(binding),
736
+ dirname(path),
737
+ );
738
+ const config = readResolvedRecipeConfig(
739
+ importPath,
740
+ [...stack, path],
741
+ options,
742
+ );
677
743
  if (!config) throw new Error(`Recipe import not found: ${alias}`);
678
744
  const bindingDefaults =
679
745
  typeof binding === "string" ? undefined : binding.defaults;
@@ -836,8 +902,16 @@ function collectRecipeContextRecords(
836
902
  };
837
903
  const imports = getRecipeImports(raw);
838
904
  const children = Object.entries(imports).flatMap(([childAlias, binding]) => {
839
- const importFile = resolveRecipeImportPath(getImportFrom(binding), dirname(path));
840
- return collectRecipeContextRecords(importFile, [...stack, path], [...importPath, childAlias], childAlias);
905
+ const importFile = resolveRecipeImportPath(
906
+ getImportFrom(binding),
907
+ dirname(path),
908
+ );
909
+ return collectRecipeContextRecords(
910
+ importFile,
911
+ [...stack, path],
912
+ [...importPath, childAlias],
913
+ childAlias,
914
+ );
841
915
  });
842
916
  return [record, ...children];
843
917
  }
package/lib/registry.ts CHANGED
@@ -161,8 +161,14 @@ function getInputTemplate(
161
161
  );
162
162
  return value;
163
163
  }
164
+ if (value && typeof value === "object") {
165
+ CommandTemplates.expandCommandTemplateConfigs(
166
+ value as CommandTemplates.CommandTemplateConfig,
167
+ );
168
+ return value as CommandTemplates.CommandTemplateConfig;
169
+ }
164
170
  throw new Error(
165
- Output.formatToolText("Tool template must be a string or sequence."),
171
+ Output.formatToolText("Tool template must be a string, object, or sequence."),
166
172
  );
167
173
  }
168
174
 
package/lib/tools.ts CHANGED
@@ -436,6 +436,7 @@ export function createRegisterToolDefinition<TContext>(
436
436
  ),
437
437
  template: unionSchema([
438
438
  stringSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.template),
439
+ looseObjectSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.template),
439
440
  arraySchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.templateArray),
440
441
  nullSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.templateNull),
441
442
  ]),
@@ -485,6 +486,27 @@ function messageBodyToToolParams(
485
486
  return { input: message.body };
486
487
  }
487
488
 
489
+ function formatToolActorFailure(
490
+ tool: string,
491
+ message: ActorMessages.ActorMessage,
492
+ params: Record<string, unknown>,
493
+ error: unknown,
494
+ ): Error {
495
+ const original = error instanceof Error ? error.message : String(error);
496
+ const paramsPreview = compactPreview(params, 240) ?? "{}";
497
+ return Object.assign(
498
+ new Error(
499
+ `tool actor ${tool} failed for message type ${message.type}: ${original}; params=${paramsPreview}`,
500
+ ),
501
+ {
502
+ message_type: message.type,
503
+ original_error: original,
504
+ params_preview: paramsPreview,
505
+ tool,
506
+ },
507
+ );
508
+ }
509
+
488
510
  function runIdFromActorAddress(
489
511
  address: string | undefined,
490
512
  ): string | undefined {
@@ -915,24 +937,25 @@ export function createInspectToolDefinition<TContext = unknown>(
915
937
  if (address.kind === "branch") {
916
938
  if (view !== "mailbox") throw new Error("inspect branch:<run>/<branch> supports view=mailbox.");
917
939
  const status = assertRunAccessibleToContext(runId, ctx);
918
- const messages = ActorRooms.readBranchInboxMessages(
940
+ const branchInbox = ActorRooms.readBranchInboxDiagnostics(
919
941
  String(status.state_dir ?? ""),
920
942
  runId,
921
943
  target,
922
944
  Number(input.lines || 40),
923
945
  );
946
+ const messages = branchInbox.messages;
924
947
  return {
925
948
  content: [
926
949
  {
927
950
  type: "text" as const,
928
951
  text: maybeJsonText(
929
- messages,
952
+ input.verbose === true ? branchInbox : messages,
930
953
  input.verbose === true,
931
- compactBranchInbox(messages.map((message) => ({ ...message }))),
954
+ `${compactBranchInbox(messages.map((message) => ({ ...message })))}${branchInbox.corrupted > 0 ? `\ncorrupted=${branchInbox.corrupted}` : ""}`,
932
955
  ),
933
956
  },
934
957
  ],
935
- details: { messages },
958
+ details: { corrupted: branchInbox.corrupted, messages },
936
959
  };
937
960
  }
938
961
  switch (view) {
@@ -1118,9 +1141,7 @@ export function createActorMessageToolDefinition<TContext = unknown>(
1118
1141
  } else if (address.kind === "branch" && address.value) {
1119
1142
  const runId = address.value;
1120
1143
  if (message.from) assertMessageSenderBelongsToRun(message, runId, `branch:${runId}/<branch>`);
1121
- const status = message.from
1122
- ? assertRunExistsForActorMessage(runId)
1123
- : assertRunAccessibleToContext(runId, ctx);
1144
+ const status = assertRunAccessibleToContext(runId, ctx);
1124
1145
  const stateDir = String(status.state_dir ?? "");
1125
1146
  if (stateDir && address.branch) {
1126
1147
  const ensureBranchMember = (actorAddress: string) => {
@@ -1159,7 +1180,7 @@ export function createActorMessageToolDefinition<TContext = unknown>(
1159
1180
  } else if (address.kind === "room" && address.value && address.room) {
1160
1181
  const runId = address.value;
1161
1182
  assertMessageSenderBelongsToRun(message, runId, `room:${runId}`);
1162
- const status = assertRunExistsForActorMessage(runId);
1183
+ const status = assertRunAccessibleToContext(runId, ctx);
1163
1184
  const stateDir = String(status.state_dir ?? "");
1164
1185
  if (!stateDir) throw new Error(`${message.to} has no run state directory.`);
1165
1186
  const recipients = getRoomMulticastRecipients(message, runId);
@@ -1185,13 +1206,19 @@ export function createActorMessageToolDefinition<TContext = unknown>(
1185
1206
  `tool actor not found or not executable: ${address.value}`,
1186
1207
  );
1187
1208
  }
1188
- const toolResult = await tool.execute(
1189
- `message:${message.type}`,
1190
- messageBodyToToolParams(message),
1191
- _signal,
1192
- _onUpdate,
1193
- ctx,
1194
- );
1209
+ const toolParams = messageBodyToToolParams(message);
1210
+ let toolResult: unknown;
1211
+ try {
1212
+ toolResult = await tool.execute(
1213
+ `message:${message.type}`,
1214
+ toolParams,
1215
+ _signal,
1216
+ _onUpdate,
1217
+ ctx,
1218
+ );
1219
+ } catch (error) {
1220
+ throw formatToolActorFailure(address.value, message, toolParams, error);
1221
+ }
1195
1222
  result = {
1196
1223
  invoked: true,
1197
1224
  sent: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-actors",
3
- "version": "0.22.2",
3
+ "version": "0.22.4",
4
4
  "private": false,
5
5
  "description": "Local Actor Kernel for Pi",
6
6
  "keywords": [
@@ -2,7 +2,7 @@
2
2
  name: actors
3
3
  description: Highest-density practical guide for pi-actors. Read this skill whenever prompt and tools are not enough for spawn, message, inspect, actor runs, tools, recipes, command templates, async lifecycle, mailboxes, artifacts, and local orchestration mechanics.
4
4
  metadata:
5
- version: 0.22.2
5
+ version: 0.22.4
6
6
  ---
7
7
 
8
8
  # Actors (pi-actors)