agent-procedures 0.3.0 → 0.3.2

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.
package/dist/runtime.js CHANGED
@@ -1,4 +1,4 @@
1
- // Generated by agent-procedures@0.3.0. Do not edit.
1
+ // Generated by agent-procedures@0.3.2. Do not edit.
2
2
  // To upgrade: npx agent-procedures init
3
3
 
4
4
  // lib/main.js
@@ -9,16 +9,59 @@ import { fileURLToPath } from "node:url";
9
9
  // lib/store.js
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
+ var GROOM_LOCK_STALE_MS = 15 * 60 * 1e3;
12
13
  function paths(reflexDir) {
13
14
  return {
14
15
  reflexDir,
15
16
  tracesDir: path.join(reflexDir, "traces"),
16
- proceduresFile: path.join(reflexDir, "procedures.jsonl")
17
+ proceduresFile: path.join(reflexDir, "procedures.jsonl"),
18
+ pendingFile: path.join(reflexDir, "pending.jsonl"),
19
+ processingFile: path.join(reflexDir, "processing.jsonl"),
20
+ groomerLockFile: path.join(reflexDir, "groomer.lock")
17
21
  };
18
22
  }
19
23
  function readJsonl(file) {
20
24
  if (!fs.existsSync(file)) return [];
21
- return fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
25
+ return fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((l) => {
26
+ try {
27
+ return JSON.parse(l);
28
+ } catch {
29
+ return null;
30
+ }
31
+ }).filter(Boolean);
32
+ }
33
+ function sleepSync(ms) {
34
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
35
+ }
36
+ function renameWithRetry(from, to, attempts = 8) {
37
+ for (let i = 0; i < attempts; i++) {
38
+ try {
39
+ fs.renameSync(from, to);
40
+ return;
41
+ } catch (e) {
42
+ const retryable = e.code === "EBUSY" || e.code === "EPERM" || e.code === "EACCES";
43
+ if (!retryable || i === attempts - 1) throw e;
44
+ sleepSync(15 * (i + 1));
45
+ }
46
+ }
47
+ }
48
+ function unlinkWithRetry(file, attempts = 8) {
49
+ for (let i = 0; i < attempts; i++) {
50
+ try {
51
+ if (fs.existsSync(file)) fs.unlinkSync(file);
52
+ return;
53
+ } catch (e) {
54
+ const retryable = e.code === "EBUSY" || e.code === "EPERM" || e.code === "EACCES";
55
+ if (!retryable || i === attempts - 1) throw e;
56
+ sleepSync(15 * (i + 1));
57
+ }
58
+ }
59
+ }
60
+ function writeJsonlAtomic(file, nodes) {
61
+ const content = nodes.length ? nodes.map((n) => JSON.stringify(n)).join("\n") + "\n" : "";
62
+ const tmp = file + ".tmp";
63
+ fs.writeFileSync(tmp, content, "utf8");
64
+ renameWithRetry(tmp, file);
22
65
  }
