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/lib/engine.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import {
2
- appendProcedure,
2
+ appendPending,
3
3
  appendTrace,
4
+ countIngest,
4
5
  deleteTrace,
5
- readProcedures,
6
+ readAllProcedures,
6
7
  readTrace,
7
8
  startTrace,
8
9
  } from "./store.js";
10
+ import { sanitizeCommand, sanitizeText } from "./sanitize.js";
11
+ import { recall } from "./recall.js";
9
12
 
10
13
  /**
11
14
  * Harness-free hot path.
@@ -33,47 +36,35 @@ export const GROOM_THRESHOLD = 5;
33
36
 
34
37
  function onPrompt(evt, reflexDir) {
35
38
  const { sessionId, promptId, prompt } = evt;
36
- startTrace(reflexDir, sessionId, promptId, { t: "Goal", prompt });
37
-
38
- const hit = recall(prompt, readProcedures(reflexDir));
39
+ const sanitized = sanitizeCommand(prompt);
40
+ startTrace(reflexDir, sessionId, promptId, {
41
+ t: "Goal",
42
+ prompt: sanitized.text,
43
+ ...(sanitized.requiresEnv.length > 0 ? { requires_env: sanitized.requiresEnv } : {}),
44
+ ...(sanitized.unsafe ? { contains_unresolvable_secret: true } : {}),
45
+ });
46
+
47
+ const hit = recall(sanitized.text, readAllProcedures(reflexDir));
39
48
  if (!hit) return { context: null };
40
49
 
41
50
  appendTrace(reflexDir, sessionId, promptId, { t: "Recall", hit: hit.node.id });
42
51
  return { context: hit.text };
43
52
  }
44
53
 
45
- function recall(prompt, procs) {
46
- if (!prompt) return null;
47
- const p = prompt.toLowerCase();
48
- const fmt = (steps) => steps.map((s) => `- ${s.t}: ${s.target}`).join("\n");
49
-
50
- for (const node of procs) {
51
- if (node.enabled === false || node.parent_id) continue;
52
-
53
- let match = node.trigger && p.includes(node.trigger.toLowerCase());
54
- if (!match && Array.isArray(node.aliases)) {
55
- match = node.aliases.some((a) => p.includes(a.toLowerCase()));
56
- }
57
- if (!match) continue;
58
-
59
- let text = fmt(node.steps);
60
- const alt = procs.find((e) => e.parent_id === node.id && e.type === "alt" && e.enabled !== false);
61
- const onFail = procs.find((e) => e.parent_id === node.id && e.type === "on_fail" && e.enabled !== false);
62
- if (alt) text += "\n\nAlternative path that worked:\n" + fmt(alt.steps);
63
- if (onFail) text += "\n\nIf it fails, this recovery worked:\n" + fmt(onFail.steps);
64
-
65
- return {
66
- node,
67
- text: `Reflex memory found a procedure for this task:\n\n${text}\n\nFollow these steps instead of figuring it out from scratch.`,
68
- };
69
- }
70
- return null;
71
- }
72
-
73
54
  // --- tool: trace ----------------------------------------------------------
74
55
 
75
56
  function onTool(evt, reflexDir) {
76
- appendTrace(reflexDir, evt.sessionId, evt.promptId, { t: evt.tool, target: evt.target || "", ok: evt.ok });
57
+ const sanitized =
58
+ evt.tool === "Bash" || evt.tool === "bash"
59
+ ? sanitizeCommand(evt.target)
60
+ : sanitizeText(evt.target);
61
+ appendTrace(reflexDir, evt.sessionId, evt.promptId, {
62
+ t: evt.tool,
63
+ target: sanitized.text,
64
+ ok: evt.ok,
65
+ ...(sanitized.requiresEnv.length > 0 ? { requires_env: sanitized.requiresEnv } : {}),
66
+ ...(sanitized.unsafe ? { contains_unresolvable_secret: true } : {}),
67
+ });
77
68
  return { context: null };
78
69
  }
79
70
 
@@ -83,48 +74,63 @@ function onStop(evt, reflexDir, deps) {
83
74
  const { sessionId, promptId } = evt;
84
75
  const lines = readTrace(reflexDir, sessionId, promptId);
85
76
  if (lines.length === 0) return { context: null };
77
+ if (lines.some((line) => line.contains_unresolvable_secret)) {
78
+ deleteTrace(reflexDir, sessionId, promptId);
79
+ return { context: null };
80
+ }
86
81
 
87
82
  let goal = "";
88
83
  let recallHit = null;
84
+ let hasGoal = false;
89
85
  for (const l of lines) {
90
- if (l.t === "Goal") goal = l.prompt;
86
+ if (l.t === "Goal") {
87
+ goal = l.prompt;
88
+ hasGoal = true;
89
+ }
91
90
  if (l.t === "Recall" && l.hit) recallHit = l.hit;
92
91
  }
92
+ // Continuation after another Stop hook can recreate a trace via appendTrace
93
+ // with no Goal line. Don't learn from that stump.
94
+ if (!hasGoal) {
95
+ deleteTrace(reflexDir, sessionId, promptId);
96
+ return { context: null };
97
+ }
93
98
 
94
99
  if (judge(lines, evt)) {
95
- const steps = synthesize(lines);
100
+ const { steps, requiresEnv } = synthesize(lines);
96
101
  if (steps.length > 0) {
102
+ const id = Math.random().toString(36).slice(2, 11);
97
103
  if (!recallHit) {
98
- appendProcedure(reflexDir, {
99
- id: Math.random().toString(36).slice(2, 11),
104
+ appendPending(reflexDir, {
105
+ id,
100
106
  title: goal,
101
107
  trigger: goal,
102
108
  steps,
109
+ ...(requiresEnv.length > 0 ? { requires_env: requiresEnv } : {}),
103
110
  enabled: true,
104
- hits: 0,
105
- last_used_at: new Date().toISOString(),
106
111
  created_at: new Date().toISOString(),
107
112
  status: "pending_review",
108
113
  });
109
114
  } else {
110
- const parent = readProcedures(reflexDir).find((p) => p.id === recallHit);
115
+ const parent = readAllProcedures(reflexDir).find((p) => p.id === recallHit);
111
116
  const parentSteps = parent ? parent.steps : [];
112
117
  const same =
113
118
  parentSteps.length === steps.length &&
114
119
  parentSteps.every((s, i) => s.t === steps[i].t && s.target === steps[i].target);
115
120
  if (!same) {
116
- appendProcedure(reflexDir, {
121
+ appendPending(reflexDir, {
122
+ id,
117
123
  parent_id: recallHit,
118
124
  type: "alt",
119
125
  steps,
126
+ ...(requiresEnv.length > 0 ? { requires_env: requiresEnv } : {}),
120
127
  created_at: new Date().toISOString(),
121
128
  status: "pending_review",
122
129
  });
123
130
  }
124
131
  }
125
132
 
126
- const pending = readProcedures(reflexDir).filter((p) => p.status === "pending_review").length;
127
- if (pending >= GROOM_THRESHOLD && deps.spawnGroomer) deps.spawnGroomer();
133
+ if (countIngest(reflexDir) >= GROOM_THRESHOLD && deps.spawnGroomer) deps.spawnGroomer();
128
134
  }
129
135
  }
130
136
 
@@ -165,15 +171,18 @@ function judge(lines, evt) {
165
171
 
166
172
  function synthesize(lines) {
167
173
  const steps = [];
174
+ const requiresEnv = new Set();
168
175
  for (const l of lines) {
176
+ for (const name of l.requires_env || []) requiresEnv.add(name);
169
177
  if (l.t === "Goal" || l.t === "Recall" || l.ok === false) continue;
170
178
  if (l.t === "Read" || l.t === "View") continue;
171
179
  if (l.t === "Bash" || l.t === "bash") {
172
180
  const target = l.target || "";
181
+ if (!target.trim()) continue;
173
182
  if (PURE_READ_CMDS.includes(target.trim().split(" ")[0])) continue;
174
183
  if (/^git (status|log|diff)/.test(target)) continue;
175
184
  }
176
185
  steps.push({ t: l.t, target: l.target });
177
186
  }
178
- return steps;
187
+ return { steps, requiresEnv: [...requiresEnv] };
179
188
  }
package/lib/groomer.js CHANGED
@@ -1,60 +1,55 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { PROVIDERS, resolveCredentials } from "./providers.js";
4
+ import {
5
+ appendPending,
6
+ claimPendingBatch,
7
+ countIngest,
8
+ migrateLegacyPending,
9
+ paths,
10
+ readProcedures,
11
+ readProcessing,
12
+ releaseGroomerLock,
13
+ tryAcquireGroomerLock,
14
+ unlinkWithRetry,
15
+ writeJsonlAtomic,
16
+ } from "./store.js";
4
17
 
5
- // Silent Failure Logging
6
- function logError(proceduresFile, msg) {
7
- const reflexDir = path.dirname(proceduresFile);
18
+ export const GROOM_MAX_ATTEMPTS = 3;
19
+
20
+ function logError(reflexDir, msg) {
8
21
  const logFile = path.join(reflexDir, "worker.log");
9
22
  const timestamp = new Date().toISOString();
10
23
  try {
11
24
  fs.appendFileSync(logFile, `[${timestamp}] ${msg}\n`, "utf8");
12
- } catch (e) {
13
- // nowhere to log, silently fail
25
+ } catch {
26
+ /* nowhere to log */
14
27
  }
