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.
@@ -0,0 +1,181 @@
1
+ /*
2
+ * Provider signatures below are ported from the Gitleaks default config:
3
+ * https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml
4
+ * Revision: b58d3f102cf3a2c84cb7f923d05c25c9b1aed84b
5
+ *
6
+ * MIT License
7
+ * Copyright (c) 2019 Zachary Rice
8
+ *
9
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ * of this software and associated documentation files (the "Software"), to deal
11
+ * in the Software without restriction, including without limitation the rights
12
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ * copies of the Software, and to permit persons to whom the Software is
14
+ * furnished to do so, subject to the following conditions:
15
+ *
16
+ * The above copyright notice and this permission notice shall be included in
17
+ * all copies or substantial portions of the Software.
18
+ *
19
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ * SOFTWARE.
26
+ */
27
+
28
+ // Gitleaks uses Go's inline (?i) flag. These ports use JavaScript's i flag.
29
+ // Patterns are compiled once when the runtime loads.
30
+ const PROVIDER_RULES = [
31
+ ["anthropic-api-key", /\b(sk-ant-api03-[a-zA-Z0-9_-]{93}AA)(?:[\x60'"\s;]|\\[nr]|$)/],
32
+ ["aws-access-token", /\b((?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\b/],
33
+ ["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],
34
+ ["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],
35
+ ["digitalocean-access-token", /\b(doo_v1_[a-f0-9]{64})(?:[\x60'"\s;]|\\[nr]|$)/],
36
+ ["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],
37
+ ["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],
38
+ ["gcp-api-key", /\b(AIza[\w-]{35})(?:[\x60'"\s;]|\\[nr]|$)/],
39
+ ["github-pat", /ghp_[0-9a-zA-Z]{36}/],
40
+ ["gitlab-pat", /glpat-[\w-]{20}/],
41
+ ["heroku-api-key-v2", /\b((HRKU-AA[0-9a-zA-Z_-]{58}))(?:[\x60'"\s;]|\\[nr]|$)/],
42
+ ["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],
43
+ ["npm-access-token", /\b(npm_[a-z0-9]{36})(?:[\x60'"\s;]|\\[nr]|$)/i],
44
+ ["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]|$)/],
45
+ ["pypi-upload-token", /pypi-AgEIcHlwaS5vcmc[\w-]{50,1000}/],
46
+ ["sendgrid-api-token", /\b(SG\.[a-z0-9=_\-.]{66})(?:[\x60'"\s;]|\\[nr]|$)/i],
47
+ ["shopify-access-token", /shpat_[a-fA-F0-9]{32}/],
48
+ ["slack-bot-token", /xoxb-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*/],
49
+ ["stripe-access-token", /\b((?:sk|rk)_(?:test|live|prod)_[a-zA-Z0-9]{10,99})(?:[\x60'"\s;]|\\[nr]|$)/],
50
+ ["twilio-api-key", /SK[0-9a-fA-F]{32}/],
51
+ ["private-key", /-----BEGIN[ A-Z0-9_-]{0,100}PRIVATE KEY(?: BLOCK)?-----[\s\S-]{64,}?KEY(?: BLOCK)?-----/i],
52
+ ["jwt", /\b(ey[a-zA-Z0-9]{17,}\.ey[a-zA-Z0-9/\\_-]{17,}\.(?:[a-zA-Z0-9/\\_-]{10,}={0,2})?)(?:[\x60'"\s;]|\\[nr]|$)/],
53
+ ];
54
+
55
+ const SENSITIVE_NAME =
56
+ /(?:^|_)(?:API_?)?(?:KEY|TOKEN|SECRET|PASS|PASSWORD|AUTH|CREDENTIALS?|DATABASE_URL|DB_URL|CONNECTION_STRING|DSN)(?:_|$)/i;
57
+ const ASSIGNMENT =
58
+ /\b(export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"]*)"|'([^']*)'|([^\s;&|"']+))(\s*)/g;
59
+ const CREDENTIAL_URL = /^[a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:[^@\s/]+@/i;
60
+
61
+ function result(text, requiresEnv = [], unsafe = false) {
62
+ return { text, requiresEnv: [...new Set(requiresEnv)], unsafe };
63
+ }
64
+
65
+ function replaceDetectedSecrets(input) {
66
+ let text = input;
67
+ let unsafe = false;
68
+
69
+ for (const [id, regex] of PROVIDER_RULES) {
70
+ let match;
71
+ while ((match = regex.exec(text))) {
72
+ const secret = match[1] || match[0];
73
+ const offset = match[0].indexOf(secret);
74
+ const start = match.index + offset;
75
+ text = text.slice(0, start) + `[secret:${id}]` + text.slice(start + secret.length);
76
+ unsafe = true;
77
+ }
78
+ }
79
+
80
+ return { text, unsafe };
81
+ }
82
+
83
+ export function sanitizeText(input = "") {
84
+ const detected = replaceDetectedSecrets(String(input));
85
+ return result(detected.text, [], detected.unsafe);
86
+ }
87
+
88
+ export function sanitizeCommand(input = "") {
89
+ let text = String(input);
90
+ const requiresEnv = [];
91
+
92
+ let removedAssignment = false;
93
+ text = text.replace(ASSIGNMENT, (match, exported, name, double, single, bare, trailing, offset, source) => {
94
+ const value = double ?? single ?? bare ?? "";
95
+ const valueIsSecret =
96
+ SENSITIVE_NAME.test(name) ||
97
+ CREDENTIAL_URL.test(value) ||
98
+ PROVIDER_RULES.some(([, regex]) => regex.test(value));
99
+ if (!valueIsSecret) return match;
100
+
101
+ requiresEnv.push(name);
102
+ const before = source.slice(0, offset);
103
+ const isCommandAssignment = exported || !before.trim() || /(?:&&|\|\||;)\s*$/.test(before);
104
+ if (isCommandAssignment) {
105
+ removedAssignment = true;
106
+ return "";
107
+ }
108
+ // Keep the caller's quoting shape: a bare value may already sit inside
109
+ // quotes, so adding our own would unbalance them.
110
+ const placeholder = bare !== undefined ? `\${${name}}` : `"\${${name}}"`;
111
+ return `${name}=${placeholder}${trailing}`;
112
+ });
113
+
114
+ text = text.replace(
115
+ /'(\bAuthorization\s*:\s*Bearer\s+)([^']+)'/gi,
116
+ (_match, prefix) => {
117
+ requiresEnv.push("AUTH_TOKEN");
118
+ return `"${prefix}\${AUTH_TOKEN}"`;
119
+ },
120
+ );
121
+ text = text.replace(
122
+ /'(\bAuthorization\s*:\s*Basic\s+)([^']+)'/gi,
123
+ (_match, prefix) => {
124
+ requiresEnv.push("BASIC_AUTH");
125
+ return `"${prefix}\${BASIC_AUTH}"`;
126
+ },
127
+ );
128
+ text = text.replace(
129
+ /'([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)([^@']+)(@[^']+)'/gi,
130
+ (_match, prefix, _password, suffix) => {
131
+ requiresEnv.push("PASSWORD");
132
+ return `"${prefix}\${PASSWORD}${suffix}"`;
133
+ },
134
+ );
135
+
136
+ text = text.replace(
137
+ /(\bAuthorization\s*:\s*Bearer\s+)([^"'\s]+)/gi,
138
+ (_match, prefix) => {
139
+ requiresEnv.push("AUTH_TOKEN");
140
+ return `${prefix}\${AUTH_TOKEN}`;
141
+ },
142
+ );
143
+ text = text.replace(
144
+ /(\bAuthorization\s*:\s*Basic\s+)([^"'\s]+)/gi,
145
+ (_match, prefix) => {
146
+ requiresEnv.push("BASIC_AUTH");
147
+ return `${prefix}\${BASIC_AUTH}`;
148
+ },
149
+ );
150
+ text = text.replace(
151
+ /([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)([^@\s/]+)(@)/gi,
152
+ (_match, prefix, _password, suffix) => {
153
+ requiresEnv.push("PASSWORD");
154
+ return `${prefix}\${PASSWORD}${suffix}`;
155
+ },
156
+ );
157
+ text = text.replace(
158
+ /(--(?:password|passwd|token|secret|api-key|key)(?:=|\s+))(["']?)([^"'\s;&|]+)\2/gi,
159
+ (_match, prefix, quote) => {
160
+ const name = prefix
161
+ .match(/^--([a-z-]+)/i)[1]
162
+ .replace(/-/g, "_")
163
+ .toUpperCase();
164
+ requiresEnv.push(name);
165
+ const safeQuote = quote === "'" ? '"' : quote;
166
+ return `${prefix}${safeQuote}\${${name}}${safeQuote}`;
167
+ },
168
+ );
169
+
170
+ if (removedAssignment) {
171
+ // Removing an assignment can leave dangling or doubled separators.
172
+ text = text
173
+ .replace(/^\s*(?:&&|\|\||;)\s*/, "")
174
+ .replace(/\s*(?:&&|\|\||;)\s*$/, "")
175
+ .replace(/(?:&&|\|\||;)\s*(?=(?:&&|\|\||;))/g, "")
176
+ .trim();
177
+ }
178
+
179
+ const detected = replaceDetectedSecrets(text);
180
+ return result(detected.text, requiresEnv, detected.unsafe);
181
+ }
package/lib/store.js CHANGED
@@ -2,18 +2,73 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
4
  // Disk layout under .reflex/. Harness-free.
5
+ // Hot path appends pending.jsonl. Stable memory is procedures.jsonl.
6
+ // Groomer renames pending → processing, then merges into procedures.
7
+
8
+ export const GROOM_LOCK_STALE_MS = 15 * 60 * 1000;
5
9
 
6
10
  export function paths(reflexDir) {
7
11
  return {
8
12
  reflexDir,
9
13
  tracesDir: path.join(reflexDir, "traces"),
10
14
  proceduresFile: path.join(reflexDir, "procedures.jsonl"),
15
+ pendingFile: path.join(reflexDir, "pending.jsonl"),
16
+ processingFile: path.join(reflexDir, "processing.jsonl"),
17
+ groomerLockFile: path.join(reflexDir, "groomer.lock"),
11
18
  };
12
19
  }
13
20
 
14
21
  function readJsonl(file) {
15
22
  if (!fs.existsSync(file)) return [];
16
- return fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
23
+ return fs
24
+ .readFileSync(file, "utf8")
25
+ .split("\n")
26
+ .filter(Boolean)
27
+ .map((l) => {
28
+ try {
29
+ return JSON.parse(l);
30
+ } catch {
31
+ return null;
32
+ }
33
+ })
34
+ .filter(Boolean);
35
+ }
36
+
37
+ function sleepSync(ms) {
38
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
39
+ }
40
+
41
+ export function renameWithRetry(from, to, attempts = 8) {
42
+ for (let i = 0; i < attempts; i++) {
43
+ try {
44
+ fs.renameSync(from, to);
45
+ return;
46
+ } catch (e) {
47
+ const retryable = e.code === "EBUSY" || e.code === "EPERM" || e.code === "EACCES";
48
+ if (!retryable || i === attempts - 1) throw e;
49
+ sleepSync(15 * (i + 1));
50
+ }
51
+ }
52
+ }
53
+
54
+ export function unlinkWithRetry(file, attempts = 8) {
55
+ for (let i = 0; i < attempts; i++) {
56
+ try {
57
+ if (fs.existsSync(file)) fs.unlinkSync(file);
58
+ return;
59
+ } catch (e) {
60
+ const retryable = e.code === "EBUSY" || e.code === "EPERM" || e.code === "EACCES";
61
+ if (!retryable || i === attempts - 1) throw e;
62
+ sleepSync(15 * (i + 1));
63
+ }
64
+ }
65
+ }
66
+
67
+ export function writeJsonlAtomic(file, nodes) {
68
+ const content = nodes.length ? nodes.map((n) => JSON.stringify(n)).join("\n") + "\n" : "";
69
+ const tmp = file + ".tmp";
70
+ fs.writeFileSync(tmp, content, "utf8");
71
+ renameWithRetry(tmp, file);
17
72
  }
18
73
 
19
74
  export function traceFile(reflexDir, sessionId, promptId) {
@@ -39,15 +94,133 @@ export function readTrace(reflexDir, sessionId, promptId) {
39
94
  export function deleteTrace(reflexDir, sessionId, promptId) {
40
95
  try {
41
96
  fs.unlinkSync(traceFile(reflexDir, sessionId, promptId));
42
- } catch (e) {}
97
+ } catch {
98
+ /* missing is fine */
99
+ }
43
100
  }
44
101
 
45
102
  export function readProcedures(reflexDir) {
46
103
  return readJsonl(paths(reflexDir).proceduresFile);
47
104
  }
48
105
 
49
- export function appendProcedure(reflexDir, node) {
50
- const file = paths(reflexDir).proceduresFile;
51
- if (!fs.existsSync(file)) fs.writeFileSync(file, "", "utf8");
106
+ export function readPending(reflexDir) {
107
+ return readJsonl(paths(reflexDir).pendingFile);
108
+ }
109
+
110
+ export function readProcessing(reflexDir) {
111
+ return readJsonl(paths(reflexDir).processingFile);
112
+ }
113
+
114
+ /** Stable + ingest buffer + any in-flight batch. Used by recall. */
115
+ export function readAllProcedures(reflexDir) {
116
+ return [
117
+ ...readProcedures(reflexDir),
118
+ ...readPending(reflexDir),
119
+ ...readProcessing(reflexDir),
120
+ ];
121
+ }
122
+
123
+ export function countIngest(reflexDir) {
124
+ return readPending(reflexDir).length + readProcessing(reflexDir).length;
125
+ }
126
+
127
+ /** Hot path only. Never writes procedures.jsonl. */
128
+ export function appendPending(reflexDir, node) {
129
+ const file = paths(reflexDir).pendingFile;
130
+ fs.mkdirSync(reflexDir, { recursive: true });
52
131
  fs.appendFileSync(file, JSON.stringify(node) + "\n", "utf8");
53
132
  }
133
+
134
+ /**
135
+ * Move legacy pending_review rows out of procedures.jsonl into pending.jsonl.
136
+ * Safe to call repeatedly.
137
+ */
138
+ export function migrateLegacyPending(reflexDir) {
139
+ const p = paths(reflexDir);
140
+ if (!fs.existsSync(p.proceduresFile)) return { moved: 0 };
141
+ const nodes = readJsonl(p.proceduresFile);
142
+ const pending = nodes.filter((n) => n.status === "pending_review");
143
+ if (pending.length === 0) return { moved: 0 };
144
+ const stable = nodes.filter((n) => n.status !== "pending_review");
145
+ for (const node of pending) appendPending(reflexDir, node);
146
+ writeJsonlAtomic(p.proceduresFile, stable);
147
+ return { moved: pending.length };
148
+ }
149
+
150
+ function pidAlive(pid) {
151
+ if (!Number.isInteger(pid) || pid <= 0) return false;
152
+ try {
153
+ process.kill(pid, 0);
154
+ return true;
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ function readLock(lockFile) {
161
+ try {
162
+ return JSON.parse(fs.readFileSync(lockFile, "utf8"));
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
168
+ export function isGroomerLocked(reflexDir, now = Date.now()) {
169
+ const lockFile = paths(reflexDir).groomerLockFile;
170
+ if (!fs.existsSync(lockFile)) return false;
171
+ const lock = readLock(lockFile);
172
+ if (!lock) return false;
173
+ const age = typeof lock.startedAt === "number" ? now - lock.startedAt : Infinity;
174
+ if (age >= GROOM_LOCK_STALE_MS) return false;
175
+ return pidAlive(lock.pid);
176
+ }
177
+
178
+ /** Exclusive create. Returns true if this process owns the lock. */
179
+ export function tryAcquireGroomerLock(reflexDir, now = Date.now()) {
180
+ const lockFile = paths(reflexDir).groomerLockFile;
181
+ fs.mkdirSync(reflexDir, { recursive: true });
182
+
183
+ if (fs.existsSync(lockFile)) {
184
+ if (isGroomerLocked(reflexDir, now)) return false;
185
+ try {
186
+ fs.unlinkSync(lockFile);
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
191
+
192
+ try {
193
+ const fd = fs.openSync(lockFile, "wx");
194
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, startedAt: now }) + "\n", "utf8");
195
+ fs.closeSync(fd);
196
+ return true;
197
+ } catch (e) {
198
+ if (e.code === "EEXIST") return false;
199
+ throw e;
200
+ }
201
+ }
202
+
203
+ export function releaseGroomerLock(reflexDir) {
204
+ const lockFile = paths(reflexDir).groomerLockFile;
205
+ try {
206
+ const lock = readLock(lockFile);
207
+ if (lock && lock.pid !== process.pid) return;
208
+ fs.unlinkSync(lockFile);
209
+ } catch {
210
+ /* missing is fine */
211
+ }
212
+ }
213
+
214
+ /** Claim current pending batch for the groomer. Returns processing path or null. */
215
+ export function claimPendingBatch(reflexDir) {
216
+ const { pendingFile, processingFile } = paths(reflexDir);
217
+ if (fs.existsSync(processingFile)) return processingFile;
218
+ if (!fs.existsSync(pendingFile)) return null;
219
+ const stat = fs.statSync(pendingFile);
220
+ if (stat.size === 0) {
221
+ unlinkWithRetry(pendingFile);
222
+ return null;
223
+ }
224
+ renameWithRetry(pendingFile, processingFile);
225
+ return processingFile;
226
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-procedures",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Procedural memory for coding agents",
5
5
  "type": "module",
6
6
  "bin": {