23
66
  function traceFile(reflexDir, sessionId, promptId) {
24
67
  return path.join(paths(reflexDir).tracesDir, sessionId, `${promptId}.jsonl`);
@@ -39,17 +82,326 @@ function readTrace(reflexDir, sessionId, promptId) {
39
82
  function deleteTrace(reflexDir, sessionId, promptId) {
40
83
  try {
41
84
  fs.unlinkSync(traceFile(reflexDir, sessionId, promptId));
42
- } catch (e) {
85
+ } catch {
43
86
  }
44
87
  }
45
88
  function readProcedures(reflexDir) {
46
89
  return readJsonl(paths(reflexDir).proceduresFile);
47
90
  }
48
- function appendProcedure(reflexDir, node) {
49
- const file = paths(reflexDir).proceduresFile;
50
- if (!fs.existsSync(file)) fs.writeFileSync(file, "", "utf8");
91
+ function readPending(reflexDir) {
92
+ return readJsonl(paths(reflexDir).pendingFile);
93
+ }
94
+ function readProcessing(reflexDir) {
95
+ return readJsonl(paths(reflexDir).processingFile);
96
+ }
97
+ function readAllProcedures(reflexDir) {
98
+ return [
99
+ ...readProcedures(reflexDir),
100
+ ...readPending(reflexDir),
101
+ ...readProcessing(reflexDir)
102
+ ];
103
+ }
104
+ function countIngest(reflexDir) {
105
+ return readPending(reflexDir).length + readProcessing(reflexDir).length;
106
+ }
107
+ function appendPending(reflexDir, node) {
108
+ const file = paths(reflexDir).pendingFile;
109
+ fs.mkdirSync(reflexDir, { recursive: true });
51
110
  fs.appendFileSync(file, JSON.stringify(node) + "\n", "utf8");
52
111
  }
112
+ function migrateLegacyPending(reflexDir) {
113
+ const p = paths(reflexDir);
114
+ if (!fs.existsSync(p.proceduresFile)) return { moved: 0 };
115
+ const nodes = readJsonl(p.proceduresFile);
116
+ const pending = nodes.filter((n) => n.status === "pending_review");
117
+ if (pending.length === 0) return { moved: 0 };
118
+ const stable = nodes.filter((n) => n.status !== "pending_review");
119
+ for (const node of pending) appendPending(reflexDir, node);
120
+ writeJsonlAtomic(p.proceduresFile, stable);
121
+ return { moved: pending.length };
122
+ }
123
+ function pidAlive(pid) {
124
+ if (!Number.isInteger(pid) || pid <= 0) return false;
125
+ try {
126
+ process.kill(pid, 0);
127
+ return true;
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+ function readLock(lockFile) {
133
+ try {
134
+ return JSON.parse(fs.readFileSync(lockFile, "utf8"));
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+ function isGroomerLocked(reflexDir, now = Date.now()) {
140
+ const lockFile = paths(reflexDir).groomerLockFile;
141
+ if (!fs.existsSync(lockFile)) return false;
142
+ const lock = readLock(lockFile);
143
+ if (!lock) return false;
144
+ const age = typeof lock.startedAt === "number" ? now - lock.startedAt : Infinity;
145
+ if (age >= GROOM_LOCK_STALE_MS) return false;
146
+ return pidAlive(lock.pid);
147
+ }
148
+ function tryAcquireGroomerLock(reflexDir, now = Date.now()) {
149
+ const lockFile = paths(reflexDir).groomerLockFile;
150
+ fs.mkdirSync(reflexDir, { recursive: true });
151
+ if (fs.existsSync(lockFile)) {
152
+ if (isGroomerLocked(reflexDir, now)) return false;
153
+ try {
154
+ fs.unlinkSync(lockFile);
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+ try {
160
+ const fd = fs.openSync(lockFile, "wx");
161
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, startedAt: now }) + "\n", "utf8");
162
+ fs.closeSync(fd);
163
+ return true;
164
+ } catch (e) {
165
+ if (e.code === "EEXIST") return false;
166
+ throw e;
167
+ }
168
+ }
169
+ function releaseGroomerLock(reflexDir) {
170
+ const lockFile = paths(reflexDir).groomerLockFile;
171
+ try {
172
+ const lock = readLock(lockFile);
173
+ if (lock && lock.pid !== process.pid) return;
174
+ fs.unlinkSync(lockFile);
175
+ } catch {
176
+ }
177
+ }
178
+ function claimPendingBatch(reflexDir) {
179
+ const { pendingFile, processingFile } = paths(reflexDir);
180
+ if (fs.existsSync(processingFile)) return processingFile;
181
+ if (!fs.existsSync(pendingFile)) return null;
182
+ const stat = fs.statSync(pendingFile);
183
+ if (stat.size === 0) {
184
+ unlinkWithRetry(pendingFile);
185
+ return null;
186
+ }
187
+ renameWithRetry(pendingFile, processingFile);
188
+ return processingFile;
189
+ }
190
+
191
+ // lib/sanitize.js
192
+ var PROVIDER_RULES = [
193
+ ["anthropic-api-key", /\b(sk-ant-api03-[a-zA-Z0-9_-]{93}AA)(?:[\x60'"\s;]|\\[nr]|$)/],
194
+ ["aws-access-token", /\b((?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\b/],
195
+ ["cloudflare-api-key", /[\w.-]{0,50}?(?:cloudflare)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9_-]{40})(?:[\x60'"\s;]|\\[nr]|$)/i],
196
+ ["datadog-access-token", /[\w.-]{0,50}?(?:datadog)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{40})(?:[\x60'"\s;]|\\[nr]|$)/i],
197
+ ["digitalocean-access-token", /\b(doo_v1_[a-f0-9]{64})(?:[\x60'"\s;]|\\[nr]|$)/],
198
+ ["discord-api-token", /[\w.-]{0,50}?(?:discord)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-f0-9]{64})(?:[\x60'"\s;]|\\[nr]|$)/i],
199
+ ["dropbox-short-lived-api-token", /[\w.-]{0,50}?(?:dropbox)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(sl\.[a-z0-9\-=_]{135})(?:[\x60'"\s;]|\\[nr]|$)/i],
200
+ ["gcp-api-key", /\b(AIza[\w-]{35})(?:[\x60'"\s;]|\\[nr]|$)/],
201
+ ["github-pat", /ghp_[0-9a-zA-Z]{36}/],
202
+ ["gitlab-pat", /glpat-[\w-]{20}/],
203
+ ["heroku-api-key-v2", /\b((HRKU-AA[0-9a-zA-Z_-]{58}))(?:[\x60'"\s;]|\\[nr]|$)/],
204
+ ["mailgun-private-api-token", /[\w.-]{0,50}?(?:mailgun)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(key-[a-f0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)/i],
205
+ ["npm-access-token", /\b(npm_[a-z0-9]{36})(?:[\x60'"\s;]|\\[nr]|$)/i],
206
+ ["openai-api-key", /\b(sk-(?:proj|svcacct|admin)-(?:[A-Za-z0-9_-]{74}|[A-Za-z0-9_-]{58})T3BlbkFJ(?:[A-Za-z0-9_-]{74}|[A-Za-z0-9_-]{58})\b|sk-[a-zA-Z0-9]{20}T3BlbkFJ[a-zA-Z0-9]{20})(?:[\x60'"\s;]|\\[nr]|$)/],
207
+ ["pypi-upload-token", /pypi-AgEIcHlwaS5vcmc[\w-]{50,1000}/],
208
+ ["sendgrid-api-token", /\b(SG\.[a-z0-9=_\-.]{66})(?:[\x60'"\s;]|\\[nr]|$)/i],
209
+ ["shopify-access-token", /shpat_[a-fA-F0-9]{32}/],
210
+ ["slack-bot-token", /xoxb-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*/],
211
+ ["stripe-access-token", /\b((?:sk|rk)_(?:test|live|prod)_[a-zA-Z0-9]{10,99})(?:[\x60'"\s;]|\\[nr]|$)/],
212
+ ["twilio-api-key", /SK[0-9a-fA-F]{32}/],
213
+ ["private-key", /-----BEGIN[ A-Z0-9_-]{0,100}PRIVATE KEY(?: BLOCK)?-----[\s\S-]{64,}?KEY(?: BLOCK)?-----/i],
214
+ ["jwt", /\b(ey[a-zA-Z0-9]{17,}\.ey[a-zA-Z0-9/\\_-]{17,}\.(?:[a-zA-Z0-9/\\_-]{10,}={0,2})?)(?:[\x60'"\s;]|\\[nr]|$)/]
215
+ ];
216
+ var SENSITIVE_NAME = /(?:^|_)(?:API_?)?(?:KEY|TOKEN|SECRET|PASS|PASSWORD|AUTH|CREDENTIALS?|DATABASE_URL|DB_URL|CONNECTION_STRING|DSN)(?:_|$)/i;
217
+ var ASSIGNMENT = /\b(export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"]*)"|'([^']*)'|([^\s;&|"']+))(\s*)/g;
218
+ var CREDENTIAL_URL = /^[a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:[^@\s/]+@/i;
219
+ function result(text, requiresEnv = [], unsafe = false) {
220
+ return { text, requiresEnv: [...new Set(requiresEnv)], unsafe };
221
+ }
222
+ function replaceDetectedSecrets(input) {
223
+ let text = input;
224
+ let unsafe = false;
225
+ for (const [id, regex] of PROVIDER_RULES) {
226
+ let match;
227
+ while (match = regex.exec(text)) {
228
+ const secret = match[1] || match[0];
229
+ const offset = match[0].indexOf(secret);
230
+ const start = match.index + offset;
231
+ text = text.slice(0, start) + `[secret:${id}]` + text.slice(start + secret.length);
232
+ unsafe = true;
233
+ }
234
+ }
235
+ return { text, unsafe };
236
+ }
237
+ function sanitizeText(input = "") {
238
+ const detected = replaceDetectedSecrets(String(input));
239
+ return result(detected.text, [], detected.unsafe);
240
+ }
241
+ function sanitizeCommand(input = "") {
242
+ let text = String(input);
243
+ const requiresEnv = [];
244
+ let removedAssignment = false;
245
+ text = text.replace(ASSIGNMENT, (match, exported, name, double, single, bare, trailing, offset, source) => {
246
+ const value = double ?? single ?? bare ?? "";
247
+ const valueIsSecret = SENSITIVE_NAME.test(name) || CREDENTIAL_URL.test(value) || PROVIDER_RULES.some(([, regex]) => regex.test(value));
248
+ if (!valueIsSecret) return match;
249
+ requiresEnv.push(name);
250
+ const before = source.slice(0, offset);
251
+ const isCommandAssignment = exported || !before.trim() || /(?:&&|\|\||;)\s*$/.test(before);
252
+ if (isCommandAssignment) {
253
+ removedAssignment = true;
254
+ return "";
255
+ }
256
+ const placeholder = bare !== void 0 ? `\${${name}}` : `"\${${name}}"`;
257
+ return `${name}=${placeholder}${trailing}`;
258
+ });
259
+ text = text.replace(
260
+ /'(\bAuthorization\s*:\s*Bearer\s+)([^']+)'/gi,
261
+ (_match, prefix) => {
262
+ requiresEnv.push("AUTH_TOKEN");
263
+ return `"${prefix}\${AUTH_TOKEN}"`;
264
+ }
265
+ );
266
+ text = text.replace(
267
+ /'(\bAuthorization\s*:\s*Basic\s+)([^']+)'/gi,
268
+ (_match, prefix) => {
269
+ requiresEnv.push("BASIC_AUTH");
270
+ return `"${prefix}\${BASIC_AUTH}"`;
271
+ }
272
+ );
273
+ text = text.replace(
274
+ /'([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)([^@']+)(@[^']+)'/gi,
275
+ (_match, prefix, _password, suffix) => {
276
+ requiresEnv.push("PASSWORD");
277
+ return `"${prefix}\${PASSWORD}${suffix}"`;
278
+ }
279
+ );
280
+ text = text.replace(
281
+ /(\bAuthorization\s*:\s*Bearer\s+)([^"'\s]+)/gi,
282
+ (_match, prefix) => {
283
+ requiresEnv.push("AUTH_TOKEN");
284
+ return `${prefix}\${AUTH_TOKEN}`;
285
+ }
286
+ );
287
+ text = text.replace(
288
+ /(\bAuthorization\s*:\s*Basic\s+)([^"'\s]+)/gi,
289
+ (_match, prefix) => {
290
+ requiresEnv.push("BASIC_AUTH");
291
+ return `${prefix}\${BASIC_AUTH}`;
292
+ }
293
+ );
294
+ text = text.replace(
295
+ /([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)([^@\s/]+)(@)/gi,
296
+ (_match, prefix, _password, suffix) => {
297
+ requiresEnv.push("PASSWORD");
298
+ return `${prefix}\${PASSWORD}${suffix}`;
299
+ }
300
+ );
301
+ text = text.replace(
302
+ /(--(?:password|passwd|token|secret|api-key|key)(?:=|\s+))(["']?)([^"'\s;&|]+)\2/gi,
303
+ (_match, prefix, quote) => {
304
+ const name = prefix.match(/^--([a-z-]+)/i)[1].replace(/-/g, "_").toUpperCase();
305
+ requiresEnv.push(name);
306
+ const safeQuote = quote === "'" ? '"' : quote;
307
+ return `${prefix}${safeQuote}\${${name}}${safeQuote}`;
308
+ }
309
+ );
310
+ if (removedAssignment) {
311
+ text = text.replace(/^\s*(?:&&|\|\||;)\s*/, "").replace(/\s*(?:&&|\|\||;)\s*$/, "").replace(/(?:&&|\|\||;)\s*(?=(?:&&|\|\||;))/g, "").trim();
312
+ }
313
+ const detected = replaceDetectedSecrets(text);
314
+ return result(detected.text, requiresEnv, detected.unsafe);
315
+ }
316
+
317
+ // lib/recall.js
318
+ var STOP_WORDS = /* @__PURE__ */ new Set([
319
+ "the",
320
+ "a",
321
+ "an",
322
+ "to",
323
+ "in",
324
+ "on",
325
+ "please",
326
+ "can",
327
+ "you",
328
+ "my",
329
+ "this",
330
+ "for",
331
+ "with"
332
+ ]);
333
+ function tokenize(text) {
334
+ if (!text) return [];
335
+ return text.toLowerCase().replace(/[^\w\s]+/g, " ").split(/\s+/).filter((w) => w.length > 1 && !STOP_WORDS.has(w));
336
+ }
337
+ function scoreTarget(promptTokens, targetString) {
338
+ const targetTokens = tokenize(targetString);
339
+ if (targetTokens.length === 0) return { coverage: 0, matchCount: 0, targetLen: 0 };
340
+ let matchCount = 0;
341
+ let hasHighValueWord = false;
342
+ for (const tWord of targetTokens) {
343
+ const isMatch = promptTokens.some(
344
+ (pWord) => pWord === tWord || tWord.length >= 3 && pWord.startsWith(tWord)
345
+ );
346
+ if (isMatch) {
347
+ matchCount++;
348
+ if (tWord.length >= 6) hasHighValueWord = true;
349
+ }
350
+ }
351
+ const coverage = matchCount / targetTokens.length;
352
+ const isReliable = coverage >= 0.75 && (matchCount >= 2 || hasHighValueWord);
353
+ if (!isReliable) return { coverage: 0, matchCount: 0, targetLen: 0 };
354
+ return { coverage, matchCount, targetLen: targetTokens.length };
355
+ }
356
+ function isBetter(a, b) {
357
+ if (a.coverage !== b.coverage) return a.coverage > b.coverage;
358
+ if (a.matchCount !== b.matchCount) return a.matchCount > b.matchCount;
359
+ return a.targetLen > b.targetLen;
360
+ }
361
+ function scoreProcedure(promptTokens, node) {
362
+ let best = scoreTarget(promptTokens, node.trigger || "");
363
+ if (Array.isArray(node.aliases)) {
364
+ for (const alias of node.aliases) {
365
+ const next = scoreTarget(promptTokens, alias);
366
+ if (isBetter(next, best)) best = next;
367
+ }
368
+ }
369
+ return best;
370
+ }
371
+ function formatSteps(steps, requiresEnv = []) {
372
+ const required = requiresEnv.length > 0 ? `Requires environment: ${requiresEnv.join(", ")}
373
+
374
+ ` : "";
375
+ return required + steps.map((s) => `- ${s.t}: ${s.target}`).join("\n");
376
+ }
377
+ function recall(prompt, procs) {
378
+ if (!prompt) return null;
379
+ const promptTokens = tokenize(prompt);
380
+ let best = null;
381
+ let bestScore = { coverage: 0, matchCount: 0, targetLen: 0 };
382
+ for (const node of procs) {
383
+ if (node.enabled === false || node.parent_id) continue;
384
+ const score = scoreProcedure(promptTokens, node);
385
+ if (isBetter(score, bestScore)) {
386
+ bestScore = score;
387
+ best = node;
388
+ }
389
+ }
390
+ if (!best) return null;
391
+ let text = formatSteps(best.steps, best.requires_env);
392
+ const alt = procs.find((e) => e.parent_id === best.id && e.type === "alt" && e.enabled !== false);
393
+ if (alt) text += "\n\nAlternative path that worked:\n" + formatSteps(alt.steps, alt.requires_env);
394
+ const label = best.trigger || "procedure";
395
+ return {
396
+ node: best,
397
+ text: `Reflex memory found a procedure for this task:
398
+
399
+ ${text}
400
+
401
+ Follow these steps instead of figuring it out from scratch.`,
402
+ notice: `Reflex recalled: "${label}"`
403
+ };
404
+ }
53
405
 
54
406
  // lib/engine.js
55
407
  function handleEvent(evt, reflexDir, deps = {}) {
@@ -62,84 +414,83 @@ function handleEvent(evt, reflexDir, deps = {}) {
62
414
  var GROOM_THRESHOLD = 5;
63
415
  function onPrompt(evt, reflexDir) {
64
416
  const { sessionId, promptId, prompt } = evt;
65
- startTrace(reflexDir, sessionId, promptId, { t: "Goal", prompt });
66
- const hit = recall(prompt, readProcedures(reflexDir));
67
- if (!hit) return { context: null };
417
+ const sanitized = sanitizeCommand(prompt);
418
+ startTrace(reflexDir, sessionId, promptId, {
419
+ t: "Goal",
420
+ prompt: sanitized.text,
421
+ ...sanitized.requiresEnv.length > 0 ? { requires_env: sanitized.requiresEnv } : {},
422
+ ...sanitized.unsafe ? { contains_unresolvable_secret: true } : {}
423
+ });
424
+ const hit = recall(sanitized.text, readAllProcedures(reflexDir));
425
+ if (!hit) return { context: null, notice: null };
68
426
  appendTrace(reflexDir, sessionId, promptId, { t: "Recall", hit: hit.node.id });
69
- return { context: hit.text };
70
- }
71
- function recall(prompt, procs) {
72
- if (!prompt) return null;
73
- const p = prompt.toLowerCase();
74
- const fmt = (steps) => steps.map((s) => `- ${s.t}: ${s.target}`).join("\n");
75
- for (const node of procs) {
76
- if (node.enabled === false || node.parent_id) continue;
77
- let match = node.trigger && p.includes(node.trigger.toLowerCase());
78
- if (!match && Array.isArray(node.aliases)) {
79
- match = node.aliases.some((a) => p.includes(a.toLowerCase()));
80
- }
81
- if (!match) continue;
82
- let text = fmt(node.steps);
83
- const alt = procs.find((e) => e.parent_id === node.id && e.type === "alt" && e.enabled !== false);
84
- const onFail = procs.find((e) => e.parent_id === node.id && e.type === "on_fail" && e.enabled !== false);
85
- if (alt) text += "\n\nAlternative path that worked:\n" + fmt(alt.steps);
86
- if (onFail) text += "\n\nIf it fails, this recovery worked:\n" + fmt(onFail.steps);
87
- return {
88
- node,
89
- text: `Reflex memory found a procedure for this task:
90
-
91
- ${text}
92
-
93
- Follow these steps instead of figuring it out from scratch.`
94
- };
95
- }
96
- return null;
427
+ return { context: hit.text, notice: hit.notice };
97
428
  }
98
429
  function onTool(evt, reflexDir) {
99
- appendTrace(reflexDir, evt.sessionId, evt.promptId, { t: evt.tool, target: evt.target || "", ok: evt.ok });
430
+ const sanitized = evt.tool === "Bash" || evt.tool === "bash" ? sanitizeCommand(evt.target) : sanitizeText(evt.target);
431
+ appendTrace(reflexDir, evt.sessionId, evt.promptId, {
432
+ t: evt.tool,
433
+ target: sanitized.text,
434
+ ok: evt.ok,
435
+ ...sanitized.requiresEnv.length > 0 ? { requires_env: sanitized.requiresEnv } : {},
436
+ ...sanitized.unsafe ? { contains_unresolvable_secret: true } : {}
437
+ });
100
438
  return { context: null };
101
439
  }
102
440
  function onStop(evt, reflexDir, deps) {
103
441
  const { sessionId, promptId } = evt;
104
442
  const lines = readTrace(reflexDir, sessionId, promptId);
105
443
  if (lines.length === 0) return { context: null };
444
+ if (lines.some((line) => line.contains_unresolvable_secret)) {
445
+ deleteTrace(reflexDir, sessionId, promptId);
446
+ return { context: null };
447
+ }
106
448
  let goal = "";
107
449
  let recallHit = null;
450
+ let hasGoal = false;
108
451
  for (const l of lines) {
109
- if (l.t === "Goal") goal = l.prompt;
452
+ if (l.t === "Goal") {
453
+ goal = l.prompt;
454
+ hasGoal = true;
455
+ }
110
456
  if (l.t === "Recall" && l.hit) recallHit = l.hit;
111
457
  }
458
+ if (!hasGoal) {
459
+ deleteTrace(reflexDir, sessionId, promptId);
460
+ return { context: null };
461
+ }
112
462
  if (judge(lines, evt)) {
113
- const steps = synthesize(lines);
463
+ const { steps, requiresEnv } = synthesize(lines);
114
464
  if (steps.length > 0) {
465
+ const id = Math.random().toString(36).slice(2, 11);
115
466
  if (!recallHit) {
116
- appendProcedure(reflexDir, {
117
- id: Math.random().toString(36).slice(2, 11),
467
+ appendPending(reflexDir, {
468
+ id,
118
469
  title: goal,
119
470
  trigger: goal,
120
471
  steps,
472
+ ...requiresEnv.length > 0 ? { requires_env: requiresEnv } : {},
121
473
  enabled: true,
122
- hits: 0,
123
- last_used_at: (/* @__PURE__ */ new Date()).toISOString(),
124
474
  created_at: (/* @__PURE__ */ new Date()).toISOString(),
125
475
  status: "pending_review"
126
476
  });
127
477
  } else {
128
- const parent = readProcedures(reflexDir).find((p) => p.id === recallHit);
478
+ const parent = readAllProcedures(reflexDir).find((p) => p.id === recallHit);
129
479
  const parentSteps = parent ? parent.steps : [];
130
480
  const same = parentSteps.length === steps.length && parentSteps.every((s, i) => s.t === steps[i].t && s.target === steps[i].target);
131
481
  if (!same) {
132
- appendProcedure(reflexDir, {
482
+ appendPending(reflexDir, {
483
+ id,
133
484
  parent_id: recallHit,
134
485
  type: "alt",
135
486
  steps,
487
+ ...requiresEnv.length > 0 ? { requires_env: requiresEnv } : {},
136
488
  created_at: (/* @__PURE__ */ new Date()).toISOString(),
137
489
  status: "pending_review"
138
490
  });
139
491
  }
140
492
  }
141
- const pending = readProcedures(reflexDir).filter((p) => p.status === "pending_review").length;
142
- if (pending >= GROOM_THRESHOLD && deps.spawnGroomer) deps.spawnGroomer();
493
+ if (countIngest(reflexDir) >= GROOM_THRESHOLD && deps.spawnGroomer) deps.spawnGroomer();
143
494
  }
144
495
  }
145
496
  deleteTrace(reflexDir, sessionId, promptId);
@@ -174,17 +525,20 @@ function judge(lines, evt) {
174
525
  }
175
526
  function synthesize(lines) {
176
527
  const steps = [];
528
+ const requiresEnv = /* @__PURE__ */ new Set();
177
529
  for (const l of lines) {
530
+ for (const name of l.requires_env || []) requiresEnv.add(name);
178
531
  if (l.t === "Goal" || l.t === "Recall" || l.ok === false) continue;
179
532
  if (l.t === "Read" || l.t === "View") continue;
180
533
  if (l.t === "Bash" || l.t === "bash") {
181
534
  const target = l.target || "";
535
+ if (!target.trim()) continue;
182
536
  if (PURE_READ_CMDS.includes(target.trim().split(" ")[0])) continue;
183
537
  if (/^git (status|log|diff)/.test(target)) continue;
184
538
  }
185
539
  steps.push({ t: l.t, target: l.target });
186
540
  }
187
- return steps;
541
+ return { steps, requiresEnv: [...requiresEnv] };
188
542
  }
189
543
 
190
544
  // lib/groomer.js
@@ -345,48 +699,37 @@ function resolveCredentials(env = process.env) {
345
699
  }
346
700
 
347
701
  // lib/groomer.js
348
- function logError(proceduresFile, msg) {
349
- const reflexDir = path3.dirname(proceduresFile);
702
+ var GROOM_MAX_ATTEMPTS = 3;
703
+ function logError(reflexDir, msg) {
350
704
  const logFile = path3.join(reflexDir, "worker.log");
351
705
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
352
706
  try {
353
707
  fs3.appendFileSync(logFile, `[${timestamp}] ${msg}
354
708
  `, "utf8");
355
- } catch (e) {
709
+ } catch {
356
710
  }
357
711
  }
358
- async function runGroomer(proceduresFile) {
359
- let creds;
360
- try {
361
- creds = resolveCredentials();
362
- } catch (e) {
363
- logError(proceduresFile, e.message);
364
- return;
712
+ function mergeStable(stable, batchIds, keptFromBatch) {
713
+ const withoutBatch = stable.filter((n) => !n.id || !batchIds.has(n.id));
714
+ const byId = /* @__PURE__ */ new Map();
715
+ const anonymous = [];
716
+ for (const n of [...withoutBatch, ...keptFromBatch]) {
717
+ if (n.id) byId.set(n.id, n);
718
+ else anonymous.push(n);
365
719
  }
366
- if (!creds) {
367
- logError(
368
- proceduresFile,
369
- "No API key found. Run `npx agent-procedures auth login` or set ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY."
370
- );
371
- return;
720
+ return [...byId.values(), ...anonymous];
721
+ }
722
+ async function processBatch(reflexDir, processingFile, creds) {
723
+ const batch = readProcessing(reflexDir);
724
+ if (batch.length === 0) {
725
+ unlinkWithRetry(processingFile);
726
+ return { ok: true, deferred: 0 };
372
727
  }
373
- const { provider, apiKey, model } = creds;
374
- if (!fs3.existsSync(proceduresFile)) return;
375
- const lines = fs3.readFileSync(proceduresFile, "utf8").split("\n").filter(Boolean);
376
- const nodes = lines.map((l) => {
377
- try {
378
- return JSON.parse(l);
379
- } catch (e) {
380
- return null;
381
- }
382
- }).filter(Boolean);
383
- const pending = nodes.filter((n) => n.status === "pending_review");
384
- if (pending.length === 0) return;
385
728
  const prompt = `
386
- Here are ${pending.length} new execution nodes/edges added by a coding agent.
729
+ Here are ${batch.length} new execution nodes/edges added by a coding agent.
387
730
 
388
731
  <edges>
389
- ${JSON.stringify(pending, null, 2)}
732
+ ${JSON.stringify(batch, null, 2)}
390
733
  </edges>
391
734
 
392
735
  For each item:
@@ -408,46 +751,77 @@ Return ONLY a JSON array of objects with this schema:
408
751
  `;
409
752
  let responseText;
410
753
  try {
411
- responseText = await PROVIDERS[provider].complete(apiKey, model, prompt);
754
+ responseText = await PROVIDERS[creds.provider].complete(creds.apiKey, creds.model, prompt);
412
755
  } catch (err) {
413
- logError(proceduresFile, err.message);
414
- return;
756
+ logError(reflexDir, err.message);
757
+ return { ok: false, deferred: 0 };
415
758
  }
416
759
  let actions = [];
417
760
  try {
418
761
  const jsonStr = responseText.replace(/```json/g, "").replace(/```/g, "").trim();
419
762
  actions = JSON.parse(jsonStr);
420
- } catch (e) {
421
- logError(
422
- proceduresFile,
423
- "Failed to parse LLM JSON response: " + responseText
424
- );
425
- return;
763
+ } catch {
764
+ logError(reflexDir, "Failed to parse LLM JSON response.");
765
+ return { ok: false, deferred: 0 };
426
766
  }
427
- const newNodes = [];
428
- for (const node of nodes) {
429
- if (node.status === "pending_review") {
430
- const actionDef = actions.find((a) => a.id === node.id);
431
- if (actionDef) {
432
- if (actionDef.action === "DROP") {
433
- continue;
434
- } else {
435
- delete node.status;
436
- if (!node.parent_id && actionDef.aliases) {
437
- node.aliases = actionDef.aliases;
438
- }
439
- }
440
- }
767
+ const kept = [];
768
+ const deferred = [];
769
+ for (const node of batch) {
770
+ const actionDef = actions.find((a) => a.id === node.id);
771
+ if (!actionDef) {
772
+ const attempts = (node.groom_attempts || 0) + 1;
773
+ if (attempts < GROOM_MAX_ATTEMPTS) deferred.push({ ...node, groom_attempts: attempts });
774
+ continue;
441
775
  }
442
- newNodes.push(node);
776
+ if (actionDef.action === "DROP") continue;
777
+ const next = { ...node };
778
+ delete next.status;
779
+ delete next.groom_attempts;
780
+ if (!next.parent_id && Array.isArray(actionDef.aliases)) {
781
+ next.aliases = actionDef.aliases;
782
+ }
783
+ kept.push(next);
443
784
  }
444
- const newFileContent = newNodes.map((n) => JSON.stringify(n)).join("\n") + "\n";
445
- const tmpFile = proceduresFile + ".tmp";
785
+ const resolvedIds = new Set(
786
+ batch.filter((n) => actions.some((a) => a.id === n.id)).map((n) => n.id).filter(Boolean)
787
+ );
788
+ const merged = mergeStable(readProcedures(reflexDir), resolvedIds, kept);
789
+ writeJsonlAtomic(paths(reflexDir).proceduresFile, merged);
790
+ unlinkWithRetry(processingFile);
791
+ for (const node of deferred) appendPending(reflexDir, node);
792
+ return { ok: true, deferred: deferred.length };
793
+ }
794
+ async function runGroomer(reflexDir) {
795
+ if (!tryAcquireGroomerLock(reflexDir)) return { ran: false, reason: "locked" };
446
796
  try {
447
- fs3.writeFileSync(tmpFile, newFileContent, "utf8");
448
- fs3.renameSync(tmpFile, proceduresFile);
449
- } catch (err) {
450
- logError(proceduresFile, `Failed atomic write: ${err.message}`);
797
+ migrateLegacyPending(reflexDir);
798
+ let creds;
799
+ try {
800
+ creds = resolveCredentials();
801
+ } catch (e) {
802
+ logError(reflexDir, e.message);
803
+ return { ran: false, reason: "creds" };
804
+ }
805
+ if (!creds) {
806
+ logError(
807
+ reflexDir,
808
+ "No API key found. Run `npx agent-procedures auth login` or set ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY."
809
+ );
810
+ return { ran: false, reason: "creds" };
811
+ }
812
+ let batches = 0;
813
+ while (batches < 8) {
814
+ const processingFile = claimPendingBatch(reflexDir);
815
+ if (!processingFile) break;
816
+ const { ok, deferred } = await processBatch(reflexDir, processingFile, creds);
817
+ if (!ok) break;
818
+ batches++;
819
+ if (deferred > 0) break;
820
+ if (countIngest(reflexDir) === 0) break;
821
+ }
822
+ return { ran: true, batches };
823
+ } finally {
824
+ releaseGroomerLock(reflexDir);
451
825
  }
452
826
  }
453
827
 
@@ -500,19 +874,21 @@ var claude_default = {
500
874
  return {
501
875
  type: "stop",
502
876
  ...base,
503
- interrupted: payload.stop_hook_active === true,
877
+ interrupted: false,
504
878
  busy: Array.isArray(payload.background_tasks) && payload.background_tasks.length > 0
505
879
  };
506
880
  }
507
881
  return null;
508
882
  },
509
883
  /** Engine result → what to print on stdout for Claude. */
510
- render(evt, result) {
884
+ render(evt, result2) {
511
885
  if (evt.type !== "prompt") return "";
512
- if (!result.context) return "{}";
513
- return JSON.stringify({
514
- hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: result.context }
515
- });
886
+ if (!result2.context) return "{}";
887
+ const out = {
888
+ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: result2.context }
889
+ };
890
+ if (result2.notice) out.systemMessage = result2.notice;
891
+ return JSON.stringify(out);
516
892
  }
517
893
  };
518
894
 
@@ -539,6 +915,7 @@ function readStdin() {
539
915
  });
540
916
  }
541
917
  function spawnGroomer() {
918
+ if (isGroomerLocked(REFLEX_DIR)) return;
542
919
  const child = spawn(process.argv[0], [process.argv[1], "Groom"], { detached: true, stdio: "ignore" });
543
920
  child.unref();
544
921
  }
@@ -546,7 +923,7 @@ async function run(harnessId, argv = process.argv.slice(2)) {
546
923
  const [eventName] = argv;
547
924
  if (!eventName) return;
548
925
  if (eventName === "Groom") {
549
- await runGroomer(path5.join(REFLEX_DIR, "procedures.jsonl"));
926
+ await runGroomer(REFLEX_DIR);
550
927
  return;
551
928
  }
552
929
  const raw = await readStdin();
@@ -560,8 +937,8 @@ async function run(harnessId, argv = process.argv.slice(2)) {
560
937
  const harness = getHarness(harnessId);
561
938
  const evt = harness.normalize(eventName, payload);
562
939
  if (!evt) return;
563
- const result = handleEvent(evt, REFLEX_DIR, { spawnGroomer });
564
- const out = harness.render(evt, result);
940
+ const result2 = handleEvent(evt, REFLEX_DIR, { spawnGroomer });
941
+ const out = harness.render(evt, result2);
565
942
  if (out) process.stdout.write(out + "\n");
566
943
  }
567
944
  export {