@llblab/pi-actors 0.44.0 → 0.45.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 (51) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +13 -0
  3. package/README.md +5 -1
  4. package/dist/index.d.ts +1 -2
  5. package/dist/index.js +10 -106
  6. package/dist/lib/async-runs.d.ts +2 -0
  7. package/dist/lib/async-runs.js +33 -4
  8. package/dist/lib/config.js +1 -0
  9. package/dist/lib/extension-runtime.d.ts +21 -0
  10. package/dist/lib/extension-runtime.js +116 -0
  11. package/dist/lib/inspector.js +8 -2
  12. package/dist/lib/recipes-discovery.js +20 -3
  13. package/dist/lib/recipes-references.d.ts +10 -0
  14. package/dist/lib/recipes-references.js +168 -5
  15. package/dist/lib/schema.d.ts +3 -0
  16. package/dist/lib/schema.js +34 -1
  17. package/dist/lib/tools-inspect.js +10 -0
  18. package/dist/lib/tools-local.js +27 -10
  19. package/dist/lib/tools-spawn.js +2 -2
  20. package/dist/scripts/async-runner.mjs +0 -16
  21. package/dist/scripts/executable-block-style.d.mts +1 -0
  22. package/dist/scripts/executable-block-style.mjs +73 -0
  23. package/dist/scripts/locker.mjs +0 -21
  24. package/dist/scripts/recipe-utils.mjs +0 -1
  25. package/dist/scripts/release-gates.mjs +14 -11
  26. package/dist/scripts/validate-recipe.mjs +0 -1
  27. package/dist/skills/actors/SKILL.md +2 -2
  28. package/docs/command-templates.md +2 -2
  29. package/docs/recipe-library.md +2 -0
  30. package/docs/template-recipes.md +10 -4
  31. package/docs/tool-registry.md +1 -1
  32. package/index.ts +18 -107
  33. package/lib/async-runs.ts +43 -9
  34. package/lib/config.ts +4 -0
  35. package/lib/extension-runtime.ts +139 -0
  36. package/lib/inspector.ts +16 -2
  37. package/lib/recipes-discovery.ts +23 -3
  38. package/lib/recipes-references.ts +227 -7
  39. package/lib/schema.ts +53 -1
  40. package/lib/tools-inspect.ts +11 -0
  41. package/lib/tools-local.ts +49 -19
  42. package/lib/tools-spawn.ts +2 -2
  43. package/package.json +2 -2
  44. package/scripts/async-runner.mjs +0 -16
  45. package/scripts/executable-block-style.d.mts +1 -0
  46. package/scripts/executable-block-style.mjs +73 -0
  47. package/scripts/locker.mjs +0 -21
  48. package/scripts/recipe-utils.mjs +0 -1
  49. package/scripts/release-gates.mjs +14 -11
  50. package/scripts/validate-recipe.mjs +0 -1
  51. package/skills/actors/SKILL.md +2 -2
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Pi actor extension runtime coordinator.
3
+ * Zones: extension session lifecycle, host tool adaptation, runtime service composition
4
+ * Owns low-level Pi lifecycle effects and tool wrapping without owning event registration.
5
+ */
6
+
7
+ import * as AutomaticReviewRuntime from "./automatic-review-runtime.ts";
8
+ import * as CommandTemplates from "./command-templates.ts";
9
+ import * as Paths from "./paths.ts";
10
+ import * as Pi from "./pi.ts";
11
+ import * as Prompts from "./prompts.ts";
12
+ import * as RecipesReferences from "./recipes-references.ts";
13
+ import * as RunUiRuntime from "./run-ui-runtime.ts";
14
+ import * as Runtime from "./runtime.ts";
15
+ import * as Temp from "./temp.ts";
16
+ import * as Tools from "./tools.ts";
17
+ import * as ToolsResponse from "./tools-response.ts";
18
+
19
+ export interface ActorExtensionRuntime {
20
+ beforeAgentStart(
21
+ systemPrompt: string,
22
+ skills: RecipesReferences.ActiveSkillRecipeSource[],
23
+ ): { systemPrompt: string };
24
+ discoverResources(metaUrl: string): { skillPaths: string[] } | undefined;
25
+ getRunOwnerId(ctx: Pi.ExtensionContext): string;
26
+ onAgentEnd(ctx: Pi.ExtensionContext): void;
27
+ onSessionShutdown(reason: string, ctx: Pi.ExtensionContext): void;
28
+ onSessionStart(ctx: Pi.ExtensionContext): Promise<void>;
29
+ registerCoreTools(): void;
30
+ }
31
+
32
+ export function createActorExtensionRuntime(
33
+ pi: Pi.ExtensionAPI,
34
+ ): ActorExtensionRuntime {
35
+ let activeRunContext: Pi.ExtensionContext | undefined;
36
+ const getRunOwnerId = Pi.getSessionId;
37
+ const automaticReview = AutomaticReviewRuntime.createAutomaticReviewRuntime({
38
+ getActiveContext: () => activeRunContext,
39
+ getRunOwnerId,
40
+ getThinkingLevel: () => pi.getThinkingLevel(),
41
+ });
42
+ const runUiRuntime = RunUiRuntime.createRunUiRuntime({
43
+ getActiveContext: () => activeRunContext,
44
+ getRunOwnerId,
45
+ onRunEvent: automaticReview.schedule,
46
+ pi,
47
+ });
48
+ const actorToolDefinitions = new Map<string, Tools.ActorToolDefinition>();
49
+ const withCurrentThinkingContext = <T extends Tools.ActorToolDefinition>(
50
+ definition: T,
51
+ ): T => {
52
+ if (typeof definition.execute !== "function") return definition;
53
+ const execute = definition.execute as (...args: unknown[]) => unknown;
54
+ return {
55
+ ...definition,
56
+ execute: async (...args: unknown[]) => {
57
+ const nextArgs = [...args];
58
+ const ctx = nextArgs[4];
59
+ if (ctx && typeof ctx === "object") {
60
+ nextArgs[4] = {
61
+ ...(ctx as Record<string, unknown>),
62
+ getThinkingLevel: () => pi.getThinkingLevel(),
63
+ };
64
+ }
65
+ try {
66
+ return ToolsResponse.spaceToolResult(await execute(...nextArgs));
67
+ } catch (error) {
68
+ throw ToolsResponse.spaceToolError(error);
69
+ }
70
+ },
71
+ } as T;
72
+ };
73
+ const runtime = Runtime.createAutoToolsRuntime({
74
+ configPath: Paths.EXTENSION_RUNTIME_PATHS.configPath,
75
+ exec: CommandTemplates.execCommandTemplate,
76
+ getActiveTools: () => pi.getActiveTools(),
77
+ registerTool: (definition) => {
78
+ const wrapped = withCurrentThinkingContext(definition);
79
+ actorToolDefinitions.set(wrapped.name, wrapped);
80
+ pi.registerTool(wrapped);
81
+ },
82
+ reservedToolNames: Tools.RESERVED_TOOL_NAMES,
83
+ setActiveTools: (toolNames) => pi.setActiveTools(toolNames),
84
+ });
85
+ const recipeReload = Runtime.createRecipeToolReloadWatcher(runtime);
86
+ return {
87
+ beforeAgentStart(systemPrompt, skills) {
88
+ RecipesReferences.setActiveSkillRecipeSources(skills);
89
+ return {
90
+ systemPrompt: `${systemPrompt}\n\n${Prompts.ONBOARDING_SYSTEM_PROMPT}`,
91
+ };
92
+ },
93
+ discoverResources(metaUrl) {
94
+ const skillPaths = Paths.getExistingExtensionSkillPaths(metaUrl);
95
+ return skillPaths.length > 0 ? { skillPaths } : undefined;
96
+ },
97
+ getRunOwnerId,
98
+ onAgentEnd(ctx) {
99
+ if (activeRunContext === ctx) automaticReview.schedule();
100
+ },
101
+ onSessionShutdown(reason, ctx) {
102
+ activeRunContext = undefined;
103
+ automaticReview.close();
104
+ recipeReload.close();
105
+ runUiRuntime.shutdown(reason, ctx);
106
+ },
107
+ async onSessionStart(ctx) {
108
+ ctx.ui.setWidget("zz-pi-actors-comms", undefined);
109
+ activeRunContext = ctx;
110
+ runUiRuntime.close();
111
+ automaticReview.close();
112
+ recipeReload.close();
113
+ await Temp.prepareExtensionTempDir(Paths.EXTENSION_RUNTIME_PATHS.tempDir);
114
+ if (activeRunContext !== ctx) return;
115
+ automaticReview.start(ctx);
116
+ runtime.loadTools(ctx);
117
+ runUiRuntime.start(ctx);
118
+ recipeReload.watch(ctx);
119
+ },
120
+ registerCoreTools() {
121
+ Pi.registerToolDefinitions(
122
+ pi,
123
+ Tools.createCoreActorToolDefinitions<Pi.ExtensionContext>({
124
+ configPath: Paths.EXTENSION_RUNTIME_PATHS.configPath,
125
+ getActiveTools: () => pi.getActiveTools(),
126
+ getRuntimeTool: (name) =>
127
+ Tools.resolveActiveRuntimeTool(
128
+ name,
129
+ runtime.getTools(),
130
+ (activeName) => actorToolDefinitions.get(activeName),
131
+ ),
132
+ handleRuntimeControl: automaticReview.handleControl,
133
+ registryRuntime: runtime,
134
+ setActiveTools: (toolNames) => pi.setActiveTools(toolNames),
135
+ }).map(withCurrentThinkingContext),
136
+ );
137
+ },
138
+ };
139
+ }
package/lib/inspector.ts CHANGED
@@ -105,9 +105,22 @@ export function readActorInspectorRecipe(
105
105
  const redactedRecord = (value: Record<string, unknown>): Record<string, unknown> =>
106
106
  SessionEvidence.redactSessionEvidenceValue(
107
107
  Object.fromEntries(
108
- Object.entries(value).filter(([, entry]) => entry !== undefined),
108
+ Object.entries(value).filter(
109
+ ([key, entry]) =>
110
+ entry !== undefined &&
111
+ key !== "recipe_dir" &&
112
+ key !== "skill_dir",
113
+ ),
109
114
  ),
110
115
  ) as Record<string, unknown>;
116
+ const launchValues =
117
+ meta.values && typeof meta.values === "object" && !Array.isArray(meta.values)
118
+ ? Object.fromEntries(
119
+ Object.entries(meta.values as Record<string, unknown>).filter(
120
+ ([key]) => key !== "recipe_dir" && key !== "skill_dir",
121
+ ),
122
+ )
123
+ : meta.values;
111
124
  return {
112
125
  composition: contexts.map((entry) =>
113
126
  redactedRecord({
@@ -116,6 +129,7 @@ export function readActorInspectorRecipe(
116
129
  file: entry.file,
117
130
  import_path: entry.import_path,
118
131
  name: entry.name,
132
+ qualified_name: entry.qualified_name,
119
133
  }),
120
134
  ),
121
135
  ...(definition ? { definition: redactedRecord(definition) } : {}),
@@ -129,7 +143,7 @@ export function readActorInspectorRecipe(
129
143
  launch: redactedRecord({
130
144
  cwd: meta.cwd,
131
145
  template: meta.template,
132
- values: meta.values,
146
+ values: launchValues,
133
147
  model_policy: meta.model_policy,
134
148
  control: meta.control,
135
149
  artifacts: meta.artifacts,
@@ -122,6 +122,25 @@ function getRecipeCommandTemplateConfig(
122
122
  : config) as CommandTemplates.CommandTemplateConfig;
123
123
  }
124
124
 
125
+ function getRecipeDiagnosticTemplateConfig(
126
+ config: TemplateRecipeConfig,
127
+ ): CommandTemplates.CommandTemplateObjectConfig {
128
+ const templateConfig = getRecipeCommandTemplateConfig(config);
129
+ return typeof templateConfig === "object" && !Array.isArray(templateConfig)
130
+ ? {
131
+ ...templateConfig,
132
+ defaults: {
133
+ ...(templateConfig.defaults ?? {}),
134
+ ...(config.defaults ?? {}),
135
+ ...(config.values ?? {}),
136
+ },
137
+ }
138
+ : {
139
+ defaults: { ...(config.defaults ?? {}), ...(config.values ?? {}) },
140
+ template: templateConfig,
141
+ };
142
+ }
143
+
125
144
  function getRecipeConfigDiagnostics(
126
145
  file: string,
127
146
  config: TemplateRecipeConfig | undefined,
@@ -131,7 +150,7 @@ function getRecipeConfigDiagnostics(
131
150
  return [`Invalid recipe: ${file}${reason ? `: ${reason}` : ""}`];
132
151
  }
133
152
  return CommandTemplates.getCommandTemplateWarnings(
134
- getRecipeCommandTemplateConfig(config),
153
+ getRecipeDiagnosticTemplateConfig(config),
135
154
  ).map((warning) => `Recipe ${file}: ${warning}`);
136
155
  }
137
156
 
@@ -141,7 +160,7 @@ function getRecipeRiskLabels(
141
160
  if (!config) return [];
142
161
  const labels = new Set(
143
162
  CommandTemplates.getCommandTemplateRiskLabels(
144
- getRecipeCommandTemplateConfig(config),
163
+ getRecipeDiagnosticTemplateConfig(config),
145
164
  ),
146
165
  );
147
166
  if (config.async === true) labels.add("risk.long_running");
@@ -846,9 +865,10 @@ export function toRegisteredTool(
846
865
  assertToolSafeRepeatConfig(
847
866
  argTemplateConfig,
848
867
  explicitArgTypes,
849
- cfg.defaults ?? {},
868
+ { ...(cfg.defaults ?? {}), ...(cfg.values ?? {}) },
850
869
  );
851
870
  const argTypes = Schema.getTemplateArgTypes(argTemplateConfig);
871
+ Schema.assertCompatibleToolArgTypes(explicitArgTypes, argTypes);
852
872
  return {
853
873
  name: entry.id,
854
874
  description,
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { existsSync, readFileSync, statSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
- import { basename, dirname, extname, resolve } from "node:path";
9
+ import { basename, dirname, extname, isAbsolute, relative, resolve } from "node:path";
10
10
 
11
11
  import type {
12
12
  CommandTemplateConfig,
@@ -15,6 +15,7 @@ import type {
15
15
  import * as CommandTemplates from "./command-templates.ts";
16
16
  import * as Paths from "./paths.ts";
17
17
  import * as RecipeControl from "./recipe-control.ts";
18
+ import * as Schema from "./schema.ts";
18
19
 
19
20
  const MAX_RECIPE_FILE_BYTES = 1024 * 1024;
20
21
  const MAX_RECIPE_IMPORT_DEPTH = 32;
@@ -57,6 +58,8 @@ export interface TemplateRecipeDefinition {
57
58
 
58
59
  export interface TemplateRecipeConfig extends TemplateRecipeDefinition {
59
60
  async?: boolean;
61
+ recipe_dir?: string;
62
+ skill_dir?: string;
60
63
  }
61
64
 
62
65
  interface ImportedRecipe {
@@ -74,6 +77,7 @@ export interface TemplateRecipeContextRecord {
74
77
  file: string;
75
78
  import_path: string[];
76
79
  name: string;
80
+ qualified_name?: string;
77
81
  recipe: Record<string, unknown>;
78
82
  role: "entry" | "import";
79
83
  }
@@ -82,10 +86,120 @@ export interface ReadResolvedRecipeConfigOptions {
82
86
  includeActorRecipeContext?: boolean;
83
87
  }
84
88
 
89
+ const RUNTIME_RECIPE_VALUE_NAMES = new Set(["recipe_dir", "skill_dir"]);
90
+ const activeSkillRecipeRoots = new Map<string, Set<string>>();
91
+
92
+ export interface ActiveSkillRecipeSource {
93
+ name: string;
94
+ filePath?: string;
95
+ baseDir?: string;
96
+ }
97
+
98
+ export function setActiveSkillRecipeSources(
99
+ skills: ActiveSkillRecipeSource[],
100
+ ): void {
101
+ activeSkillRecipeRoots.clear();
102
+ for (const skill of skills) {
103
+ const name = skill.name.trim();
104
+ const skillDir = skill.baseDir
105
+ ? resolve(skill.baseDir)
106
+ : skill.filePath
107
+ ? dirname(resolve(skill.filePath))
108
+ : undefined;
109
+ if (!name || !skillDir) continue;
110
+ const roots = activeSkillRecipeRoots.get(name) ?? new Set<string>();
111
+ roots.add(resolve(skillDir, "recipes"));
112
+ activeSkillRecipeRoots.set(name, roots);
113
+ }
114
+ }
115
+
116
+ export function getActiveSkillRecipeNamespaces(): Record<string, string[]> {
117
+ return Object.fromEntries(
118
+ [...activeSkillRecipeRoots.entries()]
119
+ .sort(([left], [right]) => left.localeCompare(right))
120
+ .map(([name, roots]) => [name, [...roots].sort()]),
121
+ );
122
+ }
123
+
124
+ function qualifiedRecipeNameForFile(file: string): string | undefined {
125
+ const path = resolve(file);
126
+ const stdRelation = relative(Paths.getPackagedRecipeRoot(), path);
127
+ if (
128
+ stdRelation &&
129
+ !stdRelation.startsWith("..") &&
130
+ !isAbsolute(stdRelation)
131
+ ) {
132
+ return `std:${stdRelation.replace(/\.(?:json|md)$/u, "")}`;
133
+ }
134
+ for (const [skillName, roots] of activeSkillRecipeRoots) {
135
+ if (roots.size !== 1) continue;
136
+ const root = [...roots][0];
137
+ const relation = relative(root, path);
138
+ if (relation && !relation.startsWith("..") && !isAbsolute(relation))
139
+ return `skill:${skillName}/${relation.replaceAll("\\", "/").replace(/\.(?:json|md)$/u, "")}`;
140
+ }
141
+ return undefined;
142
+ }
143
+
144
+ function qualifiedRecipeCandidates(value: string): string[] | undefined {
145
+ if (value.startsWith("std:")) {
146
+ return recipeNameFiles(value.slice(4)).map((file) =>
147
+ resolve(Paths.getPackagedRecipeRoot(), file),
148
+ );
149
+ }
150
+ if (!value.startsWith("skill:")) return undefined;
151
+ const qualified = value.slice(6);
152
+ const separator = qualified.indexOf("/");
153
+ if (separator <= 0 || separator === qualified.length - 1)
154
+ throw new Error(`Invalid Skill Recipe reference: ${value}`);
155
+ const skillName = qualified.slice(0, separator);
156
+ const recipeName = qualified.slice(separator + 1);
157
+ const roots = [...(activeSkillRecipeRoots.get(skillName) ?? [])];
158
+ if (roots.length === 0)
159
+ throw new Error(`Active Skill Recipe namespace not found: ${skillName}`);
160
+ if (roots.length > 1)
161
+ throw new Error(
162
+ `Ambiguous active Skill Recipe namespace ${skillName}: ${roots.join(", ")}`,
163
+ );
164
+ const root = roots[0];
165
+ return recipeNameFiles(recipeName).map((file) => {
166
+ const candidate = resolve(root, file);
167
+ const relation = relative(root, candidate);
168
+ if (!relation || relation.startsWith("..") || isAbsolute(relation))
169
+ throw new Error(`Skill Recipe reference escapes its recipes root: ${value}`);
170
+ return candidate;
171
+ });
172
+ }
173
+
85
174
  function hasWhitespace(value: string): boolean {
86
175
  return /\s/.test(value);
87
176
  }
88
177
 
178
+ function findOwningSkillDir(recipeFile: string): string | undefined {
179
+ let current = dirname(recipeFile);
180
+ while (true) {
181
+ if (existsSync(resolve(current, "SKILL.md"))) return current;
182
+ const parent = dirname(current);
183
+ if (parent === current) return undefined;
184
+ current = parent;
185
+ }
186
+ }
187
+
188
+ function assertRuntimeRecipeValuesNotDeclared(raw: Record<string, unknown>): void {
189
+ const args = Array.isArray(raw.args)
190
+ ? Schema.getExplicitToolArgNames(raw.args.map(String))
191
+ : [];
192
+ for (const name of RUNTIME_RECIPE_VALUE_NAMES) {
193
+ if (
194
+ args.includes(name) ||
195
+ (isRecord(raw.defaults) && Object.hasOwn(raw.defaults, name)) ||
196
+ (isRecord(raw.values) && Object.hasOwn(raw.values, name))
197
+ ) {
198
+ throw new Error(`Recipe runtime value is reserved: ${name}`);
199
+ }
200
+ }
201
+ }
202
+
89
203
  export function resolveRecipePath(
90
204
  value: string,
91
205
  recipeRoot = Paths.getRecipeRoot(),
@@ -125,6 +239,8 @@ function recipeCandidatePaths(
125
239
  value: string,
126
240
  currentRecipeRoot: string,
127
241
  ): string[] {
242
+ const qualified = qualifiedRecipeCandidates(value.trim());
243
+ if (qualified) return qualified;
128
244
  if (!isBareRecipeName(value))
129
245
  return [resolveRecipePath(value, currentRecipeRoot)];
130
246
  const roots = [
@@ -646,6 +762,63 @@ function mergeDefaults(
646
762
  return Object.keys(merged).length > 0 ? merged : undefined;
647
763
  }
648
764
 
765
+ function assertRecipeDefaultsDeclared(
766
+ args: string[] | undefined,
767
+ defaults: Record<string, unknown> | undefined,
768
+ label: string,
769
+ ): void {
770
+ const spec = Schema.parseToolArgDeclarationList(args ?? []);
771
+ if (spec.error) throw new Error(spec.error);
772
+ if (spec.args.length === 0) return;
773
+ const declared = new Set(spec.args);
774
+ for (const key of Object.keys(defaults ?? {})) {
775
+ if (!declared.has(key)) throw new Error(`Unknown ${label} argument: ${key}`);
776
+ }
777
+ }
778
+
779
+ function assertRecipeArgumentContract(
780
+ config: Pick<
781
+ TemplateRecipeDefinition,
782
+ "args" | "defaults" | "values" | "template"
783
+ >,
784
+ label = "Recipe default",
785
+ ): void {
786
+ assertRecipeDefaultsDeclared(config.args, config.defaults, label);
787
+ const spec = Schema.parseToolArgDeclarationList(config.args ?? []);
788
+ if (spec.error) throw new Error(spec.error);
789
+ Schema.assertCompatibleToolArgTypes(
790
+ spec.argTypes,
791
+ Schema.getTemplateArgTypes({ template: config.template }),
792
+ );
793
+ const staticValues = Object.fromEntries(
794
+ Object.entries({ ...config.defaults, ...config.values }).filter(
795
+ ([, value]) =>
796
+ typeof value !== "string" || !/\{[^{}]+\}/.test(value),
797
+ ),
798
+ );
799
+ Schema.normalizeRuntimeValues(staticValues, spec.argTypes);
800
+ }
801
+
802
+ function resolveRecipeValueLayers(
803
+ args: string[] | undefined,
804
+ ...layers: Array<Record<string, unknown> | undefined>
805
+ ): Record<string, unknown> | undefined {
806
+ const spec = Schema.parseToolArgDeclarationList(args ?? []);
807
+ if (spec.error) throw new Error(spec.error);
808
+ const merged = mergeDefaults(spec.defaults, ...layers);
809
+ if (!merged) return undefined;
810
+ const staticValues = Object.fromEntries(
811
+ Object.entries(merged).filter(
812
+ ([, value]) =>
813
+ typeof value !== "string" || !/\{[^{}]+\}/.test(value),
814
+ ),
815
+ );
816
+ return {
817
+ ...merged,
818
+ ...Schema.normalizeRuntimeValues(staticValues, spec.argTypes),
819
+ };
820
+ }
821
+
649
822
  function applyDefaultsToTemplate(
650
823
  template: CommandTemplateValue,
651
824
  defaults: Record<string, unknown> | undefined,
@@ -655,6 +828,7 @@ function applyDefaultsToTemplate(
655
828
  delete cleanOverrides.name;
656
829
  delete cleanOverrides.template;
657
830
  delete cleanOverrides.values;
831
+ delete cleanOverrides.defaults;
658
832
  if (typeof template === "object" && !Array.isArray(template)) {
659
833
  return {
660
834
  ...template,
@@ -716,11 +890,22 @@ function applyDelegatedRecipeToNode(
716
890
  delegated: TemplateRecipeConfig,
717
891
  overrides: Record<string, unknown> = {},
718
892
  ): CommandTemplateValue {
719
- return applyDefaultsToTemplate(
720
- delegated.template,
893
+ const nodeDefaults = isRecord(overrides.defaults)
894
+ ? overrides.defaults
895
+ : undefined;
896
+ const nodeValues = isRecord(overrides.values) ? overrides.values : undefined;
897
+ const values = resolveRecipeValueLayers(
898
+ delegated.args,
899
+ delegated.defaults,
721
900
  delegated.values,
722
- overrides,
901
+ nodeDefaults,
902
+ nodeValues,
903
+ {
904
+ ...(delegated.recipe_dir ? { recipe_dir: delegated.recipe_dir } : {}),
905
+ ...(delegated.skill_dir ? { skill_dir: delegated.skill_dir } : {}),
906
+ },
723
907
  );
908
+ return applyDefaultsToTemplate(delegated.template, values, overrides);
724
909
  }
725
910
 
726
911
  function expandRecipeDelegations(
@@ -830,7 +1015,8 @@ function expandImportNodes(
830
1015
  ? record.defaults
831
1016
  : undefined;
832
1017
  const nodeValues = isRecord(record.values) ? record.values : undefined;
833
- const defaults = mergeDefaults(
1018
+ const defaults = resolveRecipeValueLayers(
1019
+ imported.config.args,
834
1020
  imported.defaults,
835
1021
  imported.values,
836
1022
  nodeDefaults,
@@ -897,6 +1083,8 @@ export function readResolvedRecipeConfig(
897
1083
  const raw = readRawRecipeConfig(path);
898
1084
  if (!raw || !Object.hasOwn(raw, "template")) return undefined;
899
1085
  RecipeControl.assertRecipeHasNoMailbox(raw);
1086
+ assertRuntimeRecipeValuesNotDeclared(raw);
1087
+ assertRecipeArgumentContract(raw as unknown as TemplateRecipeDefinition);
900
1088
  const imports: Record<string, ImportedRecipe> = {};
901
1089
  for (const [alias, binding] of Object.entries(getRecipeImports(raw))) {
902
1090
  const importPath = resolveRecipeImportPath(
@@ -913,13 +1101,22 @@ export function readResolvedRecipeConfig(
913
1101
  typeof binding === "string" ? undefined : binding.defaults;
914
1102
  const bindingValues =
915
1103
  typeof binding === "string" ? undefined : binding.values;
1104
+ assertRecipeDefaultsDeclared(
1105
+ config.args,
1106
+ bindingDefaults,
1107
+ `Recipe import ${alias} default`,
1108
+ );
916
1109
  imports[alias] = {
917
1110
  alias,
918
1111
  file: importPath,
919
1112
  name: config.name ?? alias,
920
1113
  config,
921
1114
  defaults: { ...(config.defaults ?? {}), ...(bindingDefaults ?? {}) },
922
- values: { ...(bindingValues ?? {}) },
1115
+ values: {
1116
+ ...(bindingValues ?? {}),
1117
+ ...(config.recipe_dir ? { recipe_dir: config.recipe_dir } : {}),
1118
+ ...(config.skill_dir ? { skill_dir: config.skill_dir } : {}),
1119
+ },
923
1120
  };
924
1121
  }
925
1122
  const substituted = substituteImportRefs(raw, imports) as Record<
@@ -945,6 +1142,21 @@ export function readResolvedRecipeConfig(
945
1142
  )
946
1143
  : expandRecipeDelegations(expandedImportsTemplate, path, stack, options);
947
1144
  const recipeName = getRecipeIdFromPath(path);
1145
+ const recipeDir = dirname(path);
1146
+ const skillDir = findOwningSkillDir(path);
1147
+ if (
1148
+ !skillDir &&
1149
+ JSON.stringify({
1150
+ artifacts: substituted.artifacts,
1151
+ defaults: substituted.defaults,
1152
+ template: substituted.template,
1153
+ values: substituted.values,
1154
+ }).includes("{skill_dir}")
1155
+ ) {
1156
+ throw new Error(
1157
+ `Recipe uses {skill_dir} outside an owning Skill directory: ${path}`,
1158
+ );
1159
+ }
948
1160
  const templateWithContext = options.includeActorRecipeContext
949
1161
  ? withActorRecipeContext(expandedTemplate, {
950
1162
  file: path,
@@ -967,6 +1179,8 @@ export function readResolvedRecipeConfig(
967
1179
  );
968
1180
  return {
969
1181
  name: recipeName,
1182
+ recipe_dir: recipeDir,
1183
+ ...(skillDir ? { skill_dir: skillDir } : {}),
970
1184
  ...(typeof substituted.description === "string" &&
971
1185
  substituted.description.trim()
972
1186
  ? { description: substituted.description.trim() }
@@ -1059,7 +1273,11 @@ export function readResolvedRecipeConfig(
1059
1273
  ...(typeof substituted.repeat === "number"
1060
1274
  ? { repeat: substituted.repeat }
1061
1275
  : {}),
1062
- ...(isRecord(substituted.values) ? { values: substituted.values } : {}),
1276
+ values: {
1277
+ ...(isRecord(substituted.values) ? substituted.values : {}),
1278
+ recipe_dir: recipeDir,
1279
+ ...(skillDir ? { skill_dir: skillDir } : {}),
1280
+ },
1063
1281
  ...(isRecord(substituted.usage) ? { usage: substituted.usage } : {}),
1064
1282
  };
1065
1283
  }
@@ -1084,12 +1302,14 @@ function collectRecipeContextRecords(
1084
1302
  }
1085
1303
  const raw = readRawRecipeConfig(path);
1086
1304
  if (!raw || !Object.hasOwn(raw, "template")) return [];
1305
+ const qualifiedName = qualifiedRecipeNameForFile(path);
1087
1306
  const record: TemplateRecipeContextRecord = {
1088
1307
  ...(alias ? { alias } : {}),
1089
1308
  depth: stack.length,
1090
1309
  file: path,
1091
1310
  import_path: importPath,
1092
1311
  name: getRecipeIdFromPath(path),
1312
+ ...(qualifiedName ? { qualified_name: qualifiedName } : {}),
1093
1313
  recipe: raw,
1094
1314
  role: stack.length === 0 ? "entry" : "import",
1095
1315
  };
package/lib/schema.ts CHANGED
@@ -185,7 +185,11 @@ function isValidDefault(type: ToolArgType, value: string): boolean {
185
185
  }
186
186
 
187
187
  export function parseToolArgDeclarations(value: string): ToolArgSpec {
188
- const source = splitArgDeclarations(value);
188
+ return parseToolArgDeclarationList(splitArgDeclarations(value));
189
+ }
190
+
191
+ export function parseToolArgDeclarationList(value: unknown[]): ToolArgSpec {
192
+ const source = value.map(String);
189
193
  const seen = new Set<string>();
190
194
  const duplicates = new Set<string>();
191
195
  const args: string[] = [];
@@ -324,6 +328,7 @@ function collectCommandTemplateConfigDeclarations(
324
328
  collectTemplatePlaceholderDeclarations(config, declarations);
325
329
  return;
326
330
  }
331
+ if (!config || typeof config !== "object") return;
327
332
  if (Array.isArray(config)) {
328
333
  for (const step of config)
329
334
  collectCommandTemplateConfigDeclarations(step, declarations);
@@ -356,11 +361,42 @@ export function getTemplatePlaceholderNames(
356
361
  );
357
362
  }
358
363
 
364
+ function toolArgTypesEqual(left: ToolArgType, right: ToolArgType): boolean {
365
+ return JSON.stringify(left) === JSON.stringify(right);
366
+ }
367
+
368
+ export function assertCompatibleToolArgTypes(
369
+ ...sources: Array<Record<string, ToolArgType> | undefined>
370
+ ): void {
371
+ const seen: Record<string, ToolArgType> = {};
372
+ for (const source of sources) {
373
+ for (const [name, type] of Object.entries(source ?? {})) {
374
+ const previous = seen[name];
375
+ if (previous && !toolArgTypesEqual(previous, type)) {
376
+ throw new Error(
377
+ `Conflicting argument type for ${name}: ${canonicalArgDeclaration(name, previous)} vs ${canonicalArgDeclaration(name, type)}`,
378
+ );
379
+ }
380
+ seen[name] = type;
381
+ }
382
+ }
383
+ }
384
+
359
385
  export function getTemplateArgTypes(
360
386
  config: CommandTemplates.CommandTemplateConfig,
361
387
  ): Record<string, ToolArgType> {
362
388
  const argTypes: Record<string, ToolArgType> = {};
363
389
  for (const declaration of getTemplatePlaceholderDeclarations(config)) {
390
+ const previous = argTypes[declaration.arg];
391
+ if (
392
+ previous &&
393
+ declaration.type.kind !== "string" &&
394
+ !toolArgTypesEqual(previous, declaration.type)
395
+ ) {
396
+ throw new Error(
397
+ `Conflicting argument type for ${declaration.arg}: ${canonicalArgDeclaration(declaration.arg, previous)} vs ${declaration.declaration}`,
398
+ );
399
+ }
364
400
  if (declaration.type.kind !== "string")
365
401
  argTypes[declaration.arg] = declaration.type;
366
402
  }
@@ -373,6 +409,22 @@ export function getExplicitToolArgNames(args: string[] | undefined): string[] {
373
409
  );
374
410
  }
375
411
 
412
+ export function getExplicitToolArgDefaults(
413
+ args: string[] | string | undefined,
414
+ ): Record<string, string> {
415
+ const declarations =
416
+ typeof args === "string" ? splitArgDeclarations(args) : (args ?? []);
417
+ return Object.fromEntries(
418
+ declarations
419
+ .map((item) => parseToolArgToken(String(item)))
420
+ .filter(
421
+ (item): item is ParsedToolArgToken & { defaultValue: string } =>
422
+ item.defaultValue !== undefined,
423
+ )
424
+ .map((item) => [item.arg, item.defaultValue]),
425
+ );
426
+ }
427
+
376
428
  export function getToolArgNames(
377
429
  config: CommandTemplates.CommandTemplateConfig,
378
430
  ): string[] {