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/README.md +15 -2
- package/dist/runtime.js +488 -115
- package/lib/engine.js +54 -45
- package/lib/groomer.js +108 -72
- package/lib/harnesses/claude.js +3 -1
- package/lib/init.js +30 -22
- package/lib/main.js +3 -1
- package/lib/recall.js +108 -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.
|
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
99
|
-
id
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
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,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
|
+
}
|