@llblab/pi-actors 0.34.1 → 0.36.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.
@@ -67,6 +67,29 @@ export interface CommandTemplateExecResult {
67
67
  killed: boolean;
68
68
  }
69
69
 
70
+ export type CommandTemplateRiskLabel =
71
+ | "risk.shell"
72
+ | "risk.eval"
73
+ | "risk.broad_fs_write"
74
+ | "risk.destructive_fs"
75
+ | "risk.network"
76
+ | "risk.external_side_effect"
77
+ | "risk.long_running"
78
+ | "risk.platform_specific"
79
+ | "risk.secret_touching";
80
+
81
+ const COMMAND_TEMPLATE_RISK_LABEL_ORDER: CommandTemplateRiskLabel[] = [
82
+ "risk.shell",
83
+ "risk.eval",
84
+ "risk.destructive_fs",
85
+ "risk.broad_fs_write",
86
+ "risk.external_side_effect",
87
+ "risk.secret_touching",
88
+ "risk.network",
89
+ "risk.long_running",
90
+ "risk.platform_specific",
91
+ ];
92
+
70
93
  export type CommandTemplateExecCommand = (
71
94
  command: string,
72
95
  args: string[],
@@ -181,6 +204,97 @@ function hasRiskyPathArg(args: string[]): boolean {
181
204
  );
182
205
  }
183
206
 
207
+ function sortRiskLabels(
208
+ labels: Iterable<CommandTemplateRiskLabel>,
209
+ ): CommandTemplateRiskLabel[] {
210
+ const unique = new Set(labels);
211
+ return COMMAND_TEMPLATE_RISK_LABEL_ORDER.filter((label) => unique.has(label));
212
+ }
213
+
214
+ function hasAnyArg(args: string[], values: string[]): boolean {
215
+ return args.some((arg) => values.includes(arg.toLowerCase()));
216
+ }
217
+
218
+ function hasSecretTouchingText(parts: string[]): boolean {
219
+ return parts.some((part) =>
220
+ /(^|[{}._\-\s/])(?:secret|token|password|passwd|credential|api[_-]?key|private[_-]?key|\.env|ssh[_-]?key)(?:[{}._\-\s/]|$)/i.test(
221
+ part,
222
+ ),
223
+ );
224
+ }
225
+
226
+ function getLeafCommandTemplateRiskLabels(
227
+ config: CommandTemplateLeafConfig,
228
+ ): CommandTemplateRiskLabel[] {
229
+ const parts = splitCommandTemplate(config.template);
230
+ const command = getExecutableName(parts[0]);
231
+ const args = parts.slice(1);
232
+ const labels = new Set<CommandTemplateRiskLabel>();
233
+ if (["bash", "sh", "zsh", "fish"].includes(command)) {
234
+ labels.add("risk.shell");
235
+ if (hasAnyFlag(args, ["-c"])) labels.add("risk.eval");
236
+ }
237
+ if (
238
+ ["node", "deno", "bun"].includes(command) &&
239
+ hasAnyFlag(args, ["-e", "--eval"])
240
+ ) {
241
+ labels.add("risk.eval");
242
+ }
243
+ if (
244
+ ["python", "python3", "perl", "ruby"].includes(command) &&
245
+ hasAnyFlag(args, ["-c", "-e"])
246
+ ) {
247
+ labels.add("risk.eval");
248
+ }
249
+ if (
250
+ command === "rm" &&
251
+ (args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) ||
252
+ hasRiskyPathArg(args))
253
+ ) {
254
+ labels.add("risk.destructive_fs");
255
+ }
256
+ if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
257
+ labels.add("risk.broad_fs_write");
258
+ }
259
+ if (
260
+ ["curl", "wget", "ssh", "scp", "sftp", "rsync", "nc", "ncat", "telnet", "ftp"].includes(
261
+ command,
262
+ ) ||
263
+ (command === "git" &&
264
+ hasAnyArg(args, ["clone", "fetch", "pull", "push", "ls-remote"])) ||
265
+ ["npm", "pnpm", "yarn", "pip", "cargo"].includes(command)
266
+ ) {
267
+ labels.add("risk.network");
268
+ }
269
+ if (
270
+ ["gh", "glab", "hub", "kubectl", "terraform"].includes(command) ||
271
+ (command === "git" && hasAnyArg(args, ["push"])) ||
272
+ (["npm", "pnpm", "yarn"].includes(command) &&
273
+ hasAnyArg(args, ["publish", "login", "logout", "deprecate"]))
274
+ ) {
275
+ labels.add("risk.external_side_effect");
276
+ }
277
+ if (
278
+ command === "sleep" ||
279
+ command === "watch" ||
280
+ (command === "tail" && hasAnyFlag(args, ["-f"])) ||
281
+ hasAnyArg(args, ["--watch", "--serve", "serve"])
282
+ ) {
283
+ labels.add("risk.long_running");
284
+ }
285
+ if (
286
+ ["systemctl", "launchctl", "osascript", "open", "xdg-open", "powershell", "pwsh", "cmd.exe", "apt", "apt-get", "dnf", "yum", "brew", "pacman", "apk", "xclip", "wl-copy"].includes(
287
+ command,
288
+ )
289
+ ) {
290
+ labels.add("risk.platform_specific");
291
+ }
292
+ if (["pass", "gpg", "ssh-add"].includes(command) || hasSecretTouchingText(parts)) {
293
+ labels.add("risk.secret_touching");
294
+ }
295
+ return sortRiskLabels(labels);
296
+ }
297
+
184
298
  function getLeafCommandTemplateWarnings(
185
299
  config: CommandTemplateLeafConfig,
186
300
  ): string[] {
@@ -347,6 +461,16 @@ export function getCommandTemplateWarnings(
347
461
  ];
348
462
  }
