@llblab/pi-actors 0.16.3 → 0.17.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.
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Actor room persistence helpers.
3
+ * Zones: room timelines, room rosters, cross-branch discovery state
4
+ * Owns small file-backed room state; routing policy stays in tools/runtime adapters.
5
+ */
6
+
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+
10
+ import type { ActorMessage } from "./actor-messages.ts";
11
+
12
+ export interface RoomMember {
13
+ address: string;
14
+ caps?: unknown;
15
+ claim?: unknown;
16
+ joined_at: string;
17
+ last_seen: string;
18
+ parent?: unknown;
19
+ role?: unknown;
20
+ status?: string;
21
+ }
22
+
23
+ export interface RoomTimelineEntry extends ActorMessage {
24
+ received_at: string;
25
+ }
26
+
27
+ export interface RoomAppendResult {
28
+ room: string;
29
+ message_count: number;
30
+ roster_count: number;
31
+ sent: true;
32
+ }
33
+
34
+ export interface RoomMessagePreview {
35
+ body_preview?: string;
36
+ from?: string;
37
+ summary?: string;
38
+ timestamp: string;
39
+ to: string;
40
+ type: string;
41
+ }
42
+
43
+ export interface RoomStatus {
44
+ room: string;
45
+ message_count: number;
46
+ roster_count: number;
47
+ last_message_at?: string;
48
+ last_message_from?: string;
49
+ last_message_summary?: string;
50
+ last_message_type?: string;
51
+ }
52
+
53
+ export interface RoomContact {
54
+ address: string;
55
+ caps?: unknown;
56
+ claim?: unknown;
57
+ parent?: unknown;
58
+ role?: unknown;
59
+ status?: string;
60
+ }
61
+
62
+ export interface ActorCommunicationSnapshot {
63
+ contacts?: RoomContact[];
64
+ parent?: string;
65
+ root: string;
66
+ self: string;
67
+ rooms: Array<{
68
+ address: string;
69
+ members?: RoomMember[];
70
+ name: string;
71
+ }>;
72
+ updated_at: string;
73
+ }
74
+
75
+ function roomDir(stateDir: string, room: string): string {
76
+ return path.join(stateDir, "rooms", room);
77
+ }
78
+
79
+ function messagesFile(stateDir: string, room: string): string {
80
+ return path.join(roomDir(stateDir, room), "messages.jsonl");
81
+ }
82
+
83
+ function rosterFile(stateDir: string, room: string): string {
84
+ return path.join(roomDir(stateDir, room), "roster.json");
85
+ }
86
+
87
+ function snapshotFile(stateDir: string): string {
88
+ return path.join(stateDir, "communication.json");
89
+ }
90
+
91
+ function branchSnapshotFile(stateDir: string, branch: string): string {
92
+ return path.join(stateDir, "branches", branch, "communication.json");
93
+ }
94
+
95
+ function branchIdFromAddress(address: string | undefined, run: string): string | undefined {
96
+ if (!address) return undefined;
97
+ const match = new RegExp(`^branch:${run}/(.+)$`).exec(address);
98
+ return match?.[1];
99
+ }
100
+
101
+ function ensureRoomDir(stateDir: string, room: string): void {
102
+ fs.mkdirSync(roomDir(stateDir, room), { recursive: true });
103
+ }
104
+
105
+ function asRecord(value: unknown): Record<string, unknown> {
106
+ return value && typeof value === "object" && !Array.isArray(value)
107
+ ? (value as Record<string, unknown>)
108
+ : {};
109
+ }
110
+
111
+ function runFromRoomAddress(address: string): string | undefined {
112
+ const match = /^room:([^/]+)(?:\/main)?$/.exec(address);
113
+ return match?.[1];
114
+ }
115
+
116
+ function readJsonFile<T>(file: string, fallback: T): T {
117
+ try {
118
+ return JSON.parse(fs.readFileSync(file, "utf8")) as T;
119
+ } catch (error) {
120
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return fallback;
121
+ throw error;
122
+ }
123
+ }
124
+
125
+ function writeJsonFile(file: string, value: unknown): void {
126
+ fs.mkdirSync(path.dirname(file), { recursive: true });
127
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
128
+ }
129
+
130
+ export function readRoomRoster(
131
+ stateDir: string,
132
+ room: string,
133
+ ): Record<string, RoomMember> {
134
+ return readJsonFile<Record<string, RoomMember>>(rosterFile(stateDir, room), {});
135
+ }
136
+
137
+ function writeRoomRoster(
138
+ stateDir: string,
139
+ room: string,
140
+ roster: Record<string, RoomMember>,
141
+ ): void {
142
+ ensureRoomDir(stateDir, room);
143
+ writeJsonFile(rosterFile(stateDir, room), roster);
144
+ }
145
+
146
+ function updateRosterForMessage(
147
+ stateDir: string,
148
+ room: string,
149
+ message: ActorMessage,
150
+ receivedAt: string,
151
+ ): Record<string, RoomMember> {
152
+ const roster = readRoomRoster(stateDir, room);
153
+ if (!message.from) return roster;
154
+ if (message.type === "actor.leave") {
155
+ delete roster[message.from];
156
+ writeRoomRoster(stateDir, room, roster);
157
+ return roster;
158
+ }
159
+ const body = asRecord(message.body);
160
+ const current = roster[message.from];
161
+ roster[message.from] = {
162
+ address: message.from,
163
+ joined_at: current?.joined_at ?? receivedAt,
164
+ last_seen: receivedAt,
165
+ ...(body.caps !== undefined ? { caps: body.caps } : current?.caps !== undefined ? { caps: current.caps } : {}),
166
+ ...(body.claim !== undefined ? { claim: body.claim } : current?.claim !== undefined ? { claim: current.claim } : {}),
167
+ ...(body.parent !== undefined ? { parent: body.parent } : current?.parent !== undefined ? { parent: current.parent } : {}),
168
+ ...(body.role !== undefined ? { role: body.role } : current?.role !== undefined ? { role: current.role } : { role: "actor" }),
169
+ status: String(body.status ?? current?.status ?? "present"),
170
+ };
171
+ writeRoomRoster(stateDir, room, roster);
172
+ return roster;
173
+ }
174
+
175
+ export function appendRoomMessage(
176
+ stateDir: string,
177
+ room: string,
178
+ message: ActorMessage,
179
+ ): RoomAppendResult {
180
+ ensureRoomDir(stateDir, room);
181
+ const receivedAt = new Date().toISOString();
182
+ const entry: RoomTimelineEntry = { ...message, received_at: receivedAt };
183
+ fs.appendFileSync(messagesFile(stateDir, room), `${JSON.stringify(entry)}\n`);
184
+ const roster = updateRosterForMessage(stateDir, room, message, receivedAt);
185
+ const run = runFromRoomAddress(message.to);
186
+ if (run) {
187
+ writeCommunicationSnapshot(stateDir, run);
188
+ if (message.from && branchIdFromAddress(message.from, run)) {
189
+ writeBranchCommunicationSnapshot(stateDir, run, message.from);
190
+ }
191
+ }
192
+ return {
193
+ message_count: readRoomMessages(stateDir, room).length,
194
+ room,
195
+ roster_count: Object.keys(roster).length,
196
+ sent: true,
197
+ };
198
+ }
199
+
200
+ export function readRoomMessages(
201
+ stateDir: string,
202
+ room: string,
203
+ limit = 40,
204
+ ): RoomTimelineEntry[] {
205
+ try {
206
+ const lines = fs
207
+ .readFileSync(messagesFile(stateDir, room), "utf8")
208
+ .split("\n")
209
+ .filter(Boolean);
210
+ return lines.slice(-Math.max(1, limit)).map((line) => JSON.parse(line));
211
+ } catch (error) {
212
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
213
+ throw error;
214
+ }
215
+ }
216
+
217
+ function previewValue(value: unknown, maxLength = 120): string | undefined {
218
+ if (value === undefined) return undefined;
219
+ const text = typeof value === "string" ? value : JSON.stringify(value);
220
+ const compact = text.replaceAll(/\s+/g, " ").trim();
221
+ if (!compact) return undefined;
222
+ return compact.length > maxLength
223
+ ? `${compact.slice(0, Math.max(0, maxLength - 1))}…`
224
+ : compact;
225
+ }
226
+
227
+ export function readRoomMessagePreviews(
228
+ stateDir: string,
229
+ room: string,
230
+ limit = 40,
231
+ ): RoomMessagePreview[] {
232
+ return readRoomMessages(stateDir, room, limit).map((message) => ({
233
+ ...(previewValue(message.body) ? { body_preview: previewValue(message.body) } : {}),
234
+ ...(message.from ? { from: message.from } : {}),
235
+ ...(message.summary ? { summary: message.summary } : {}),
236
+ timestamp: message.received_at,
237
+ to: message.to,
238
+ type: message.type,
239
+ }));
240
+ }
241
+
242
+ export function getRoomStatus(stateDir: string, room: string): RoomStatus {
243
+ const messages = readRoomMessages(stateDir, room, Number.MAX_SAFE_INTEGER);
244
+ const last = messages[messages.length - 1];
245
+ return {
246
+ ...(last
247
+ ? {
248
+ last_message_at: last.received_at,
249
+ ...(last.from ? { last_message_from: last.from } : {}),
250
+ ...(last.summary ? { last_message_summary: last.summary } : {}),
251
+ last_message_type: last.type,
252
+ }
253
+ : {}),
254
+ message_count: messages.length,
255
+ room,
256
+ roster_count: Object.keys(readRoomRoster(stateDir, room)).length,
257
+ };
258
+ }
259
+
260
+ export function ensureRoomMember(
261
+ stateDir: string,
262
+ run: string,
263
+ room: string,
264
+ address: string,
265
+ body: Record<string, unknown>,
266
+ summary: string,
267
+ ): RoomAppendResult {
268
+ const roster = readRoomRoster(stateDir, room);
269
+ if (roster[address]) {
270
+ return {
271
+ message_count: readRoomMessages(stateDir, room).length,
272
+ room,
273
+ roster_count: Object.keys(roster).length,
274
+ sent: true,
275
+ };
276
+ }
277
+ return appendRoomMessage(stateDir, room, {
278
+ body,
279
+ from: address,
280
+ summary,
281
+ to: `room:${run}`,
282
+ type: "actor.join",
283
+ });
284
+ }
285
+
286
+ export function ensureDefaultRoom(stateDir: string, run: string): RoomAppendResult {
287
+ return ensureRoomMember(
288
+ stateDir,
289
+ run,
290
+ "main",
291
+ `run:${run}`,
292
+ { role: "run", status: "present" },
293
+ "Run joined default room",
294
+ );
295
+ }
296
+
297
+ export function readCommunicationSnapshot(
298
+ stateDir: string,
299
+ ): ActorCommunicationSnapshot | undefined {
300
+ try {
301
+ return JSON.parse(
302
+ fs.readFileSync(snapshotFile(stateDir), "utf8"),
303
+ ) as ActorCommunicationSnapshot;
304
+ } catch (error) {
305
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
306
+ throw error;
307
+ }
308
+ }
309
+
310
+ export function readRoomContacts(
311
+ stateDir: string,
312
+ room: string,
313
+ self?: string,
314
+ ): RoomContact[] {
315
+ return Object.values(readRoomRoster(stateDir, room))
316
+ .filter((member) => member.address !== self)
317
+ .map((member) => ({
318
+ address: member.address,
319
+ ...(member.caps !== undefined ? { caps: member.caps } : {}),
320
+ ...(member.claim !== undefined ? { claim: member.claim } : {}),
321
+ ...(member.parent !== undefined ? { parent: member.parent } : {}),
322
+ ...(member.role !== undefined ? { role: member.role } : {}),
323
+ ...(member.status !== undefined ? { status: member.status } : {}),
324
+ }));
325
+ }
326
+
327
+ function buildCommunicationSnapshot(
328
+ stateDir: string,
329
+ run: string,
330
+ self: string,
331
+ ): ActorCommunicationSnapshot {
332
+ const mainRoster = Object.values(readRoomRoster(stateDir, "main"));
333
+ const contacts = readRoomContacts(stateDir, "main", self);
334
+ return {
335
+ ...(contacts.length > 0 ? { contacts } : {}),
336
+ ...(self !== `run:${run}` ? { parent: `run:${run}` } : {}),
337
+ root: `run:${run}`,
338
+ self,
339
+ rooms: [
340
+ {
341
+ address: `room:${run}`,
342
+ ...(mainRoster.length > 0 ? { members: mainRoster } : {}),
343
+ name: "main",
344
+ },
345
+ ],
346
+ updated_at: new Date().toISOString(),
347
+ };
348
+ }
349
+
350
+ export function writeCommunicationSnapshot(
351
+ stateDir: string,
352
+ run: string,
353
+ ): ActorCommunicationSnapshot {
354
+ const snapshot = buildCommunicationSnapshot(stateDir, run, `run:${run}`);
355
+ writeJsonFile(snapshotFile(stateDir), snapshot);
356
+ return snapshot;
357
+ }
358
+
359
+ export function writeBranchCommunicationSnapshot(
360
+ stateDir: string,
361
+ run: string,
362
+ self: string,
363
+ ): ActorCommunicationSnapshot {
364
+ const branch = branchIdFromAddress(self, run);
365
+ if (!branch) throw new Error(`Expected branch:${run}/<branch>; got ${self}`);
366
+ const snapshot = buildCommunicationSnapshot(stateDir, run, self);
367
+ writeJsonFile(branchSnapshotFile(stateDir, branch), snapshot);
368
+ return snapshot;
369
+ }
package/lib/async-runs.ts CHANGED
@@ -296,6 +296,7 @@ function prepareStateDirForStart(stateDir: string): void {
296
296
  }
