agent-procedures 0.3.0 → 0.3.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.
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.1. 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,324 @@ 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
+ return {
395
+ node: best,
396
+ text: `Reflex memory found a procedure for this task:
397
+
398
+ ${text}
399
+
400
+ Follow these steps instead of figuring it out from scratch.`
401
+ };
402
+ }
53
403
 
54
404
  // lib/engine.js
55
405
  function handleEvent(evt, reflexDir, deps = {}) {
@@ -62,84 +412,83 @@ function handleEvent(evt, reflexDir, deps = {}) {
62
412
  var GROOM_THRESHOLD = 5;
63
413
  function onPrompt(evt, reflexDir) {
64
414
  const { sessionId, promptId, prompt } = evt;
65
- startTrace(reflexDir, sessionId, promptId, { t: "Goal", prompt });
66
- const hit = recall(prompt, readProcedures(reflexDir));
415
+ const sanitized = sanitizeCommand(prompt);
416
+ startTrace(reflexDir, sessionId, promptId, {
417
+ t: "Goal",
418
+ prompt: sanitized.text,
419
+ ...sanitized.requiresEnv.length > 0 ? { requires_env: sanitized.requiresEnv } : {},
420
+ ...sanitized.unsafe ? { contains_unresolvable_secret: true } : {}
421
+ });
422
+ const hit = recall(sanitized.text, readAllProcedures(reflexDir));
67
423
  if (!hit) return { context: null };
68
424
  appendTrace(reflexDir, sessionId, promptId, { t: "Recall", hit: hit.node.id });
69
425
  return { context: hit.text };
70
426
  }
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;
97
- }
98
427
  function onTool(evt, reflexDir) {
99
- appendTrace(reflexDir, evt.sessionId, evt.promptId, { t: evt.tool, target: evt.target || "", ok: evt.ok });
428
+ const sanitized = evt.tool === "Bash" || evt.tool === "bash" ? sanitizeCommand(evt.target) : sanitizeText(evt.target);
429
+ appendTrace(reflexDir, evt.sessionId, evt.promptId, {
430
+ t: evt.tool,
431
+ target: sanitized.text,
432
+ ok: evt.ok,
433
+ ...sanitized.requiresEnv.length > 0 ? { requires_env: sanitized.requiresEnv } : {},
434
+ ...sanitized.unsafe ? { contains_unresolvable_secret: true } : {}
435
+ });
100
436
  return { context: null };
101
437
  }
102
438
  function onStop(evt, reflexDir, deps) {
103
439
  const { sessionId, promptId } = evt;
104
440
  const lines = readTrace(reflexDir, sessionId, promptId);
105
441
  if (lines.length === 0) return { context: null };
442
+ if (lines.some((line) => line.contains_unresolvable_secret)) {
443
+ deleteTrace(reflexDir, sessionId, promptId);
444
+ return { context: null };
445
+ }
106
446
  let goal = "";
107
447
  let recallHit = null;
448
+ let hasGoal = false;
108
449
  for (const l of lines) {
109
- if (l.t === "Goal") goal = l.prompt;
450
+ if (l.t === "Goal") {
451
+ goal = l.prompt;
452
+ hasGoal = true;
453
+ }
110
454
  if (l.t === "Recall" && l.hit) recallHit = l.hit;
111
455
  }
456
+ if (!hasGoal) {
457
+ deleteTrace(reflexDir, sessionId, promptId);
458
+ return { context: null };
459
+ }
112
460
  if (judge(lines, evt)) {
113
- const steps = synthesize(lines);
461
+ const { steps, requiresEnv } = synthesize(lines);
114
462
  if (steps.length > 0) {
463
+ const id = Math.random().toString(36).slice(2, 11);
115
464
  if (!recallHit) {
116
- appendProcedure(reflexDir, {
117
- id: Math.random().toString(36).slice(2, 11),
465
+ appendPending(reflexDir, {
466
+ id,
118
467
  title: goal,
119
468
  trigger: goal,
120
469
  steps,
470
+ ...requiresEnv.length > 0 ? { requires_env: requiresEnv } : {},
121
471
  enabled: true,
122
- hits: 0,
123
- last_used_at: (/* @__PURE__ */ new Date()).toISOString(),
124
472
  created_at: (/* @__PURE__ */ new Date()).toISOString(),
125
473
  status: "pending_review"
126
474
  });
127
475
  } else {
128
- const parent = readProcedures(reflexDir).find((p) => p.id === recallHit);
476
+ const parent = readAllProcedures(reflexDir).find((p) => p.id === recallHit);
129
477
  const parentSteps = parent ? parent.steps : [];
130
478
  const same = parentSteps.length === steps.length && parentSteps.every((s, i) => s.t === steps[i].t && s.target === steps[i].target);
131
479
  if (!same) {
132
- appendProcedure(reflexDir, {
480
+ appendPending(reflexDir, {
481
+ id,
133
482
  parent_id: recallHit,
134
483
  type: "alt",
135
484
  steps,
485
+ ...requiresEnv.length > 0 ? { requires_env: requiresEnv } : {},
136
486
  created_at: (/* @__PURE__ */ new Date()).toISOString(),
137
487
  status: "pending_review"
138
488
  });
139
489
  }
140
490
  }
141
- const pending = readProcedures(reflexDir).filter((p) => p.status === "pending_review").length;
142
- if (pending >= GROOM_THRESHOLD && deps.spawnGroomer) deps.spawnGroomer();
491
+ if (countIngest(reflexDir) >= GROOM_THRESHOLD && deps.spawnGroomer) deps.spawnGroomer();
143
492
  }
144
493
  }
145
494
  deleteTrace(reflexDir, sessionId, promptId);
@@ -174,17 +523,20 @@ function judge(lines, evt) {
174
523
  }
175
524
  function synthesize(lines) {
176
525
  const steps = [];
526
+ const requiresEnv = /* @__PURE__ */ new Set();
177
527
  for (const l of lines) {
528
+ for (const name of l.requires_env || []) requiresEnv.add(name);
178
529
  if (l.t === "Goal" || l.t === "Recall" || l.ok === false) continue;
179
530
  if (l.t === "Read" || l.t === "View") continue;
180
531
  if (l.t === "Bash" || l.t === "bash") {
181
532
  const target = l.target || "";
533
+ if (!target.trim()) continue;
182
534
  if (PURE_READ_CMDS.includes(target.trim().split(" ")[0])) continue;
183
535
  if (/^git (status|log|diff)/.test(target)) continue;
184
536
  }
185
537
  steps.push({ t: l.t, target: l.target });
186
538
  }
187
- return steps;
539
+ return { steps, requiresEnv: [...requiresEnv] };
188
540
  }
189
541
 
190
542
  // lib/groomer.js
@@ -345,48 +697,37 @@ function resolveCredentials(env = process.env) {
345
697
  }
346
698
 
347
699
  // lib/groomer.js
348
- function logError(proceduresFile, msg) {
349
- const reflexDir = path3.dirname(proceduresFile);
700
+ var GROOM_MAX_ATTEMPTS = 3;
701
+ function logError(reflexDir, msg) {
350
702
  const logFile = path3.join(reflexDir, "worker.log");
351
703
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
352
704
  try {
353
705
  fs3.appendFileSync(logFile, `[${timestamp}] ${msg}
354
706
  `, "utf8");
355
- } catch (e) {
707
+ } catch {
356
708
  }
357
709
  }
358
- async function runGroomer(proceduresFile) {
359
- let creds;
360
- try {
361
- creds = resolveCredentials();
362
- } catch (e) {
363
- logError(proceduresFile, e.message);
364
- return;
710
+ function mergeStable(stable, batchIds, keptFromBatch) {
711
+ const withoutBatch = stable.filter((n) => !n.id || !batchIds.has(n.id));
712
+ const byId = /* @__PURE__ */ new Map();
713
+ const anonymous = [];
714
+ for (const n of [...withoutBatch, ...keptFromBatch]) {
715
+ if (n.id) byId.set(n.id, n);
716
+ else anonymous.push(n);
365
717
  }
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;
718
+ return [...byId.values(), ...anonymous];
719
+ }
720
+ async function processBatch(reflexDir, processingFile, creds) {
721
+ const batch = readProcessing(reflexDir);
722
+ if (batch.length === 0) {
723
+ unlinkWithRetry(processingFile);
724
+ return { ok: true, deferred: 0 };
372
725
  }
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
726
  const prompt = `
386
- Here are ${pending.length} new execution nodes/edges added by a coding agent.
727
+ Here are ${batch.length} new execution nodes/edges added by a coding agent.
387
728
 
388
729
  <edges>
389
- ${JSON.stringify(pending, null, 2)}
730
+ ${JSON.stringify(batch, null, 2)}
390
731
  </edges>
391
732
 
392
733
  For each item:
@@ -408,46 +749,77 @@ Return ONLY a JSON array of objects with this schema:
408
749
  `;
409
750
  let responseText;
410
751
  try {
411
- responseText = await PROVIDERS[provider].complete(apiKey, model, prompt);
752
+ responseText = await PROVIDERS[creds.provider].complete(creds.apiKey, creds.model, prompt);
412
753
  } catch (err) {
413
- logError(proceduresFile, err.message);
414
- return;
754
+ logError(reflexDir, err.message);
755
+ return { ok: false, deferred: 0 };
415
756
  }
416
757
  let actions = [];
417
758
  try {
418
759
  const jsonStr = responseText.replace(/```json/g, "").replace(/```/g, "").trim();
419
760
  actions = JSON.parse(jsonStr);
420
- } catch (e) {
421
- logError(
422
- proceduresFile,
423
- "Failed to parse LLM JSON response: " + responseText
424
- );
425
- return;
761
+ } catch {
762
+ logError(reflexDir, "Failed to parse LLM JSON response.");
763
+ return { ok: false, deferred: 0 };
426
764
  }
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
- }
765
+ const kept = [];
766
+ const deferred = [];
767
+ for (const node of batch) {
768
+ const actionDef = actions.find((a) => a.id === node.id);
769
+ if (!actionDef) {
770
+ const attempts = (node.groom_attempts || 0) + 1;
771
+ if (attempts < GROOM_MAX_ATTEMPTS) deferred.push({ ...node, groom_attempts: attempts });
772
+ continue;
773
+ }
774
+ if (actionDef.action === "DROP") continue;
775
+ const next = { ...node };
776
+ delete next.status;
777
+ delete next.groom_attempts;
778
+ if (!next.parent_id && Array.isArray(actionDef.aliases)) {
779
+ next.aliases = actionDef.aliases;
441
780
  }
442
- newNodes.push(node);
781
+ kept.push(next);
443
782
  }
444
- const newFileContent = newNodes.map((n) => JSON.stringify(n)).join("\n") + "\n";
445
- const tmpFile = proceduresFile + ".tmp";
783
+ const resolvedIds = new Set(
784
+ batch.filter((n) => actions.some((a) => a.id === n.id)).map((n) => n.id).filter(Boolean)
785
+ );
786
+ const merged = mergeStable(readProcedures(reflexDir), resolvedIds, kept);
787
+ writeJsonlAtomic(paths(reflexDir).proceduresFile, merged);
788
+ unlinkWithRetry(processingFile);
789
+ for (const node of deferred) appendPending(reflexDir, node);
790
+ return { ok: true, deferred: deferred.length };
791
+ }
792
+ async function runGroomer(reflexDir) {
793
+ if (!tryAcquireGroomerLock(reflexDir)) return { ran: false, reason: "locked" };
446
794
  try {
447
- fs3.writeFileSync(tmpFile, newFileContent, "utf8");
448
- fs3.renameSync(tmpFile, proceduresFile);
449
- } catch (err) {
450
- logError(proceduresFile, `Failed atomic write: ${err.message}`);
795
+ migrateLegacyPending(reflexDir);
796
+ let creds;
797
+ try {
798
+ creds = resolveCredentials();
799
+ } catch (e) {
800
+ logError(reflexDir, e.message);
801
+ return { ran: false, reason: "creds" };
802
+ }
803
+ if (!creds) {
804
+ logError(
805
+ reflexDir,
806
+ "No API key found. Run `npx agent-procedures auth login` or set ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY."
807
+ );
808
+ return { ran: false, reason: "creds" };
809
+ }
810
+ let batches = 0;
811
+ while (batches < 8) {
812
+ const processingFile = claimPendingBatch(reflexDir);
813
+ if (!processingFile) break;
814
+ const { ok, deferred } = await processBatch(reflexDir, processingFile, creds);
815
+ if (!ok) break;
816
+ batches++;
817
+ if (deferred > 0) break;
818
+ if (countIngest(reflexDir) === 0) break;
819
+ }
820
+ return { ran: true, batches };
821
+ } finally {
822
+ releaseGroomerLock(reflexDir);
451
823
  }
452
824
  }
453
825
 
@@ -500,18 +872,18 @@ var claude_default = {
500
872
  return {
501
873
  type: "stop",
502
874
  ...base,
503
- interrupted: payload.stop_hook_active === true,
875
+ interrupted: false,
504
876
  busy: Array.isArray(payload.background_tasks) && payload.background_tasks.length > 0
505
877
  };
506
878
  }
507
879
  return null;
508
880
  },
509
881
  /** Engine result → what to print on stdout for Claude. */
510
- render(evt, result) {
882
+ render(evt, result2) {
511
883
  if (evt.type !== "prompt") return "";
512
- if (!result.context) return "{}";
884
+ if (!result2.context) return "{}";
513
885
  return JSON.stringify({
514
- hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: result.context }
886
+ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: result2.context }
515
887
  });
516
888
  }
517
889
  };
@@ -539,6 +911,7 @@ function readStdin() {
539
911
  });
540
912
  }
541
913
  function spawnGroomer() {
914
+ if (isGroomerLocked(REFLEX_DIR)) return;
542
915
  const child = spawn(process.argv[0], [process.argv[1], "Groom"], { detached: true, stdio: "ignore" });
543
916
  child.unref();
544
917
  }
@@ -546,7 +919,7 @@ async function run(harnessId, argv = process.argv.slice(2)) {
546
919
  const [eventName] = argv;
547
920
  if (!eventName) return;
548
921
  if (eventName === "Groom") {
549
- await runGroomer(path5.join(REFLEX_DIR, "procedures.jsonl"));
922
+ await runGroomer(REFLEX_DIR);
550
923
  return;
551
924
  }
552
925
  const raw = await readStdin();
@@ -560,8 +933,8 @@ async function run(harnessId, argv = process.argv.slice(2)) {
560
933
  const harness = getHarness(harnessId);
561
934
  const evt = harness.normalize(eventName, payload);
562
935
  if (!evt) return;
563
- const result = handleEvent(evt, REFLEX_DIR, { spawnGroomer });
564
- const out = harness.render(evt, result);
936
+ const result2 = handleEvent(evt, REFLEX_DIR, { spawnGroomer });
937
+ const out = harness.render(evt, result2);
565
938
  if (out) process.stdout.write(out + "\n");
566
939
  }
567
940
  export {