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/README.md +15 -2
- package/dist/runtime.js +496 -119
- package/lib/engine.js +58 -48
- package/lib/groomer.js +108 -72
- package/lib/harnesses/claude.js +8 -3
- package/lib/init.js +30 -22
- package/lib/main.js +3 -1
- package/lib/recall.js +110 -0
- package/lib/sanitize.js +181 -0
- package/lib/store.js +178 -5
- package/package.json +1 -1
package/lib/engine.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
appendPending,
|
|
3
3
|
appendTrace,
|
|
4
|
+
countIngest,
|
|
4
5
|
deleteTrace,
|
|
5
|
-
|
|
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
|
|
19
|
-
* inject into the agent's next turn
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
99
|
-
id
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
13
|
-
|
|
25
|
+
} catch {
|
|
26
|
+
/* nowhere to log */
|
|
14
27
|
}
|
|
15
28
|
}
|
|
16
29
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 ${
|
|
49
|
+
Here are ${batch.length} new execution nodes/edges added by a coding agent.
|
|
55
50
|
|
|
56
51
|
<edges>
|
|
57
|
-
${JSON.stringify(
|
|
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(
|
|
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
|
|
94
|
-
logError(
|
|
95
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
}
|
package/lib/harnesses/claude.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
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(
|
|
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
|
+
}
|