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/dist/runtime.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Generated by agent-procedures@0.3.
|
|
1
|
+
// Generated by agent-procedures@0.3.2. Do not edit.
|
|
2
2
|
// To upgrade: npx agent-procedures init
|
|
3
3
|
|
|
4
4
|
// lib/main.js
|
|
@@ -9,16 +9,59 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
// lib/store.js
|
|
10
10
|
import fs from "node:fs";
|
|
11
11
|
import path from "node:path";
|
|
12
|
+
var GROOM_LOCK_STALE_MS = 15 * 60 * 1e3;
|
|
12
13
|
function paths(reflexDir) {
|
|
13
14
|
return {
|
|
14
15
|
reflexDir,
|
|
15
16
|
tracesDir: path.join(reflexDir, "traces"),
|
|
16
|
-
proceduresFile: path.join(reflexDir, "procedures.jsonl")
|
|
17
|
+
proceduresFile: path.join(reflexDir, "procedures.jsonl"),
|
|
18
|
+
pendingFile: path.join(reflexDir, "pending.jsonl"),
|
|
19
|
+
processingFile: path.join(reflexDir, "processing.jsonl"),
|
|
20
|
+
groomerLockFile: path.join(reflexDir, "groomer.lock")
|
|
17
21
|
};
|
|
18
22
|
}
|
|
19
23
|
function readJsonl(file) {
|
|
20
24
|
if (!fs.existsSync(file)) return [];
|
|
21
|
-
return fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((l) =>
|
|
25
|
+
return fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((l) => {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(l);
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}).filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
function sleepSync(ms) {
|
|
34
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
35
|
+
}
|
|
36
|
+
function renameWithRetry(from, to, attempts = 8) {
|
|
37
|
+
for (let i = 0; i < attempts; i++) {
|
|
38
|
+
try {
|
|
39
|
+
fs.renameSync(from, to);
|
|
40
|
+
return;
|
|
41
|
+
} catch (e) {
|
|
42
|
+
const retryable = e.code === "EBUSY" || e.code === "EPERM" || e.code === "EACCES";
|
|
43
|
+
if (!retryable || i === attempts - 1) throw e;
|
|
44
|
+
sleepSync(15 * (i + 1));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function unlinkWithRetry(file, attempts = 8) {
|
|
49
|
+
for (let i = 0; i < attempts; i++) {
|
|
50
|
+
try {
|
|
51
|
+
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
52
|
+
return;
|
|
53
|
+
} catch (e) {
|
|
54
|
+
const retryable = e.code === "EBUSY" || e.code === "EPERM" || e.code === "EACCES";
|
|
55
|
+
if (!retryable || i === attempts - 1) throw e;
|
|
56
|
+
sleepSync(15 * (i + 1));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function writeJsonlAtomic(file, nodes) {
|
|
61
|
+
const content = nodes.length ? nodes.map((n) => JSON.stringify(n)).join("\n") + "\n" : "";
|
|
62
|
+
const tmp = file + ".tmp";
|
|
63
|
+
fs.writeFileSync(tmp, content, "utf8");
|
|
64
|
+
renameWithRetry(tmp, file);
|
|
22
65
|
}
|
|
23
66
|
function traceFile(reflexDir, sessionId, promptId) {
|
|
24
67
|
return path.join(paths(reflexDir).tracesDir, sessionId, `${promptId}.jsonl`);
|
|
@@ -39,17 +82,326 @@ function readTrace(reflexDir, sessionId, promptId) {
|
|
|
39
82
|
function deleteTrace(reflexDir, sessionId, promptId) {
|
|
40
83
|
try {
|
|
41
84
|
fs.unlinkSync(traceFile(reflexDir, sessionId, promptId));
|
|
42
|
-
} catch
|
|
85
|
+
} catch {
|
|
43
86
|
}
|
|
44
87
|
}
|
|
45
88
|
function readProcedures(reflexDir) {
|
|
46
89
|
return readJsonl(paths(reflexDir).proceduresFile);
|
|
47
90
|
}
|
|
48
|
-
function
|
|
49
|
-
|
|
50
|
-
|
|
91
|
+
function readPending(reflexDir) {
|
|
92
|
+
return readJsonl(paths(reflexDir).pendingFile);
|
|
93
|
+
}
|
|
94
|
+
function readProcessing(reflexDir) {
|
|
95
|
+
return readJsonl(paths(reflexDir).processingFile);
|
|
96
|
+
}
|
|
97
|
+
function readAllProcedures(reflexDir) {
|
|
98
|
+
return [
|
|
99
|
+
...readProcedures(reflexDir),
|
|
100
|
+
...readPending(reflexDir),
|
|
101
|
+
...readProcessing(reflexDir)
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
function countIngest(reflexDir) {
|
|
105
|
+
return readPending(reflexDir).length + readProcessing(reflexDir).length;
|
|
106
|
+
}
|
|
107
|
+
function appendPending(reflexDir, node) {
|
|
108
|
+
const file = paths(reflexDir).pendingFile;
|
|
109
|
+
fs.mkdirSync(reflexDir, { recursive: true });
|
|
51
110
|
fs.appendFileSync(file, JSON.stringify(node) + "\n", "utf8");
|
|
52
111
|
}
|
|
112
|
+
function migrateLegacyPending(reflexDir) {
|
|
113
|
+
const p = paths(reflexDir);
|
|
114
|
+
if (!fs.existsSync(p.proceduresFile)) return { moved: 0 };
|
|
115
|
+
const nodes = readJsonl(p.proceduresFile);
|
|
116
|
+
const pending = nodes.filter((n) => n.status === "pending_review");
|
|
117
|
+
if (pending.length === 0) return { moved: 0 };
|
|
118
|
+
const stable = nodes.filter((n) => n.status !== "pending_review");
|
|
119
|
+
for (const node of pending) appendPending(reflexDir, node);
|
|
120
|
+
writeJsonlAtomic(p.proceduresFile, stable);
|
|
121
|
+
return { moved: pending.length };
|
|
122
|
+
}
|
|
123
|
+
function pidAlive(pid) {
|
|
124
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
125
|
+
try {
|
|
126
|
+
process.kill(pid, 0);
|
|
127
|
+
return true;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function readLock(lockFile) {
|
|
133
|
+
try {
|
|
134
|
+
return JSON.parse(fs.readFileSync(lockFile, "utf8"));
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function isGroomerLocked(reflexDir, now = Date.now()) {
|
|
140
|
+
const lockFile = paths(reflexDir).groomerLockFile;
|
|
141
|
+
if (!fs.existsSync(lockFile)) return false;
|
|
142
|
+
const lock = readLock(lockFile);
|
|
143
|
+
if (!lock) return false;
|
|
144
|
+
const age = typeof lock.startedAt === "number" ? now - lock.startedAt : Infinity;
|
|
145
|
+
if (age >= GROOM_LOCK_STALE_MS) return false;
|
|
146
|
+
return pidAlive(lock.pid);
|
|
147
|
+
}
|
|
148
|
+
function tryAcquireGroomerLock(reflexDir, now = Date.now()) {
|
|
149
|
+
const lockFile = paths(reflexDir).groomerLockFile;
|
|
150
|
+
fs.mkdirSync(reflexDir, { recursive: true });
|
|
151
|
+
if (fs.existsSync(lockFile)) {
|
|
152
|
+
if (isGroomerLocked(reflexDir, now)) return false;
|
|
153
|
+
try {
|
|
154
|
+
fs.unlinkSync(lockFile);
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const fd = fs.openSync(lockFile, "wx");
|
|
161
|
+
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, startedAt: now }) + "\n", "utf8");
|
|
162
|
+
fs.closeSync(fd);
|
|
163
|
+
return true;
|
|
164
|
+
} catch (e) {
|
|
165
|
+
if (e.code === "EEXIST") return false;
|
|
166
|
+
throw e;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function releaseGroomerLock(reflexDir) {
|
|
170
|
+
const lockFile = paths(reflexDir).groomerLockFile;
|
|
171
|
+
try {
|
|
172
|
+
const lock = readLock(lockFile);
|
|
173
|
+
if (lock && lock.pid !== process.pid) return;
|
|
174
|
+
fs.unlinkSync(lockFile);
|
|
175
|
+
} catch {
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function claimPendingBatch(reflexDir) {
|
|
179
|
+
const { pendingFile, processingFile } = paths(reflexDir);
|
|
180
|
+
if (fs.existsSync(processingFile)) return processingFile;
|
|
181
|
+
if (!fs.existsSync(pendingFile)) return null;
|
|
182
|
+
const stat = fs.statSync(pendingFile);
|
|
183
|
+
if (stat.size === 0) {
|
|
184
|
+
unlinkWithRetry(pendingFile);
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
renameWithRetry(pendingFile, processingFile);
|
|
188
|
+
return processingFile;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// lib/sanitize.js
|
|
192
|
+
var PROVIDER_RULES = [
|
|
193
|
+
["anthropic-api-key", /\b(sk-ant-api03-[a-zA-Z0-9_-]{93}AA)(?:[\x60'"\s;]|\\[nr]|$)/],
|
|
194
|
+
["aws-access-token", /\b((?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\b/],
|
|
195
|
+
["cloudflare-api-key", /[\w.-]{0,50}?(?:cloudflare)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9_-]{40})(?:[\x60'"\s;]|\\[nr]|$)/i],
|
|
196
|
+
["datadog-access-token", /[\w.-]{0,50}?(?:datadog)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{40})(?:[\x60'"\s;]|\\[nr]|$)/i],
|
|
197
|
+
["digitalocean-access-token", /\b(doo_v1_[a-f0-9]{64})(?:[\x60'"\s;]|\\[nr]|$)/],
|
|
198
|
+
["discord-api-token", /[\w.-]{0,50}?(?:discord)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-f0-9]{64})(?:[\x60'"\s;]|\\[nr]|$)/i],
|
|
199
|
+
["dropbox-short-lived-api-token", /[\w.-]{0,50}?(?:dropbox)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(sl\.[a-z0-9\-=_]{135})(?:[\x60'"\s;]|\\[nr]|$)/i],
|
|
200
|
+
["gcp-api-key", /\b(AIza[\w-]{35})(?:[\x60'"\s;]|\\[nr]|$)/],
|
|
201
|
+
["github-pat", /ghp_[0-9a-zA-Z]{36}/],
|
|
202
|
+
["gitlab-pat", /glpat-[\w-]{20}/],
|
|
203
|
+
["heroku-api-key-v2", /\b((HRKU-AA[0-9a-zA-Z_-]{58}))(?:[\x60'"\s;]|\\[nr]|$)/],
|
|
204
|
+
["mailgun-private-api-token", /[\w.-]{0,50}?(?:mailgun)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(key-[a-f0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)/i],
|
|
205
|
+
["npm-access-token", /\b(npm_[a-z0-9]{36})(?:[\x60'"\s;]|\\[nr]|$)/i],
|
|
206
|
+
["openai-api-key", /\b(sk-(?:proj|svcacct|admin)-(?:[A-Za-z0-9_-]{74}|[A-Za-z0-9_-]{58})T3BlbkFJ(?:[A-Za-z0-9_-]{74}|[A-Za-z0-9_-]{58})\b|sk-[a-zA-Z0-9]{20}T3BlbkFJ[a-zA-Z0-9]{20})(?:[\x60'"\s;]|\\[nr]|$)/],
|
|
207
|
+
["pypi-upload-token", /pypi-AgEIcHlwaS5vcmc[\w-]{50,1000}/],
|
|
208
|
+
["sendgrid-api-token", /\b(SG\.[a-z0-9=_\-.]{66})(?:[\x60'"\s;]|\\[nr]|$)/i],
|
|
209
|
+
["shopify-access-token", /shpat_[a-fA-F0-9]{32}/],
|
|
210
|
+
["slack-bot-token", /xoxb-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*/],
|
|
211
|
+
["stripe-access-token", /\b((?:sk|rk)_(?:test|live|prod)_[a-zA-Z0-9]{10,99})(?:[\x60'"\s;]|\\[nr]|$)/],
|
|
212
|
+
["twilio-api-key", /SK[0-9a-fA-F]{32}/],
|
|
213
|
+
["private-key", /-----BEGIN[ A-Z0-9_-]{0,100}PRIVATE KEY(?: BLOCK)?-----[\s\S-]{64,}?KEY(?: BLOCK)?-----/i],
|
|
214
|
+
["jwt", /\b(ey[a-zA-Z0-9]{17,}\.ey[a-zA-Z0-9/\\_-]{17,}\.(?:[a-zA-Z0-9/\\_-]{10,}={0,2})?)(?:[\x60'"\s;]|\\[nr]|$)/]
|
|
215
|
+
];
|
|
216
|
+
var SENSITIVE_NAME = /(?:^|_)(?:API_?)?(?:KEY|TOKEN|SECRET|PASS|PASSWORD|AUTH|CREDENTIALS?|DATABASE_URL|DB_URL|CONNECTION_STRING|DSN)(?:_|$)/i;
|
|
217
|
+
var ASSIGNMENT = /\b(export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"]*)"|'([^']*)'|([^\s;&|"']+))(\s*)/g;
|
|
218
|
+
var CREDENTIAL_URL = /^[a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:[^@\s/]+@/i;
|
|
219
|
+
function result(text, requiresEnv = [], unsafe = false) {
|
|
220
|
+
return { text, requiresEnv: [...new Set(requiresEnv)], unsafe };
|
|
221
|
+
}
|
|
222
|
+
function replaceDetectedSecrets(input) {
|
|
223
|
+
let text = input;
|
|
224
|
+
let unsafe = false;
|
|
225
|
+
for (const [id, regex] of PROVIDER_RULES) {
|
|
226
|
+
let match;
|
|
227
|
+
while (match = regex.exec(text)) {
|
|
228
|
+
const secret = match[1] || match[0];
|
|
229
|
+
const offset = match[0].indexOf(secret);
|
|
230
|
+
const start = match.index + offset;
|
|
231
|
+
text = text.slice(0, start) + `[secret:${id}]` + text.slice(start + secret.length);
|
|
232
|
+
unsafe = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { text, unsafe };
|
|
236
|
+
}
|
|
237
|
+
function sanitizeText(input = "") {
|
|
238
|
+
const detected = replaceDetectedSecrets(String(input));
|
|
239
|
+
return result(detected.text, [], detected.unsafe);
|
|
240
|
+
}
|
|
241
|
+
function sanitizeCommand(input = "") {
|
|
242
|
+
let text = String(input);
|
|
243
|
+
const requiresEnv = [];
|
|
244
|
+
let removedAssignment = false;
|
|
245
|
+
text = text.replace(ASSIGNMENT, (match, exported, name, double, single, bare, trailing, offset, source) => {
|
|
246
|
+
const value = double ?? single ?? bare ?? "";
|
|
247
|
+
const valueIsSecret = SENSITIVE_NAME.test(name) || CREDENTIAL_URL.test(value) || PROVIDER_RULES.some(([, regex]) => regex.test(value));
|
|
248
|
+
if (!valueIsSecret) return match;
|
|
249
|
+
requiresEnv.push(name);
|
|
250
|
+
const before = source.slice(0, offset);
|
|
251
|
+
const isCommandAssignment = exported || !before.trim() || /(?:&&|\|\||;)\s*$/.test(before);
|
|
252
|
+
if (isCommandAssignment) {
|
|
253
|
+
removedAssignment = true;
|
|
254
|
+
return "";
|
|
255
|
+
}
|
|
256
|
+
const placeholder = bare !== void 0 ? `\${${name}}` : `"\${${name}}"`;
|
|
257
|
+
return `${name}=${placeholder}${trailing}`;
|
|
258
|
+
});
|
|
259
|
+
text = text.replace(
|
|
260
|
+
/'(\bAuthorization\s*:\s*Bearer\s+)([^']+)'/gi,
|
|
261
|
+
(_match, prefix) => {
|
|
262
|
+
requiresEnv.push("AUTH_TOKEN");
|
|
263
|
+
return `"${prefix}\${AUTH_TOKEN}"`;
|
|
264
|
+
}
|
|
265
|
+
);
|
|
266
|
+
text = text.replace(
|
|
267
|
+
/'(\bAuthorization\s*:\s*Basic\s+)([^']+)'/gi,
|
|
268
|
+
(_match, prefix) => {
|
|
269
|
+
requiresEnv.push("BASIC_AUTH");
|
|
270
|
+
return `"${prefix}\${BASIC_AUTH}"`;
|
|
271
|
+
}
|
|
272
|
+
);
|
|
273
|
+
text = text.replace(
|
|
274
|
+
/'([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)([^@']+)(@[^']+)'/gi,
|
|
275
|
+
(_match, prefix, _password, suffix) => {
|
|
276
|
+
requiresEnv.push("PASSWORD");
|
|
277
|
+
return `"${prefix}\${PASSWORD}${suffix}"`;
|
|
278
|
+
}
|
|
279
|
+
);
|
|
280
|
+
text = text.replace(
|
|
281
|
+
/(\bAuthorization\s*:\s*Bearer\s+)([^"'\s]+)/gi,
|
|
282
|
+
(_match, prefix) => {
|
|
283
|
+
requiresEnv.push("AUTH_TOKEN");
|
|
284
|
+
return `${prefix}\${AUTH_TOKEN}`;
|
|
285
|
+
}
|
|
286
|
+
);
|
|
287
|
+
text = text.replace(
|
|
288
|
+
/(\bAuthorization\s*:\s*Basic\s+)([^"'\s]+)/gi,
|
|
289
|
+
(_match, prefix) => {
|
|
290
|
+
requiresEnv.push("BASIC_AUTH");
|
|
291
|
+
return `${prefix}\${BASIC_AUTH}`;
|
|
292
|
+
}
|
|
293
|
+
);
|
|
294
|
+
text = text.replace(
|
|
295
|
+
/([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)([^@\s/]+)(@)/gi,
|
|
296
|
+
(_match, prefix, _password, suffix) => {
|
|
297
|
+
requiresEnv.push("PASSWORD");
|
|
298
|
+
return `${prefix}\${PASSWORD}${suffix}`;
|
|
299
|
+
}
|
|
300
|
+
);
|
|
301
|
+
text = text.replace(
|
|
302
|
+
/(--(?:password|passwd|token|secret|api-key|key)(?:=|\s+))(["']?)([^"'\s;&|]+)\2/gi,
|
|
303
|
+
(_match, prefix, quote) => {
|
|
304
|
+
const name = prefix.match(/^--([a-z-]+)/i)[1].replace(/-/g, "_").toUpperCase();
|
|
305
|
+
requiresEnv.push(name);
|
|
306
|
+
const safeQuote = quote === "'" ? '"' : quote;
|
|
307
|
+
return `${prefix}${safeQuote}\${${name}}${safeQuote}`;
|
|
308
|
+
}
|
|
309
|
+
);
|
|
310
|
+
if (removedAssignment) {
|
|
311
|
+
text = text.replace(/^\s*(?:&&|\|\||;)\s*/, "").replace(/\s*(?:&&|\|\||;)\s*$/, "").replace(/(?:&&|\|\||;)\s*(?=(?:&&|\|\||;))/g, "").trim();
|
|
312
|
+
}
|
|
313
|
+
const detected = replaceDetectedSecrets(text);
|
|
314
|
+
return result(detected.text, requiresEnv, detected.unsafe);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// lib/recall.js
|
|
318
|
+
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
319
|
+
"the",
|
|
320
|
+
"a",
|
|
321
|
+
"an",
|
|
322
|
+
"to",
|
|
323
|
+
"in",
|
|
324
|
+
"on",
|
|
325
|
+
"please",
|
|
326
|
+
"can",
|
|
327
|
+
"you",
|
|
328
|
+
"my",
|
|
329
|
+
"this",
|
|
330
|
+
"for",
|
|
331
|
+
"with"
|
|
332
|
+
]);
|
|
333
|
+
function tokenize(text) {
|
|
334
|
+
if (!text) return [];
|
|
335
|
+
return text.toLowerCase().replace(/[^\w\s]+/g, " ").split(/\s+/).filter((w) => w.length > 1 && !STOP_WORDS.has(w));
|
|
336
|
+
}
|
|
337
|
+
function scoreTarget(promptTokens, targetString) {
|
|
338
|
+
const targetTokens = tokenize(targetString);
|
|
339
|
+
if (targetTokens.length === 0) return { coverage: 0, matchCount: 0, targetLen: 0 };
|
|
340
|
+
let matchCount = 0;
|
|
341
|
+
let hasHighValueWord = false;
|
|
342
|
+
for (const tWord of targetTokens) {
|
|
343
|
+
const isMatch = promptTokens.some(
|
|
344
|
+
(pWord) => pWord === tWord || tWord.length >= 3 && pWord.startsWith(tWord)
|
|
345
|
+
);
|
|
346
|
+
if (isMatch) {
|
|
347
|
+
matchCount++;
|
|
348
|
+
if (tWord.length >= 6) hasHighValueWord = true;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const coverage = matchCount / targetTokens.length;
|
|
352
|
+
const isReliable = coverage >= 0.75 && (matchCount >= 2 || hasHighValueWord);
|
|
353
|
+
if (!isReliable) return { coverage: 0, matchCount: 0, targetLen: 0 };
|
|
354
|
+
return { coverage, matchCount, targetLen: targetTokens.length };
|
|
355
|
+
}
|
|
356
|
+
function isBetter(a, b) {
|
|
357
|
+
if (a.coverage !== b.coverage) return a.coverage > b.coverage;
|
|
358
|
+
if (a.matchCount !== b.matchCount) return a.matchCount > b.matchCount;
|
|
359
|
+
return a.targetLen > b.targetLen;
|
|
360
|
+
}
|
|
361
|
+
function scoreProcedure(promptTokens, node) {
|
|
362
|
+
let best = scoreTarget(promptTokens, node.trigger || "");
|
|
363
|
+
if (Array.isArray(node.aliases)) {
|
|
364
|
+
for (const alias of node.aliases) {
|
|
365
|
+
const next = scoreTarget(promptTokens, alias);
|
|
366
|
+
if (isBetter(next, best)) best = next;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return best;
|
|
370
|
+
}
|
|
371
|
+
function formatSteps(steps, requiresEnv = []) {
|
|
372
|
+
const required = requiresEnv.length > 0 ? `Requires environment: ${requiresEnv.join(", ")}
|
|
373
|
+
|
|
374
|
+
` : "";
|
|
375
|
+
return required + steps.map((s) => `- ${s.t}: ${s.target}`).join("\n");
|
|
376
|
+
}
|
|
377
|
+
function recall(prompt, procs) {
|
|
378
|
+
if (!prompt) return null;
|
|
379
|
+
const promptTokens = tokenize(prompt);
|
|
380
|
+
let best = null;
|
|
381
|
+
let bestScore = { coverage: 0, matchCount: 0, targetLen: 0 };
|
|
382
|
+
for (const node of procs) {
|
|
383
|
+
if (node.enabled === false || node.parent_id) continue;
|
|
384
|
+
const score = scoreProcedure(promptTokens, node);
|
|
385
|
+
if (isBetter(score, bestScore)) {
|
|
386
|
+
bestScore = score;
|
|
387
|
+
best = node;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (!best) return null;
|
|
391
|
+
let text = formatSteps(best.steps, best.requires_env);
|
|
392
|
+
const alt = procs.find((e) => e.parent_id === best.id && e.type === "alt" && e.enabled !== false);
|
|
393
|
+
if (alt) text += "\n\nAlternative path that worked:\n" + formatSteps(alt.steps, alt.requires_env);
|
|
394
|
+
const label = best.trigger || "procedure";
|
|
395
|
+
return {
|
|
396
|
+
node: best,
|
|
397
|
+
text: `Reflex memory found a procedure for this task:
|
|
398
|
+
|
|
399
|
+
${text}
|
|
400
|
+
|
|
401
|
+
Follow these steps instead of figuring it out from scratch.`,
|
|
402
|
+
notice: `Reflex recalled: "${label}"`
|
|
403
|
+
};
|
|
404
|
+
}
|
|
53
405
|
|
|
54
406
|
// lib/engine.js
|
|
55
407
|
function handleEvent(evt, reflexDir, deps = {}) {
|
|
@@ -62,84 +414,83 @@ function handleEvent(evt, reflexDir, deps = {}) {
|
|
|
62
414
|
var GROOM_THRESHOLD = 5;
|
|
63
415
|
function onPrompt(evt, reflexDir) {
|
|
64
416
|
const { sessionId, promptId, prompt } = evt;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
417
|
+
const sanitized = sanitizeCommand(prompt);
|
|
418
|
+
startTrace(reflexDir, sessionId, promptId, {
|
|
419
|
+
t: "Goal",
|
|
420
|
+
prompt: sanitized.text,
|
|
421
|
+
...sanitized.requiresEnv.length > 0 ? { requires_env: sanitized.requiresEnv } : {},
|
|
422
|
+
...sanitized.unsafe ? { contains_unresolvable_secret: true } : {}
|
|
423
|
+
});
|
|
424
|
+
const hit = recall(sanitized.text, readAllProcedures(reflexDir));
|
|
425
|
+
if (!hit) return { context: null, notice: null };
|
|
68
426
|
appendTrace(reflexDir, sessionId, promptId, { t: "Recall", hit: hit.node.id });
|
|
69
|
-
return { context: hit.text };
|
|
70
|
-
}
|
|
71
|
-
function recall(prompt, procs) {
|
|
72
|
-
if (!prompt) return null;
|
|
73
|
-
const p = prompt.toLowerCase();
|
|
74
|
-
const fmt = (steps) => steps.map((s) => `- ${s.t}: ${s.target}`).join("\n");
|
|
75
|
-
for (const node of procs) {
|
|
76
|
-
if (node.enabled === false || node.parent_id) continue;
|
|
77
|
-
let match = node.trigger && p.includes(node.trigger.toLowerCase());
|
|
78
|
-
if (!match && Array.isArray(node.aliases)) {
|
|
79
|
-
match = node.aliases.some((a) => p.includes(a.toLowerCase()));
|
|
80
|
-
}
|
|
81
|
-
if (!match) continue;
|
|
82
|
-
let text = fmt(node.steps);
|
|
83
|
-
const alt = procs.find((e) => e.parent_id === node.id && e.type === "alt" && e.enabled !== false);
|
|
84
|
-
const onFail = procs.find((e) => e.parent_id === node.id && e.type === "on_fail" && e.enabled !== false);
|
|
85
|
-
if (alt) text += "\n\nAlternative path that worked:\n" + fmt(alt.steps);
|
|
86
|
-
if (onFail) text += "\n\nIf it fails, this recovery worked:\n" + fmt(onFail.steps);
|
|
87
|
-
return {
|
|
88
|
-
node,
|
|
89
|
-
text: `Reflex memory found a procedure for this task:
|
|
90
|
-
|
|
91
|
-
${text}
|
|
92
|
-
|
|
93
|
-
Follow these steps instead of figuring it out from scratch.`
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
return null;
|
|
427
|
+
return { context: hit.text, notice: hit.notice };
|
|
97
428
|
}
|
|
98
429
|
function onTool(evt, reflexDir) {
|
|
99
|
-
|
|
430
|
+
const sanitized = evt.tool === "Bash" || evt.tool === "bash" ? sanitizeCommand(evt.target) : sanitizeText(evt.target);
|
|
431
|
+
appendTrace(reflexDir, evt.sessionId, evt.promptId, {
|
|
432
|
+
t: evt.tool,
|
|
433
|
+
target: sanitized.text,
|
|
434
|
+
ok: evt.ok,
|
|
435
|
+
...sanitized.requiresEnv.length > 0 ? { requires_env: sanitized.requiresEnv } : {},
|
|
436
|
+
...sanitized.unsafe ? { contains_unresolvable_secret: true } : {}
|
|
437
|
+
});
|
|
100
438
|
return { context: null };
|
|
101
439
|
}
|
|
102
440
|
function onStop(evt, reflexDir, deps) {
|
|
103
441
|
const { sessionId, promptId } = evt;
|
|
104
442
|
const lines = readTrace(reflexDir, sessionId, promptId);
|
|
105
443
|
if (lines.length === 0) return { context: null };
|
|
444
|
+
if (lines.some((line) => line.contains_unresolvable_secret)) {
|
|
445
|
+
deleteTrace(reflexDir, sessionId, promptId);
|
|
446
|
+
return { context: null };
|
|
447
|
+
}
|
|
106
448
|
let goal = "";
|
|
107
449
|
let recallHit = null;
|
|
450
|
+
let hasGoal = false;
|
|
108
451
|
for (const l of lines) {
|
|
109
|
-
if (l.t === "Goal")
|
|
452
|
+
if (l.t === "Goal") {
|
|
453
|
+
goal = l.prompt;
|
|
454
|
+
hasGoal = true;
|
|
455
|
+
}
|
|
110
456
|
if (l.t === "Recall" && l.hit) recallHit = l.hit;
|
|
111
457
|
}
|
|
458
|
+
if (!hasGoal) {
|
|
459
|
+
deleteTrace(reflexDir, sessionId, promptId);
|
|
460
|
+
return { context: null };
|
|
461
|
+
}
|
|
112
462
|
if (judge(lines, evt)) {
|
|
113
|
-
const steps = synthesize(lines);
|
|
463
|
+
const { steps, requiresEnv } = synthesize(lines);
|
|
114
464
|
if (steps.length > 0) {
|
|
465
|
+
const id = Math.random().toString(36).slice(2, 11);
|
|
115
466
|
if (!recallHit) {
|
|
116
|
-
|
|
117
|
-
id
|
|
467
|
+
appendPending(reflexDir, {
|
|
468
|
+
id,
|
|
118
469
|
title: goal,
|
|
119
470
|
trigger: goal,
|
|
120
471
|
steps,
|
|
472
|
+
...requiresEnv.length > 0 ? { requires_env: requiresEnv } : {},
|
|
121
473
|
enabled: true,
|
|
122
|
-
hits: 0,
|
|
123
|
-
last_used_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
124
474
|
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
125
475
|
status: "pending_review"
|
|
126
476
|
});
|
|
127
477
|
} else {
|
|
128
|
-
const parent =
|
|
478
|
+
const parent = readAllProcedures(reflexDir).find((p) => p.id === recallHit);
|
|
129
479
|
const parentSteps = parent ? parent.steps : [];
|
|
130
480
|
const same = parentSteps.length === steps.length && parentSteps.every((s, i) => s.t === steps[i].t && s.target === steps[i].target);
|
|
131
481
|
if (!same) {
|
|
132
|
-
|
|
482
|
+
appendPending(reflexDir, {
|
|
483
|
+
id,
|
|
133
484
|
parent_id: recallHit,
|
|
134
485
|
type: "alt",
|
|
135
486
|
steps,
|
|
487
|
+
...requiresEnv.length > 0 ? { requires_env: requiresEnv } : {},
|
|
136
488
|
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
137
489
|
status: "pending_review"
|
|
138
490
|
});
|
|
139
491
|
}
|
|
140
492
|
}
|
|
141
|
-
|
|
142
|
-
if (pending >= GROOM_THRESHOLD && deps.spawnGroomer) deps.spawnGroomer();
|
|
493
|
+
if (countIngest(reflexDir) >= GROOM_THRESHOLD && deps.spawnGroomer) deps.spawnGroomer();
|
|
143
494
|
}
|
|
144
495
|
}
|
|
145
496
|
deleteTrace(reflexDir, sessionId, promptId);
|
|
@@ -174,17 +525,20 @@ function judge(lines, evt) {
|
|
|
174
525
|
}
|
|
175
526
|
function synthesize(lines) {
|
|
176
527
|
const steps = [];
|
|
528
|
+
const requiresEnv = /* @__PURE__ */ new Set();
|
|
177
529
|
for (const l of lines) {
|
|
530
|
+
for (const name of l.requires_env || []) requiresEnv.add(name);
|
|
178
531
|
if (l.t === "Goal" || l.t === "Recall" || l.ok === false) continue;
|
|
179
532
|
if (l.t === "Read" || l.t === "View") continue;
|
|
180
533
|
if (l.t === "Bash" || l.t === "bash") {
|
|
181
534
|
const target = l.target || "";
|
|
535
|
+
if (!target.trim()) continue;
|
|
182
536
|
if (PURE_READ_CMDS.includes(target.trim().split(" ")[0])) continue;
|
|
183
537
|
if (/^git (status|log|diff)/.test(target)) continue;
|
|
184
538
|
}
|
|
185
539
|
steps.push({ t: l.t, target: l.target });
|
|
186
540
|
}
|
|
187
|
-
return steps;
|
|
541
|
+
return { steps, requiresEnv: [...requiresEnv] };
|
|
188
542
|
}
|
|
189
543
|
|
|
190
544
|
// lib/groomer.js
|
|
@@ -345,48 +699,37 @@ function resolveCredentials(env = process.env) {
|
|
|
345
699
|
}
|
|
346
700
|
|
|
347
701
|
// lib/groomer.js
|
|
348
|
-
|
|
349
|
-
|
|
702
|
+
var GROOM_MAX_ATTEMPTS = 3;
|
|
703
|
+
function logError(reflexDir, msg) {
|
|
350
704
|
const logFile = path3.join(reflexDir, "worker.log");
|
|
351
705
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
352
706
|
try {
|
|
353
707
|
fs3.appendFileSync(logFile, `[${timestamp}] ${msg}
|
|
354
708
|
`, "utf8");
|
|
355
|
-
} catch
|
|
709
|
+
} catch {
|
|
356
710
|
}
|
|
357
711
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
712
|
+
function mergeStable(stable, batchIds, keptFromBatch) {
|
|
713
|
+
const withoutBatch = stable.filter((n) => !n.id || !batchIds.has(n.id));
|
|
714
|
+
const byId = /* @__PURE__ */ new Map();
|
|
715
|
+
const anonymous = [];
|
|
716
|
+
for (const n of [...withoutBatch, ...keptFromBatch]) {
|
|
717
|
+
if (n.id) byId.set(n.id, n);
|
|
718
|
+
else anonymous.push(n);
|
|
365
719
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
720
|
+
return [...byId.values(), ...anonymous];
|
|
721
|
+
}
|
|
722
|
+
async function processBatch(reflexDir, processingFile, creds) {
|
|
723
|
+
const batch = readProcessing(reflexDir);
|
|
724
|
+
if (batch.length === 0) {
|
|
725
|
+
unlinkWithRetry(processingFile);
|
|
726
|
+
return { ok: true, deferred: 0 };
|
|
372
727
|
}
|
|
373
|
-
const { provider, apiKey, model } = creds;
|
|
374
|
-
if (!fs3.existsSync(proceduresFile)) return;
|
|
375
|
-
const lines = fs3.readFileSync(proceduresFile, "utf8").split("\n").filter(Boolean);
|
|
376
|
-
const nodes = lines.map((l) => {
|
|
377
|
-
try {
|
|
378
|
-
return JSON.parse(l);
|
|
379
|
-
} catch (e) {
|
|
380
|
-
return null;
|
|
381
|
-
}
|
|
382
|
-
}).filter(Boolean);
|
|
383
|
-
const pending = nodes.filter((n) => n.status === "pending_review");
|
|
384
|
-
if (pending.length === 0) return;
|
|
385
728
|
const prompt = `
|
|
386
|
-
Here are ${
|
|
729
|
+
Here are ${batch.length} new execution nodes/edges added by a coding agent.
|
|
387
730
|
|
|
388
731
|
<edges>
|
|
389
|
-
${JSON.stringify(
|
|
732
|
+
${JSON.stringify(batch, null, 2)}
|
|
390
733
|
</edges>
|
|
391
734
|
|
|
392
735
|
For each item:
|
|
@@ -408,46 +751,77 @@ Return ONLY a JSON array of objects with this schema:
|
|
|
408
751
|
`;
|
|
409
752
|
let responseText;
|
|
410
753
|
try {
|
|
411
|
-
responseText = await PROVIDERS[provider].complete(apiKey, model, prompt);
|
|
754
|
+
responseText = await PROVIDERS[creds.provider].complete(creds.apiKey, creds.model, prompt);
|
|
412
755
|
} catch (err) {
|
|
413
|
-
logError(
|
|
414
|
-
return;
|
|
756
|
+
logError(reflexDir, err.message);
|
|
757
|
+
return { ok: false, deferred: 0 };
|
|
415
758
|
}
|
|
416
759
|
let actions = [];
|
|
417
760
|
try {
|
|
418
761
|
const jsonStr = responseText.replace(/```json/g, "").replace(/```/g, "").trim();
|
|
419
762
|
actions = JSON.parse(jsonStr);
|
|
420
|
-
} catch
|
|
421
|
-
logError(
|
|
422
|
-
|
|
423
|
-
"Failed to parse LLM JSON response: " + responseText
|
|
424
|
-
);
|
|
425
|
-
return;
|
|
763
|
+
} catch {
|
|
764
|
+
logError(reflexDir, "Failed to parse LLM JSON response.");
|
|
765
|
+
return { ok: false, deferred: 0 };
|
|
426
766
|
}
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
delete node.status;
|
|
436
|
-
if (!node.parent_id && actionDef.aliases) {
|
|
437
|
-
node.aliases = actionDef.aliases;
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
}
|
|
767
|
+
const kept = [];
|
|
768
|
+
const deferred = [];
|
|
769
|
+
for (const node of batch) {
|
|
770
|
+
const actionDef = actions.find((a) => a.id === node.id);
|
|
771
|
+
if (!actionDef) {
|
|
772
|
+
const attempts = (node.groom_attempts || 0) + 1;
|
|
773
|
+
if (attempts < GROOM_MAX_ATTEMPTS) deferred.push({ ...node, groom_attempts: attempts });
|
|
774
|
+
continue;
|
|
441
775
|
}
|
|
442
|
-
|
|
776
|
+
if (actionDef.action === "DROP") continue;
|
|
777
|
+
const next = { ...node };
|
|
778
|
+
delete next.status;
|
|
779
|
+
delete next.groom_attempts;
|
|
780
|
+
if (!next.parent_id && Array.isArray(actionDef.aliases)) {
|
|
781
|
+
next.aliases = actionDef.aliases;
|
|
782
|
+
}
|
|
783
|
+
kept.push(next);
|
|
443
784
|
}
|
|
444
|
-
const
|
|
445
|
-
|
|
785
|
+
const resolvedIds = new Set(
|
|
786
|
+
batch.filter((n) => actions.some((a) => a.id === n.id)).map((n) => n.id).filter(Boolean)
|
|
787
|
+
);
|
|
788
|
+
const merged = mergeStable(readProcedures(reflexDir), resolvedIds, kept);
|
|
789
|
+
writeJsonlAtomic(paths(reflexDir).proceduresFile, merged);
|
|
790
|
+
unlinkWithRetry(processingFile);
|
|
791
|
+
for (const node of deferred) appendPending(reflexDir, node);
|
|
792
|
+
return { ok: true, deferred: deferred.length };
|
|
793
|
+
}
|
|
794
|
+
async function runGroomer(reflexDir) {
|
|
795
|
+
if (!tryAcquireGroomerLock(reflexDir)) return { ran: false, reason: "locked" };
|
|
446
796
|
try {
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
797
|
+
migrateLegacyPending(reflexDir);
|
|
798
|
+
let creds;
|
|
799
|
+
try {
|
|
800
|
+
creds = resolveCredentials();
|
|
801
|
+
} catch (e) {
|
|
802
|
+
logError(reflexDir, e.message);
|
|
803
|
+
return { ran: false, reason: "creds" };
|
|
804
|
+
}
|
|
805
|
+
if (!creds) {
|
|
806
|
+
logError(
|
|
807
|
+
reflexDir,
|
|
808
|
+
"No API key found. Run `npx agent-procedures auth login` or set ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY."
|
|
809
|
+
);
|
|
810
|
+
return { ran: false, reason: "creds" };
|
|
811
|
+
}
|
|
812
|
+
let batches = 0;
|
|
813
|
+
while (batches < 8) {
|
|
814
|
+
const processingFile = claimPendingBatch(reflexDir);
|
|
815
|
+
if (!processingFile) break;
|
|
816
|
+
const { ok, deferred } = await processBatch(reflexDir, processingFile, creds);
|
|
817
|
+
if (!ok) break;
|
|
818
|
+
batches++;
|
|
819
|
+
if (deferred > 0) break;
|
|
820
|
+
if (countIngest(reflexDir) === 0) break;
|
|
821
|
+
}
|
|
822
|
+
return { ran: true, batches };
|
|
823
|
+
} finally {
|
|
824
|
+
releaseGroomerLock(reflexDir);
|
|
451
825
|
}
|
|
452
826
|
}
|
|
453
827
|
|
|
@@ -500,19 +874,21 @@ var claude_default = {
|
|
|
500
874
|
return {
|
|
501
875
|
type: "stop",
|
|
502
876
|
...base,
|
|
503
|
-
interrupted:
|
|
877
|
+
interrupted: false,
|
|
504
878
|
busy: Array.isArray(payload.background_tasks) && payload.background_tasks.length > 0
|
|
505
879
|
};
|
|
506
880
|
}
|
|
507
881
|
return null;
|
|
508
882
|
},
|
|
509
883
|
/** Engine result → what to print on stdout for Claude. */
|
|
510
|
-
render(evt,
|
|
884
|
+
render(evt, result2) {
|
|
511
885
|
if (evt.type !== "prompt") return "";
|
|
512
|
-
if (!
|
|
513
|
-
|
|
514
|
-
hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext:
|
|
515
|
-
}
|
|
886
|
+
if (!result2.context) return "{}";
|
|
887
|
+
const out = {
|
|
888
|
+
hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: result2.context }
|
|
889
|
+
};
|
|
890
|
+
if (result2.notice) out.systemMessage = result2.notice;
|
|
891
|
+
return JSON.stringify(out);
|
|
516
892
|
}
|
|
517
893
|
};
|
|
518
894
|
|
|
@@ -539,6 +915,7 @@ function readStdin() {
|
|
|
539
915
|
});
|
|
540
916
|
}
|
|
541
917
|
function spawnGroomer() {
|
|
918
|
+
if (isGroomerLocked(REFLEX_DIR)) return;
|
|
542
919
|
const child = spawn(process.argv[0], [process.argv[1], "Groom"], { detached: true, stdio: "ignore" });
|
|
543
920
|
child.unref();
|
|
544
921
|
}
|
|
@@ -546,7 +923,7 @@ async function run(harnessId, argv = process.argv.slice(2)) {
|
|
|
546
923
|
const [eventName] = argv;
|
|
547
924
|
if (!eventName) return;
|
|
548
925
|
if (eventName === "Groom") {
|
|
549
|
-
await runGroomer(
|
|
926
|
+
await runGroomer(REFLEX_DIR);
|
|
550
927
|
return;
|
|
551
928
|
}
|
|
552
929
|
const raw = await readStdin();
|
|
@@ -560,8 +937,8 @@ async function run(harnessId, argv = process.argv.slice(2)) {
|
|
|
560
937
|
const harness = getHarness(harnessId);
|
|
561
938
|
const evt = harness.normalize(eventName, payload);
|
|
562
939
|
if (!evt) return;
|
|
563
|
-
const
|
|
564
|
-
const out = harness.render(evt,
|
|
940
|
+
const result2 = handleEvent(evt, REFLEX_DIR, { spawnGroomer });
|
|
941
|
+
const out = harness.render(evt, result2);
|
|
565
942
|
if (out) process.stdout.write(out + "\n");
|
|
566
943
|
}
|
|
567
944
|
export {
|