349
463
 
464
+ export function getCommandTemplateRiskLabels(
465
+ config: CommandTemplateConfig,
466
+ ): CommandTemplateRiskLabel[] {
467
+ return sortRiskLabels(
468
+ expandCommandTemplateConfigs(config).flatMap((leaf) =>
469
+ getLeafCommandTemplateRiskLabels(leaf),
470
+ ),
471
+ );
472
+ }
473
+
350
474
  function parseCommandTemplateArgToken(value: string): {
351
475
  name: string;
352
476
  defaultValue?: string;
package/lib/prompts.ts CHANGED
@@ -38,6 +38,8 @@ export const REGISTER_TOOL_PARAM_DESCRIPTIONS = {
38
38
  name: "Tool name in snake_case (e.g., 'transcribe')",
39
39
  description:
40
40
  "Describe what the tool does for the LLM. Required unless deleting; omitted updates keep the old description.",
41
+ draft:
42
+ "Promote a draft recipe path from ~/.pi/agent/recipes/drafts into an active named recipe under ~/.pi/agent/recipes. Requires name; use update=true to overwrite.",
41
43
  async:
42
44
  "Set true for a co-located async template recipe. Omit for ordinary command templates or file-backed recipe references.",
43
45
  state_dir:
@@ -27,6 +27,7 @@ export interface DiscoveredRecipe {
27
27
  tool: boolean;
28
28
  mutableUsage: boolean;
29
29
  diagnostics: string[];
30
+ riskLabels: CommandTemplates.CommandTemplateRiskLabel[];
30
31
  shadows: string[];
31
32
  }
32
33
 
@@ -110,6 +111,14 @@ function listRecipeFiles(root: string): string[] {
110
111
  );
111
112
  }
112
113
 
114
+ function getRecipeCommandTemplateConfig(
115
+ config: TemplateRecipeConfig,
116
+ ): CommandTemplates.CommandTemplateConfig {
117
+ return (typeof config.template === "object" && config.template !== null
118
+ ? config.template
119
+ : config) as CommandTemplates.CommandTemplateConfig;
120
+ }
121
+
113
122
  function getRecipeConfigDiagnostics(
114
123
  file: string,
115
124
  config: TemplateRecipeConfig | undefined,
@@ -118,15 +127,36 @@ function getRecipeConfigDiagnostics(
118
127
  const reason = RecipesReferences.diagnoseRawRecipeConfigFailure(file);
119
128
  return [`Invalid recipe: ${file}${reason ? `: ${reason}` : ""}`];
120
129
  }
121
- const commandTemplateConfig =
122
- typeof config.template === "object" && config.template !== null
123
- ? config.template
124
- : config;
125
130
  return CommandTemplates.getCommandTemplateWarnings(
126
- commandTemplateConfig as CommandTemplates.CommandTemplateConfig,
131
+ getRecipeCommandTemplateConfig(config),
127
132
  ).map((warning) => `Recipe ${file}: ${warning}`);
128
133
  }
129
134
 
135
+ function getRecipeRiskLabels(
136
+ config: TemplateRecipeConfig | undefined,
137
+ ): CommandTemplates.CommandTemplateRiskLabel[] {
138
+ if (!config) return [];
139
+ const labels = new Set(
140
+ CommandTemplates.getCommandTemplateRiskLabels(
141
+ getRecipeCommandTemplateConfig(config),
142
+ ),
143
+ );
144
+ if (config.async === true) labels.add("risk.long_running");
145
+ return [
146
+ "risk.shell",
147
+ "risk.eval",
148
+ "risk.destructive_fs",
149
+ "risk.broad_fs_write",
150
+ "risk.external_side_effect",
151
+ "risk.secret_touching",
152
+ "risk.network",
153
+ "risk.long_running",
154
+ "risk.platform_specific",
155
+ ].filter((label): label is CommandTemplates.CommandTemplateRiskLabel =>
156
+ labels.has(label as CommandTemplates.CommandTemplateRiskLabel),
157
+ );
158
+ }
159
+
130
160
  function readDiscoveredRecipe(
131
161
  root: string,
132
162
  file: string,
@@ -152,6 +182,7 @@ function readDiscoveredRecipe(
152
182
  tool: defaultTool && !disabled && !invalid,
153
183
  mutableUsage,
154
184
  diagnostics: getRecipeConfigDiagnostics(file, config),
185
+ riskLabels: getRecipeRiskLabels(config),
155
186
  shadows: [],
156
187
  };
157
188
  } catch (error) {
@@ -169,6 +200,7 @@ function readDiscoveredRecipe(
169
200
  diagnostics: [
170
201
  `Failed to load recipe ${file}: ${error instanceof Error ? error.message : String(error)}`,
171
202
  ],
203
+ riskLabels: [],
172
204
  shadows: [],
173
205
  };
174
206
  }
@@ -392,7 +424,7 @@ function diagnosticSeverity(message: string): "info" | "warning" | "error" {
392
424
  return "error";
393
425
  }
394
426
  if (
395
- /world-writable|group-writable|invokes bash|eval|destructive|unsafe/i.test(
427
+ /world-writable|group-writable|invokes bash|eval|destructive|broad filesystem|unsafe/i.test(
396
428
  message,
397
429
  )
398
430
  ) {
@@ -435,6 +467,7 @@ function diagnosticDetails(
435
467
  seen.add(key);
436
468
  details.push({
437
469
  ...(entry ? { id: entry.id, path: entry.path } : {}),
470
+ ...(entry?.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
438
471
  action: diagnosticSuggestedAction(message),
439
472
  message,
440
473
  severity: diagnosticSeverity(message),
@@ -496,6 +529,7 @@ function remediationForEntry(
496
529
  kind: "risky_shell_boundary",
497
530
  severity: "warning",
498
531
  path: entry.path,
532
+ ...(entry.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
499
533
  reason: riskyDiagnostics[0],
500
534
  action:
501
535
  "audit trusted command boundary; keep only if the recipe is local and intentional",
@@ -544,6 +578,31 @@ function discoveryRemediations(
544
578
  );
545
579
  }
546
580
 
581
+ function summarizeRiskLabels(
582
+ entries: DiscoveredRecipe[],
583
+ ): Array<Record<string, unknown>> {
584
+ const counts = new Map<string, { count: number; ids: Set<string> }>();
585
+ for (const entry of entries) {
586
+ for (const label of entry.riskLabels) {
587
+ const current = counts.get(label) ?? { count: 0, ids: new Set<string>() };
588
+ current.count += 1;
589
+ current.ids.add(entry.id);
590
+ counts.set(label, current);
591
+ }
592
+ }
593
+ return [...counts.entries()]
594
+ .map(([label, value]) => ({
595
+ label,
596
+ count: value.count,
597
+ recipes: [...value.ids].sort(),
598
+ }))
599
+ .sort(
600
+ (a, b) =>
601
+ Number(b.count) - Number(a.count) ||
602
+ String(a.label).localeCompare(String(b.label)),
603
+ );
604
+ }
605
+
547
606
  function recommendationForEntry(
548
607
  entry: DiscoveredRecipe,
549
608
  activePath: string | undefined,
@@ -574,14 +633,45 @@ export function getShadowedLaunchDiagnostic(
574
633
  };
575
634
  }
576
635
 
636
+ function templatePreview(value: unknown): string | undefined {
637
+ const rendered =
638
+ typeof value === "string"
639
+ ? value
640
+ : value === undefined
641
+ ? undefined
642
+ : JSON.stringify(value);
643
+ return rendered && rendered.length > 120
644
+ ? `${rendered.slice(0, 117)}...`
645
+ : rendered;
646
+ }
647
+
577
648
  export function listDraftRecipes(root: string): Array<Record<string, unknown>> {
578
649
  return listRecipeFiles(root).map((path) => {
579
650
  const id = RecipesReferences.getRecipeIdFromPath(path);
651
+ const bytes = readFileSync(path);
652
+ const stat = statSync(path);
580
653
  const config = RecipesReferences.readRawRecipeConfig(path);
654
+ const resolved = RecipesReferences.readResolvedRecipeConfig(path);
655
+ const diagnostics = getRecipeConfigDiagnostics(path, resolved);
656
+ const sourceRun = String(config?.description ?? "").match(
657
+ /spawn run ([^\s]+)/,
658
+ )?.[1];
659
+ const preview = templatePreview(config?.template);
660
+ const riskLabels = getRecipeRiskLabels(resolved);
581
661
  return {
582
662
  id,
583
663
  path,
664
+ sha256: createHash("sha256").update(bytes).digest("hex"),
665
+ size: bytes.byteLength,
666
+ created_at: stat.birthtime.toISOString(),
667
+ modified_at: stat.mtime.toISOString(),
668
+ valid: Boolean(resolved),
669
+ diagnostics,
670
+ ...(riskLabels.length ? { risk_labels: riskLabels } : {}),
584
671
  ...(config?.description ? { description: config.description } : {}),
672
+ ...(sourceRun ? { source_run: sourceRun } : {}),
673
+ ...(config?.async !== undefined ? { async: config.async } : {}),
674
+ ...(preview ? { template_preview: preview } : {}),
585
675
  };
586
676
  });
587
677
  }
@@ -610,6 +700,7 @@ export function summarizeDiscovery(
610
700
  disabled: entry.disabled,
611
701
  invalid: entry.invalid,
612
702
  shadows: entry.shadows,
703
+ ...(entry.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
613
704
  ...(entry.config?.imports ? { imports: entry.config.imports } : {}),
614
705
  ...(recipeUsage(entry.config)
615
706
  ? { usage: recipeUsage(entry.config) }
@@ -636,6 +727,7 @@ export function summarizeDiscovery(
636
727
  .filter((entry) => entry.disabled)
637
728
  .map((entry) => ({ id: entry.id, path: entry.path }))
638
729
  .sort((a, b) => a.id.localeCompare(b.id)),
730
+ risk_summary: summarizeRiskLabels(result.entries),
639
731
  recommendations,
640
732
  remediations,
641
733
  top_action: remediations[0],
package/lib/registry.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { existsSync, mkdirSync, unlinkSync } from "node:fs";
8
- import { dirname, join } from "node:path";
8
+ import { dirname, join, relative, resolve } from "node:path";
9
9
 
10
10
  import * as CommandTemplates from "./command-templates.ts";
11
11
  import * as Config from "./config.ts";
@@ -22,6 +22,7 @@ export interface RegisterToolInput {
22
22
  async?: boolean;
23
23
  state_dir?: string;
24
24
  template?: CommandTemplates.CommandTemplateValue | null;
25
+ draft?: string;
25
26
  args?: string;
26
27
  update?: boolean;
27
28
  values?: Record<string, unknown>;
@@ -32,6 +33,8 @@ export interface RegisterToolResultDetails {
32
33
  async?: boolean;
33
34
  config?: string;
34
35
  defaults?: Record<string, string>;
36
+ draft?: string;
37
+ promoted?: boolean;
35
38
  recipeName?: string;
36
39
  state_dir?: string;
37
40
  template?: CommandTemplates.CommandTemplateValue;
@@ -95,6 +98,93 @@ function getToolRecipePath<TContext>(
95
98
  return join(getRecipeRoot(deps), `${name}.json`);
96
99
  }
97
100
 
101
+ function assertDraftPath<TContext>(
102
+ deps: RegisterToolRuntimeDeps<TContext>,
103
+ draft: string,
104
+ ): string {
105
+ const draftRoot = resolve(getRecipeRoot(deps), "drafts");
106
+ const path = resolve(draft);
107
+ const relation = relative(draftRoot, path);
108
+ if (
109
+ relation === "" ||
110
+ relation.startsWith("..") ||
111
+ resolve(relation) === relation
112
+ ) {
113
+ throw new Error(
114
+ ExecutionOutput.formatToolText(
115
+ `Draft must be under ${draftRoot}. Use inspect target=recipes view=summary to list drafts.`,
116
+ ),
117
+ );
118
+ }
119
+ if (!existsSync(path)) {
120
+ throw new Error(ExecutionOutput.formatToolText(`Draft not found: ${path}`));
121
+ }
122
+ return path;
123
+ }
124
+
125
+ function promoteDraftRecipe<TContext>(
126
+ name: string,
127
+ input: RegisterToolInput,
128
+ ctx: TContext,
129
+ deps: RegisterToolRuntimeDeps<TContext>,
130
+ ): RegisterToolResult {
131
+ const draftPath = assertDraftPath(deps, String(input.draft ?? ""));
132
+ const targetPath = getToolRecipePath(deps, name);
133
+ const tools = deps.getTools();
134
+ const existing = tools.get(name);
135
+ const conflict = deps.getExternalToolConflict(name);
136
+ if (conflict) throw new Error(ExecutionOutput.formatToolText(conflict));
137
+ if ((existing || existsSync(targetPath)) && !input.update) {
138
+ throw new Error(
139
+ ExecutionOutput.formatToolText(
140
+ `Tool "${name}" already registered. Use update=true to overwrite.`,
141
+ ),
142
+ );
143
+ }
144
+ const config = RecipesReferences.readResolvedRecipeConfig(draftPath);
145
+ if (!config) {
146
+ const reason = RecipesReferences.diagnoseRawRecipeConfigFailure(draftPath);
147
+ throw new Error(
148
+ ExecutionOutput.formatToolText(
149
+ `Draft recipe is invalid${reason ? `: ${reason}` : "."}`,
150
+ ),
151
+ );
152
+ }
153
+ const promoted = buildConfig(
154
+ name,
155
+ {
156
+ ...input,
157
+ description: input.description ?? config.description,
158
+ template: draftPath,
159
+ },
160
+ existing,
161
+ );
162
+ const raw = RecipesReferences.readRawRecipeConfig(draftPath)!;
163
+ mkdirSync(dirname(targetPath), { recursive: true });
164
+ writeJsonAtomic(targetPath, raw);
165
+ promoted.template = targetPath;
166
+ promoted.sourcePath = targetPath;
167
+ tools.set(name, promoted);
168
+ deps.registerRuntimeTool(promoted);
169
+ deps.notify(ctx, `Promoted draft recipe: ${name}`, "info");
170
+ return {
171
+ content: [
172
+ textContent(
173
+ ExecutionOutput.formatToolText(
174
+ `${existing ? "Updated" : "Registered"} tool "${name}" from draft recipe.`,
175
+ ),
176
+ ),
177
+ ],
178
+ details: {
179
+ args: promoted.args,
180
+ config: targetPath,
181
+ draft: draftPath,
182
+ promoted: true,
183
+ tool: name,
184
+ } as RegisterToolResultDetails & Record<string, unknown>,
185
+ };
186
+ }
187
+
98
188
  function persistToolRecipe<TContext>(
99
189
  deps: RegisterToolRuntimeDeps<TContext>,
100
190
  cfg: Config.RegisteredTool,
@@ -284,6 +374,9 @@ export async function executeRegisterTool<TContext>(
284
374
  ExecutionOutput.formatToolText(`Reserved tool name: ${name}`),
285
375
  );
286
376
  }
377
+ if (typeof input.draft === "string" && input.draft.trim()) {
378
+ return promoteDraftRecipe(name, input, ctx, deps);
379
+ }
287
380
  const templateProvided = Object.hasOwn(input, "template");
288
381
  const template = getInputTemplate(input.template);
289
382
  if (templateProvided && (template === null || template === ""))
@@ -147,6 +147,7 @@ export function createFileRuntimeNotifier(
147
147
  mkdirSync(dirname(file), { recursive: true });
148
148
  let position =
149
149
  options.replay || !existsSync(file) ? 0 : statSync(file).size;
150
+ let pending = "";
150
151
  let closed = false;
151
152
  const reconcile = (reason: RuntimeReconcileReason): void => {
152
153
  if (closed) return;
@@ -160,10 +161,15 @@ export function createFileRuntimeNotifier(
160
161
  const drain = (): void => {
161
162
  if (closed || !existsSync(file)) return;
162
163
  const buffer = readFileSync(file);
163
- if (position > buffer.length) position = 0;
164
- const chunk = buffer.subarray(position).toString("utf8");
164
+ if (position > buffer.length) {
165
+ position = 0;
166
+ pending = "";
167
+ }
168
+ const chunk = pending + buffer.subarray(position).toString("utf8");
165
169
  position = buffer.length;
166
- for (const line of chunk.split("\n")) {
170
+ const lines = chunk.split("\n");
171
+ pending = chunk.endsWith("\n") ? "" : (lines.pop() ?? "");
172
+ for (const line of lines) {
167
173
  if (!line.trim()) continue;
168
174
  const event = parseRuntimeWakeEventLine(line);
169
175
  if (event && event.actor === actor) {