15
28
  }
16
29
 
17
- export async function runGroomer(proceduresFile) {
18
- let creds;
19
- try {
20
- creds = resolveCredentials();
21
- } catch (e) {
22
- logError(proceduresFile, e.message);
23
- return;
30
+ function mergeStable(stable, batchIds, keptFromBatch) {
31
+ const withoutBatch = stable.filter((n) => !n.id || !batchIds.has(n.id));
32
+ const byId = new Map();
33
+ const anonymous = [];
34
+ for (const n of [...withoutBatch, ...keptFromBatch]) {
35
+ if (n.id) byId.set(n.id, n);
36
+ else anonymous.push(n);
24
37
  }
25
- if (!creds) {
26
- logError(
27
- proceduresFile,
28
- "No API key found. Run `npx agent-procedures auth login` or set ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY.",
29
- );
30
- return;
38
+ return [...byId.values(), ...anonymous];
39
+ }
40
+
41
+ async function processBatch(reflexDir, processingFile, creds) {
42
+ const batch = readProcessing(reflexDir);
43
+ if (batch.length === 0) {
44
+ unlinkWithRetry(processingFile);
45
+ return { ok: true, deferred: 0 };
31
46
  }
32
- const { provider, apiKey, model } = creds;
33
-
34
- if (!fs.existsSync(proceduresFile)) return;
35
-
36
- const lines = fs
37
- .readFileSync(proceduresFile, "utf8")
38
- .split("\n")
39
- .filter(Boolean);
40
- const nodes = lines
41
- .map((l) => {
42
- try {
43
- return JSON.parse(l);
44
- } catch (e) {
45
- return null;
46
- }
47
- })
48
- .filter(Boolean);
49
-
50
- const pending = nodes.filter((n) => n.status === "pending_review");
51
- if (pending.length === 0) return;
52
47
 
53
48
  const prompt = `
54
- Here are ${pending.length} new execution nodes/edges added by a coding agent.
49
+ Here are ${batch.length} new execution nodes/edges added by a coding agent.
55
50
 
56
51
  <edges>
57
- ${JSON.stringify(pending, null, 2)}
52
+ ${JSON.stringify(batch, null, 2)}
58
53
  </edges>
59
54
 
60
55
  For each item:
@@ -77,10 +72,10 @@ Return ONLY a JSON array of objects with this schema:
77
72
 
78
73
  let responseText;
79
74
  try {
80
- responseText = await PROVIDERS[provider].complete(apiKey, model, prompt);
75
+ responseText = await PROVIDERS[creds.provider].complete(creds.apiKey, creds.model, prompt);
81
76
  } catch (err) {
82
- logError(proceduresFile, err.message);
83
- return;
77
+ logError(reflexDir, err.message);
78
+ return { ok: false, deferred: 0 };
84
79
  }
85
80
 
86
81
  let actions = [];
@@ -90,40 +85,81 @@ Return ONLY a JSON array of objects with this schema:
90
85
  .replace(/```/g, "")
91
86
  .trim();
92
87
  actions = JSON.parse(jsonStr);
93
- } catch (e) {
94
- logError(
95
- proceduresFile,
96
- "Failed to parse LLM JSON response: " + responseText,
97
- );
98
- return;
88
+ } catch {
89
+ logError(reflexDir, "Failed to parse LLM JSON response.");
90
+ return { ok: false, deferred: 0 };
99
91
  }
100
92
 
101
- const newNodes = [];
102
- for (const node of nodes) {
103
- if (node.status === "pending_review") {
104
- const actionDef = actions.find((a) => a.id === node.id);
105
- if (actionDef) {
106
- if (actionDef.action === "DROP") {
107
- continue;
108
- } else {
109
- delete node.status;
110
- if (!node.parent_id && actionDef.aliases) {
111
- node.aliases = actionDef.aliases;
112
- }
113
- }
114
- }
93
+ const kept = [];
94
+ const deferred = [];
95
+ for (const node of batch) {
96
+ const actionDef = actions.find((a) => a.id === node.id);
97
+ if (!actionDef) {
98
+ // No verdict from the model. Retry a few times, then give up on it.
99
+ const attempts = (node.groom_attempts || 0) + 1;
100
+ if (attempts < GROOM_MAX_ATTEMPTS) deferred.push({ ...node, groom_attempts: attempts });
101
+ continue;
115
102
  }
116
- newNodes.push(node);
103
+ if (actionDef.action === "DROP") continue;
104
+ const next = { ...node };
105
+ delete next.status;
106
+ delete next.groom_attempts;
107
+ if (!next.parent_id && Array.isArray(actionDef.aliases)) {
108
+ next.aliases = actionDef.aliases;
109
+ }
110
+ kept.push(next);
117
111
  }
118
112
 
119
- // Atomic Writes for the Groomer
120
- const newFileContent =
121
- newNodes.map((n) => JSON.stringify(n)).join("\n") + "\n";
122
- const tmpFile = proceduresFile + ".tmp";
113
+ // Only remove ids we actually resolved; deferred ids stay out of stable.
114
+ const resolvedIds = new Set(
115
+ batch.filter((n) => actions.some((a) => a.id === n.id)).map((n) => n.id).filter(Boolean),
116
+ );
117
+ const merged = mergeStable(readProcedures(reflexDir), resolvedIds, kept);
118
+ writeJsonlAtomic(paths(reflexDir).proceduresFile, merged);
119
+ unlinkWithRetry(processingFile);
120
+
121
+ for (const node of deferred) appendPending(reflexDir, node);
122
+ return { ok: true, deferred: deferred.length };
123
+ }
124
+
125
+ /**
126
+ * Compact pending → procedures. Single-flight. Safe vs concurrent hot-path appends.
127
+ * @param {string} reflexDir path to .reflex/
128
+ */
129
+ export async function runGroomer(reflexDir) {
130
+ if (!tryAcquireGroomerLock(reflexDir)) return { ran: false, reason: "locked" };
131
+
123
132
  try {
124
- fs.writeFileSync(tmpFile, newFileContent, "utf8");
125
- fs.renameSync(tmpFile, proceduresFile);
126
- } catch (err) {
127
- logError(proceduresFile, `Failed atomic write: ${err.message}`);
133
+ migrateLegacyPending(reflexDir);
134
+
135
+ let creds;
136
+ try {
137
+ creds = resolveCredentials();
138
+ } catch (e) {
139
+ logError(reflexDir, e.message);
140
+ return { ran: false, reason: "creds" };
141
+ }
142
+ if (!creds) {
143
+ logError(
144
+ reflexDir,
145
+ "No API key found. Run `npx agent-procedures auth login` or set ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY.",
146
+ );
147
+ return { ran: false, reason: "creds" };
148
+ }
149
+
150
+ let batches = 0;
151
+ while (batches < 8) {
152
+ const processingFile = claimPendingBatch(reflexDir);
153
+ if (!processingFile) break;
154
+ const { ok, deferred } = await processBatch(reflexDir, processingFile, creds);
155
+ if (!ok) break;
156
+ batches++;
157
+ // Deferred rows were written back to pending; don't spin on the same ids.
158
+ if (deferred > 0) break;
159
+ if (countIngest(reflexDir) === 0) break;
160
+ }
161
+ return { ran: true, batches };
162
+ } finally {
163
+ releaseGroomerLock(reflexDir);
128
164
  }
129
165
  }
@@ -54,10 +54,12 @@ export default {
54
54
  return { type: "tool", ...base, tool, target, ok: name === "PostToolUse" };
55
55
  }
56
56
  if (name === "Stop") {
57
+ // stop_hook_active is a Stop-hook loop guard, not a user cancel.
58
+ // Claude's Stop does not fire on interrupt, so there is no interrupt signal here.
57
59
  return {
58
60
  type: "stop",
59
61
  ...base,
60
- interrupted: payload.stop_hook_active === true,
62
+ interrupted: false,
61
63
  busy: Array.isArray(payload.background_tasks) && payload.background_tasks.length > 0,
62
64
  };
63
65
  }
package/lib/init.js CHANGED
@@ -2,13 +2,13 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { DEFAULT_HARNESS, getHarness } from "./harnesses/index.js";
5
+ import { migrateLegacyPending } from "./store.js";
5
6
 
6
7
  const LIB_DIR = path.dirname(fileURLToPath(import.meta.url));
7
8
 
8
9
  const DEFAULT_CONFIG = {
9
10
  version: 1,
10
11
  harness: "claude",
11
- minConfidence: 0.7,
12
12
  store: {
13
13
  procedures: "procedures.jsonl",
14
14
  runs: "runs.jsonl",
@@ -38,27 +38,34 @@ function writeAlways(filePath, contents) {
38
38
 
39
39
  function ensureGitignore(cwd) {
40
40
  const gitignorePath = path.join(cwd, ".gitignore");
41
- const entry = ".reflex/traces/";
42
-
43
- if (!fs.existsSync(gitignorePath)) {
44
- fs.writeFileSync(gitignorePath, `${entry}\n`, "utf8");
45
- return "created";
46
- }
47
-
48
- const current = fs.readFileSync(gitignorePath, "utf8");
49
- const lines = current.split(/\r?\n/);
50
- const alreadyIgnored = lines.some(
51
- (line) =>
52
- line.trim() === ".reflex/traces/" || line.trim() === ".reflex/traces",
53
- );
54
-
55
- if (alreadyIgnored) {
56
- return "exists";
57
- }
58
-
59
- const suffix = current.endsWith("\n") || current.length === 0 ? "" : "\n";
60
- fs.writeFileSync(gitignorePath, `${current}${suffix}${entry}\n`, "utf8");
61
- return "updated";
41
+ const block = [
42
+ "# Reflex: ignore runtime state; commit only the shared allowlist",
43
+ ".reflex/*",
44
+ "!.reflex/config.json",
45
+ "!.reflex/procedures.jsonl",
46
+ "!.reflex/runs.jsonl",
47
+ "!.reflex/runtime.js",
48
+ ];
49
+ // A whole-dir ignore (".reflex/") would defeat the allowlist: git cannot
50
+ // re-include a file whose parent directory is excluded.
51
+ const managed = new Set([
52
+ ...block,
53
+ ".reflex",
54
+ ".reflex/",
55
+ ".reflex/traces",
56
+ ".reflex/traces/",
57
+ ]);
58
+ const existed = fs.existsSync(gitignorePath);
59
+ const current = existed ? fs.readFileSync(gitignorePath, "utf8") : "";
60
+ const kept = current
61
+ .split(/\r?\n/)
62
+ .filter((line) => !managed.has(line.trim()));
63
+ while (kept.length > 0 && kept.at(-1) === "") kept.pop();
64
+ const next = [...kept, ...(kept.length > 0 ? [""] : []), ...block].join("\n") + "\n";
65
+
66
+ if (next === current) return "exists";
67
+ fs.writeFileSync(gitignorePath, next, "utf8");
68
+ return existed ? "updated" : "created";
62
69
  }
63
70
 
64
71
  // One bundled file, built by `npm run build` into dist/runtime.js.
@@ -117,6 +124,7 @@ export function init(cwd = process.cwd(), { harness: harnessId = DEFAULT_HARNESS
117
124
  writeShim(cwd, harness);
118
125
  harness.register(cwd);
119
126
  results.hooks = "updated";
127
+ results.migrate = migrateLegacyPending(reflexDir).moved > 0 ? "moved" : "noop";
120
128
 
121
129
  return results;
122
130
  }
package/lib/main.js CHANGED
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { handleEvent } from "./engine.js";
5
5
  import { runGroomer } from "./groomer.js";
6
6
  import { getHarness } from "./harnesses/index.js";
7
+ import { isGroomerLocked } from "./store.js";
7
8
 
8
9
  // Bundled to <repo>/.reflex/runtime.js. That file sits inside .reflex/.
9
10
  const REFLEX_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -19,6 +20,7 @@ function readStdin() {
19
20
  }
20
21
 
21
22
  function spawnGroomer() {
23
+ if (isGroomerLocked(REFLEX_DIR)) return;
22
24
  const child = spawn(process.argv[0], [process.argv[1], "Groom"], { detached: true, stdio: "ignore" });
23
25
  child.unref();
24
26
  }
@@ -28,7 +30,7 @@ export async function run(harnessId, argv = process.argv.slice(2)) {
28
30
  if (!eventName) return;
29
31
 
30
32
  if (eventName === "Groom") {
31
- await runGroomer(path.join(REFLEX_DIR, "procedures.jsonl"));
33
+ await runGroomer(REFLEX_DIR);
32
34
  return;
33
35
  }
34
36
 
package/lib/recall.js ADDED
@@ -0,0 +1,108 @@
1
+ const STOP_WORDS = new Set([
2
+ "the",
3
+ "a",
4
+ "an",
5
+ "to",
6
+ "in",
7
+ "on",
8
+ "please",
9
+ "can",
10
+ "you",
11
+ "my",
12
+ "this",
13
+ "for",
14
+ "with",
15
+ ]);
16
+
17
+ export function tokenize(text) {
18
+ if (!text) return [];
19
+ return text
20
+ .toLowerCase()
21
+ .replace(/[^\w\s]+/g, " ")
22
+ .split(/\s+/)
23
+ .filter((w) => w.length > 1 && !STOP_WORDS.has(w));
24
+ }
25
+
26
+ function scoreTarget(promptTokens, targetString) {
27
+ const targetTokens = tokenize(targetString);
28
+ if (targetTokens.length === 0) return { coverage: 0, matchCount: 0, targetLen: 0 };
29
+
30
+ let matchCount = 0;
31
+ let hasHighValueWord = false;
32
+
33
+ for (const tWord of targetTokens) {
34
+ const isMatch = promptTokens.some(
35
+ (pWord) => pWord === tWord || (tWord.length >= 3 && pWord.startsWith(tWord)),
36
+ );
37
+ if (isMatch) {
38
+ matchCount++;
39
+ if (tWord.length >= 6) hasHighValueWord = true;
40
+ }
41
+ }
42
+
43
+ const coverage = matchCount / targetTokens.length;
44
+ const isReliable = coverage >= 0.75 && (matchCount >= 2 || hasHighValueWord);
45
+ if (!isReliable) return { coverage: 0, matchCount: 0, targetLen: 0 };
46
+
47
+ return { coverage, matchCount, targetLen: targetTokens.length };
48
+ }
49
+
50
+ /** Coverage of target tokens present in the prompt. 0 if below the reliability floor. */
51
+ export function calculateCoverage(promptTokens, targetString) {
52
+ return scoreTarget(promptTokens, targetString).coverage;
53
+ }
54
+
55
+ function isBetter(a, b) {
56
+ if (a.coverage !== b.coverage) return a.coverage > b.coverage;
57
+ if (a.matchCount !== b.matchCount) return a.matchCount > b.matchCount;
58
+ return a.targetLen > b.targetLen;
59
+ }
60
+
61
+ function scoreProcedure(promptTokens, node) {
62
+ let best = scoreTarget(promptTokens, node.trigger || "");
63
+ if (Array.isArray(node.aliases)) {
64
+ for (const alias of node.aliases) {
65
+ const next = scoreTarget(promptTokens, alias);
66
+ if (isBetter(next, best)) best = next;
67
+ }
68
+ }
69
+ return best;
70
+ }
71
+
72
+ function formatSteps(steps, requiresEnv = []) {
73
+ const required =
74
+ requiresEnv.length > 0 ? `Requires environment: ${requiresEnv.join(", ")}\n\n` : "";
75
+ return required + steps.map((s) => `- ${s.t}: ${s.target}`).join("\n");
76
+ }
77
+
78
+ /**
79
+ * Best-scoring root procedure for this prompt, or null.
80
+ * Returns { node, text } ready to inject.
81
+ */
82
+ export function recall(prompt, procs) {
83
+ if (!prompt) return null;
84
+
85
+ const promptTokens = tokenize(prompt);
86
+ let best = null;
87
+ let bestScore = { coverage: 0, matchCount: 0, targetLen: 0 };
88
+
89
+ for (const node of procs) {
90
+ if (node.enabled === false || node.parent_id) continue;
91
+ const score = scoreProcedure(promptTokens, node);
92
+ if (isBetter(score, bestScore)) {
93
+ bestScore = score;
94
+ best = node;
95
+ }
96
+ }
97
+
98
+ if (!best) return null;
99
+
100
+ let text = formatSteps(best.steps, best.requires_env);
101
+ const alt = procs.find((e) => e.parent_id === best.id && e.type === "alt" && e.enabled !== false);
102
+ if (alt) text += "\n\nAlternative path that worked:\n" + formatSteps(alt.steps, alt.requires_env);
103
+
104
+ return {
105
+ node: best,
106
+ text: `Reflex memory found a procedure for this task:\n\n${text}\n\nFollow these steps instead of figuring it out from scratch.`,
107
+ };
108
+ }