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/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.
@@ -15,8 +18,9 @@ import {
15
18
  * { type: "tool", sessionId, promptId, tool, target, ok }
16
19
  * { type: "stop", sessionId, promptId, interrupted, busy }
17
20
  *
18
- * Returns { context: string | null }. `context` is text the harness should
19
- * inject into the agent's next turn, if it knows how.
21
+ * Returns { context: string | null, notice?: string | null }.
22
+ * `context` is text the harness should inject into the agent's next turn.
23
+ * `notice` is a short user-visible line; the harness maps it if the host supports it.
20
24
  */
21
25
  export function handleEvent(evt, reflexDir, deps = {}) {
22
26
  if (!evt || !evt.sessionId || !evt.promptId) return { context: null };
@@ -33,47 +37,35 @@ export const GROOM_THRESHOLD = 5;
33
37
 
34
38
  function onPrompt(evt, reflexDir) {
35
39
  const { sessionId, promptId, prompt } = evt;
36
- startTrace(reflexDir, sessionId, promptId, { t: "Goal", prompt });
40
+ const sanitized = sanitizeCommand(prompt);
41
+ startTrace(reflexDir, sessionId, promptId, {
42
+ t: "Goal",
43
+ prompt: sanitized.text,
44
+ ...(sanitized.requiresEnv.length > 0 ? { requires_env: sanitized.requiresEnv } : {}),
45
+ ...(sanitized.unsafe ? { contains_unresolvable_secret: true } : {}),
46
+ });
37
47
 
38
- const hit = recall(prompt, readProcedures(reflexDir));
39
- if (!hit) return { context: null };
48
+ const hit = recall(sanitized.text, readAllProcedures(reflexDir));
49
+ if (!hit) return { context: null, notice: null };
40
50
 
41
51
  appendTrace(reflexDir, sessionId, promptId, { t: "Recall", hit: hit.node.id });
42
- return { context: hit.text };
43
- }
44
-
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;
52
+ return { context: hit.text, notice: hit.notice };
71
53
  }
72
54
 
73
55
  // --- tool: trace ----------------------------------------------------------
74
56
 
75
57
  function onTool(evt, reflexDir) {
76
- appendTrace(reflexDir, evt.sessionId, evt.promptId, { t: evt.tool, target: evt.target || "", ok: evt.ok });
58
+ const sanitized =
59
+ evt.tool === "Bash" || evt.tool === "bash"
60
+ ? sanitizeCommand(evt.target)
61
+ : sanitizeText(evt.target);
62
+ appendTrace(reflexDir, evt.sessionId, evt.promptId, {
63
+ t: evt.tool,
64
+ target: sanitized.text,
65
+ ok: evt.ok,
66
+ ...(sanitized.requiresEnv.length > 0 ? { requires_env: sanitized.requiresEnv } : {}),
67
+ ...(sanitized.unsafe ? { contains_unresolvable_secret: true } : {}),
68
+ });
77
69
  return { context: null };
78
70
  }
79
71
 
@@ -83,48 +75,63 @@ function onStop(evt, reflexDir, deps) {
83
75
  const { sessionId, promptId } = evt;
84
76
  const lines = readTrace(reflexDir, sessionId, promptId);
85
77
  if (lines.length === 0) return { context: null };
78
+ if (lines.some((line) => line.contains_unresolvable_secret)) {
79
+ deleteTrace(reflexDir, sessionId, promptId);
80
+ return { context: null };
81
+ }
86
82
 
87
83
  let goal = "";
88
84
  let recallHit = null;
85
+ let hasGoal = false;
89
86
  for (const l of lines) {
90
- if (l.t === "Goal") goal = l.prompt;
87
+ if (l.t === "Goal") {
88
+ goal = l.prompt;
89
+ hasGoal = true;
90
+ }
91
91
  if (l.t === "Recall" && l.hit) recallHit = l.hit;
92
92
  }
93
+ // Continuation after another Stop hook can recreate a trace via appendTrace
94
+ // with no Goal line. Don't learn from that stump.
95
+ if (!hasGoal) {
96
+ deleteTrace(reflexDir, sessionId, promptId);
97
+ return { context: null };
98
+ }
93
99
 
94
100
  if (judge(lines, evt)) {
95
- const steps = synthesize(lines);
101
+ const { steps, requiresEnv } = synthesize(lines);
96
102
  if (steps.length > 0) {
103
+ const id = Math.random().toString(36).slice(2, 11);
97
104
  if (!recallHit) {
98
- appendProcedure(reflexDir, {
99
- id: Math.random().toString(36).slice(2, 11),
105
+ appendPending(reflexDir, {
106
+ id,
100
107
  title: goal,
101
108
  trigger: goal,
102
109
  steps,
110
+ ...(requiresEnv.length > 0 ? { requires_env: requiresEnv } : {}),
103
111
  enabled: true,
104
- hits: 0,
105
- last_used_at: new Date().toISOString(),
106
112
  created_at: new Date().toISOString(),
107
113
  status: "pending_review",
108
114
  });
109
115
  } else {
110
- const parent = readProcedures(reflexDir).find((p) => p.id === recallHit);
116
+ const parent = readAllProcedures(reflexDir).find((p) => p.id === recallHit);
111
117
  const parentSteps = parent ? parent.steps : [];
112
118
  const same =
113
119
  parentSteps.length === steps.length &&
114
120
  parentSteps.every((s, i) => s.t === steps[i].t && s.target === steps[i].target);
115
121
  if (!same) {
116
- appendProcedure(reflexDir, {
122
+ appendPending(reflexDir, {
123
+ id,
117
124
  parent_id: recallHit,
118
125
  type: "alt",
119
126
  steps,
127
+ ...(requiresEnv.length > 0 ? { requires_env: requiresEnv } : {}),
120
128
  created_at: new Date().toISOString(),
121
129
  status: "pending_review",
122
130
  });
123
131
  }
124
132
  }
125
133
 
126
- const pending = readProcedures(reflexDir).filter((p) => p.status === "pending_review").length;
127
- if (pending >= GROOM_THRESHOLD && deps.spawnGroomer) deps.spawnGroomer();
134
+ if (countIngest(reflexDir) >= GROOM_THRESHOLD && deps.spawnGroomer) deps.spawnGroomer();
128
135
  }
129
136
  }
130
137
 
@@ -165,15 +172,18 @@ function judge(lines, evt) {
165
172
 
166
173
  function synthesize(lines) {
167
174
  const steps = [];
175
+ const requiresEnv = new Set();
168
176
  for (const l of lines) {
177
+ for (const name of l.requires_env || []) requiresEnv.add(name);
169
178
  if (l.t === "Goal" || l.t === "Recall" || l.ok === false) continue;
170
179
  if (l.t === "Read" || l.t === "View") continue;
171
180
  if (l.t === "Bash" || l.t === "bash") {
172
181
  const target = l.target || "";
182
+ if (!target.trim()) continue;
173
183
  if (PURE_READ_CMDS.includes(target.trim().split(" ")[0])) continue;
174
184
  if (/^git (status|log|diff)/.test(target)) continue;
175
185
  }
176
186
  steps.push({ t: l.t, target: l.target });
177
187
  }
178
- return steps;
188
+ return { steps, requiresEnv: [...requiresEnv] };
179
189
  }
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
  }
@@ -68,8 +70,11 @@ export default {
68
70
  render(evt, result) {
69
71
  if (evt.type !== "prompt") return "";
70
72
  if (!result.context) return "{}";
71
- return JSON.stringify({
73
+ const out = {
72
74
  hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: result.context },
73
- });
75
+ };
76
+ // Top-level systemMessage is shown to the user; not model context.
77
+ if (result.notice) out.systemMessage = result.notice;
78
+ return JSON.stringify(out);
74
79
  },
75
80
  };
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,110 @@
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, notice } — text for the agent, notice for the harness UI.
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
+ const label = best.trigger || "procedure";
105
+ return {
106
+ node: best,
107
+ text: `Reflex memory found a procedure for this task:\n\n${text}\n\nFollow these steps instead of figuring it out from scratch.`,
108
+ notice: `Reflex recalled: "${label}"`,
109
+ };
110
+ }