297
297
  for (const file of [
298
298
  "events.jsonl",
299
+ "inbox.jsonl",
299
300
  "outbox.jsonl",
300
301
  "progress.json",
301
302
  "result.json",
@@ -331,6 +332,9 @@ export function startRun(
331
332
  const argv = ["--experimental-strip-types", RUNNER_PATH, stateDir];
332
333
  const values = {
333
334
  ...(startParams.values || {}),
335
+ actor_address: `run:${run}`,
336
+ communication_file: join(stateDir, "communication.json"),
337
+ default_room: `room:${run}`,
334
338
  run_id: run,
335
339
  state_dir: stateDir,
336
340
  };
@@ -481,6 +485,7 @@ export function getRunStatus(runOrDir: string): Record<string, unknown> {
481
485
  return {
482
486
  ...meta,
483
487
  eventsFile: join(stateDir, "events.jsonl"),
488
+ inboxFile: join(stateDir, "inbox.jsonl"),
484
489
  outboxFile: join(stateDir, "outbox.jsonl"),
485
490
  progress: readJson(join(stateDir, "progress.json")) || null,
486
491
  result: result || null,
@@ -632,11 +637,22 @@ export function sendRunMessage(
632
637
  const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(
633
638
  trimmedMessage,
634
639
  );
640
+ const ts = new Date().toISOString();
635
641
  writeFileSync(
636
642
  join(stateDir, "events.jsonl"),
637
- `${JSON.stringify({ bytes, event: "run.message", terminal: terminalMessage || undefined, ts: new Date().toISOString() })}\n`,
643
+ `${JSON.stringify({ bytes, event: "run.message", terminal: terminalMessage || undefined, ts })}\n`,
638
644
  { flag: "a" },
639
645
  );
646
+ try {
647
+ const envelope = JSON.parse(message) as Record<string, unknown>;
648
+ writeFileSync(
649
+ join(stateDir, "inbox.jsonl"),
650
+ `${JSON.stringify({ ...envelope, received_at: ts })}\n`,
651
+ { flag: "a" },
652
+ );
653
+ } catch {
654
+ // Plain control lines are already represented in events.jsonl.
655
+ }
640
656
  if (terminalMessage) {
641
657
  markTerminalHandled(stateDir, {
642
658
  event: "run.message",
package/lib/config.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Persistent tool registry config helpers
3
3
  * Zones: registry config, persistence, migration boundary
4
- * Owns actors-tools.json loading, normalization, unsupported-shape rejection, and serialization
4
+ * Owns registered-tool config loading, normalization, unsupported-shape rejection, and serialization
5
5
  */
6
6
 
7
7
  import { existsSync, readFileSync } from "node:fs";
package/lib/paths.ts CHANGED
@@ -17,7 +17,7 @@ export function getAgentDir(
17
17
  }
18
18
 
19
19
  export function getConfigPath(agentDir = getAgentDir()): string {
20
- return join(agentDir, "actors-tools.json");
20
+ return join(agentDir, "legacy-tool-registry.json");
21
21
  }
22
22
 
23
23
  export function getExtensionTmpDir(
package/lib/prompts.ts CHANGED
@@ -24,11 +24,12 @@ export const ONBOARDING_SYSTEM_PROMPT = `pi-actors quick model:
24
24
  - Layers: task -> command template -> recipe/tool -> spawn -> run:<id>; tool:<name> wraps registered capabilities.
25
25
  - Command templates stay sync: string leaf, array sequence, object node; flags include args/defaults, parallel, when, timeout, delay, retry, failure, recover, repeat, output.
26
26
  - Placeholders support typed/default args plus {value??fallback} and {flag?yes:no}.
27
- - Recipes live in ~/.pi/agent/recipes/*.json, own template directly, and may declare metadata/defaults/imports/mailbox/artifacts.
27
+ - ~/.pi/agent/recipes/*.json is actor muscle memory: every recipe there is auto-registered as an agent tool across sessions; register_tool writes there.
28
+ - Recipes own template directly and may declare metadata/defaults/imports/mailbox/artifacts.
28
29
  - Recipe imports are local variables; imported recipes are definitions, not nested async runs; parent async:true creates one run.
29
30
  - Use spawn/message/inspect for actor-level start/send/observe; avoid runtime/FIFO/outbox vocabulary in public guidance.
30
31
  - Run state lives under ~/.pi/agent/tmp/pi-actors/runs; inspect status/tail/messages/mailbox/files/artifacts intentionally and avoid busy-polling.
31
- - Register_tool writes user recipe files in ~/.pi/agent/recipes; that root is the default tool set, while packaged recipes are the lower-priority standard library.
32
+ - Maintain ~/.pi/agent/recipes like MEMORY.md for capabilities: keep useful tools, curate stale ones; packaged recipes are lower-priority components, not tools by location.
32
33
  - Foreground tools/templates fit short work; async recipes/runs fit subagents, services, fanout, media, and long pipelines.
33
34
  - Long fanout = parent async recipe wrapping template(parallel:true) and imports; packaged fanout recipes bubble branch completion messages.
34
35
  - For deeper pi-actors guidance, inspect installed extension sources/docs/recipes; README and docs are not automatically in context.`;
@@ -74,7 +74,7 @@ function listRecipeFiles(root: string): string[] {
74
74
  (entry) =>
75
75
  entry.isFile() &&
76
76
  entry.name.endsWith(".json") &&
77
- entry.name !== "actors-tools-migration-report.json",
77
+ entry.name !== "legacy-tool-registry-migration-report.json",
78
78
  )
79
79
  .map((entry) => join(root, entry.name))
80
80
  .sort();
@@ -192,7 +192,87 @@ export function discoverRecipes(roots: string[]): RecipeDiscoveryResult {
192
192
  return discoverRecipeSources(roots.map((root) => ({ root })));
193
193
  }
194
194
 
195
+ function recipeUsage(config: TemplateRecipeConfig | undefined): Record<string, unknown> | undefined {
196
+ const usage = (config as { usage?: unknown } | undefined)?.usage;
197
+ return usage && typeof usage === "object" && !Array.isArray(usage)
198
+ ? (usage as Record<string, unknown>)
199
+ : undefined;
200
+ }
201
+
202
+ function cleanupRecommendation(entry: DiscoveredRecipe): Record<string, unknown> | undefined {
203
+ if (entry.invalid) {
204
+ return {
205
+ id: entry.id,
206
+ path: entry.path,
207
+ reason: "invalid recipe blocks lower-priority entries with the same id",
208
+ actions: ["fix", "delete", "archive"],
209
+ };
210
+ }
211
+ if (entry.shadowed) {
212
+ return {
213
+ id: entry.id,
214
+ path: entry.path,
215
+ reason: "shadowed by a higher-priority recipe",
216
+ actions: ["merge", "delete", "archive"],
217
+ };
218
+ }
219
+ if (entry.disabled) {
220
+ return {
221
+ id: entry.id,
222
+ path: entry.path,
223
+ reason: "disabled recipe is retained but not exposed as a tool",
224
+ actions: ["keep disabled", "delete", "archive"],
225
+ };
226
+ }
227
+ const usage = recipeUsage(entry.config);
228
+ const calls = Number(usage?.calls ?? 0);
229
+ if (entry.mutableUsage && entry.tool && calls === 0) {
230
+ return {
231
+ id: entry.id,
232
+ path: entry.path,
233
+ reason: "active user tool has no recorded launches",
234
+ actions: ["keep as tool", "set tool false", "delete", "archive"],
235
+ };
236
+ }
237
+ if (entry.mutableUsage && !entry.tool) {
238
+ return {
239
+ id: entry.id,
240
+ path: entry.path,
241
+ reason: "user recipe is a component, not an active tool",
242
+ actions: ["keep component", "enable tool", "merge", "delete", "archive"],
243
+ };
244
+ }
245
+ if (entry.shadows.length > 0) {
246
+ return {
247
+ id: entry.id,
248
+ path: entry.path,
249
+ reason: `overrides ${entry.shadows.length} lower-priority recipe(s)`,
250
+ actions: ["keep override", "merge", "delete", "archive"],
251
+ };
252
+ }
253
+ return undefined;
254
+ }
255
+
256
+ function recommendationForEntry(
257
+ entry: DiscoveredRecipe,
258
+ activePath: string | undefined,
259
+ ): Record<string, unknown> | undefined {
260
+ const recommendation = cleanupRecommendation(entry);
261
+ if (!recommendation) return undefined;
262
+ if (entry.shadowed && activePath) {
263
+ return {
264
+ ...recommendation,
265
+ reason: `shadowed by ${activePath}`,
266
+ };
267
+ }
268
+ return recommendation;
269
+ }
270
+
195
271
  export function summarizeDiscovery(result: RecipeDiscoveryResult): Record<string, unknown> {
272
+ const recommendations = result.entries
273
+ .map((entry) => recommendationForEntry(entry, result.active.get(entry.id)?.path))
274
+ .filter((entry): entry is Record<string, unknown> => Boolean(entry))
275
+ .sort((a, b) => String(a.id).localeCompare(String(b.id)) || String(a.path).localeCompare(String(b.path)));
196
276
  return {
197
277
  active: [...result.active.values()].map((entry) => ({
198
278
  id: entry.id,
@@ -202,6 +282,7 @@ export function summarizeDiscovery(result: RecipeDiscoveryResult): Record<string
202
282
  disabled: entry.disabled,
203
283
  invalid: entry.invalid,
204
284
  shadows: entry.shadows,
285
+ ...(recipeUsage(entry.config) ? { usage: recipeUsage(entry.config) } : {}),
205
286
  })).sort((a, b) => a.id.localeCompare(b.id)),
206
287
  shadowed: result.entries
207
288
  .filter((entry) => entry.shadowed)
@@ -215,6 +296,7 @@ export function summarizeDiscovery(result: RecipeDiscoveryResult): Record<string
215
296
  .filter((entry) => entry.disabled)
216
297
  .map((entry) => ({ id: entry.id, path: entry.path }))
217
298
  .sort((a, b) => a.id.localeCompare(b.id)),
299
+ recommendations,
218
300
  diagnostics: result.diagnostics,
219
301
  };
220
302
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Legacy actors-tools.json to recipe-file migration helpers
2
+ * Legacy tool-registry to recipe-file migration helpers
3
3
  * Zones: registry migration, recipe persistence, compatibility diagnostics
4
4
  * Owns one-way migration from legacy tool registry entries into user recipe files
5
5
  */
@@ -98,7 +98,7 @@ export function migrateLegacyToolRegistry(
98
98
 
99
99
  if (loaded.tools.size === 0) result.skipped.push(basename(configPath));
100
100
 
101
- const reportPath = join(recipeRoot, "actors-tools-migration-report.json");
101
+ const reportPath = join(recipeRoot, "legacy-tool-registry-migration-report.json");
102
102
  writeJsonAtomic(reportPath, {
103
103
  source: configPath,
104
104
  migrated: result.migrated,
@@ -50,6 +50,7 @@ export interface TemplateRecipeDefinition {
50
50
  recover?: CommandTemplateValue;
51
51
  repeat?: number;
52
52
  values?: Record<string, unknown>;
53
+ usage?: Record<string, unknown>;
53
54
  }
54
55
 
55
56
  export interface TemplateRecipeConfig extends TemplateRecipeDefinition {
@@ -597,6 +598,7 @@ export function readResolvedRecipeConfig(
597
598
  ? { repeat: substituted.repeat }
598
599
  : {}),
599
600
  ...(isRecord(substituted.values) ? { values: substituted.values } : {}),
601
+ ...(isRecord(substituted.usage) ? { usage: substituted.usage } : {}),
600
602
  };
601
603
  }
602
604
 
@@ -4,6 +4,7 @@
4
4
  * Owns lightweight launch counters for user-owned recipe files
5
5
  */
6
6
 
7
+ import { createHash } from "node:crypto";
7
8
  import { existsSync, readFileSync } from "node:fs";
8
9
 
9
10
  import { writeJsonAtomic } from "./file-state.ts";
@@ -11,6 +12,8 @@ import { writeJsonAtomic } from "./file-state.ts";
11
12
  interface RecipeUsageRecord {
12
13
  calls?: number;
13
14
  last_called?: string;
15
+ fingerprint?: string;
16
+ reset_at?: string;
14
17
  }
15
18
 
16
19
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -23,18 +26,37 @@ function normalizeCalls(value: unknown): number {
23
26
  : 0;
24
27
  }
25
28
 
29
+ function stableStringify(value: unknown): string {
30
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
31
+ if (!isRecord(value)) return JSON.stringify(value);
32
+ return `{${Object.keys(value)
33
+ .sort()
34
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
35
+ .join(",")}}`;
36
+ }
37
+
38
+ function getRecipeFingerprint(raw: Record<string, unknown>): string {
39
+ const { usage: _usage, ...content } = raw;
40
+ return createHash("sha256").update(stableStringify(content)).digest("hex");
41
+ }
42
+
26
43
  export function recordRecipeLaunch(path: string, now = new Date()): boolean {
27
44
  if (!existsSync(path)) return false;
28
45
  try {
29
46
  const raw = JSON.parse(readFileSync(path, "utf8"));
30
47
  if (!isRecord(raw)) return false;
31
48
  const usage: RecipeUsageRecord = isRecord(raw.usage) ? raw.usage : {};
49
+ const fingerprint = getRecipeFingerprint(raw);
50
+ const changed = typeof usage.fingerprint === "string" && usage.fingerprint !== fingerprint;
51
+ const nowIso = now.toISOString();
32
52
  writeJsonAtomic(path, {
33
53
  ...raw,
34
54
  usage: {
35
55
  ...usage,
36
- calls: normalizeCalls(usage.calls) + 1,
37
- last_called: now.toISOString(),
56
+ calls: (changed ? 0 : normalizeCalls(usage.calls)) + 1,
57
+ last_called: nowIso,
58
+ fingerprint,
59
+ ...(changed ? { reset_at: nowIso } : {}),
38
60
  },
39
61
  });
40
62
  return true;