agent-procedures 0.1.0 → 0.2.0
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 +56 -5
- package/bin/cli.js +37 -8
- package/lib/auth.js +169 -0
- package/lib/engine.js +179 -0
- package/lib/groomer.js +129 -0
- package/lib/harnesses/claude.js +75 -0
- package/lib/harnesses/index.js +15 -0
- package/lib/init.js +69 -8
- package/lib/main.js +52 -0
- package/lib/providers.js +178 -0
- package/lib/store.js +53 -0
- package/package.json +11 -3
package/README.md
CHANGED
|
@@ -2,21 +2,72 @@
|
|
|
2
2
|
|
|
3
3
|
Procedural memory for coding agents.
|
|
4
4
|
|
|
5
|
+
Coding agents are pretty good at figuring things out. They're bad at remembering how they did it last time, so they just figure it out again. Every time.
|
|
6
|
+
|
|
7
|
+
Reflex watches the tools your agent actually runs, keeps the paths that worked, and hands them back the next time you ask for something similar. It doesn't store facts, your docs already do that. It stores how.
|
|
8
|
+
|
|
9
|
+
The npm package is `agent-procedures`. The product is Reflex.
|
|
10
|
+
|
|
11
|
+
## How it works (hot path vs cold path)
|
|
12
|
+
|
|
13
|
+
The **hot path** runs every time you prompt the agent or the agent runs a tool. It has to be fast, so it doesn't call an LLM. It's just disk I/O. When you ask for something, Reflex checks its memory for a match and injects the steps into the agent's context. When the agent uses tools, Reflex traces what happens. If the agent gets the job done without leaving broken steps behind, Reflex saves that trace as a new procedure.
|
|
14
|
+
|
|
15
|
+
The **cold path** is a background groomer. Once 5 new procedures pile up, it kicks off a background process that asks a cheap LLM to review them. It drops the risky ones and adds aliases (like synonyms) to the good ones so they match more easily next time.
|
|
16
|
+
|
|
17
|
+
You don't run either of these manually. You just use your agent.
|
|
18
|
+
|
|
19
|
+
## Status & Harnesses
|
|
20
|
+
|
|
21
|
+
Early. Reflex is built to plug into different agent platforms (harnesses). Right now, **Claude Code is the only one built** and is the default.
|
|
22
|
+
|
|
23
|
+
I set the engine up so Cursor and others can plug in later, but the adapters for those don't exist yet.
|
|
24
|
+
|
|
5
25
|
## Install
|
|
6
26
|
|
|
27
|
+
Run this in the repo you want Reflex in:
|
|
28
|
+
|
|
7
29
|
```bash
|
|
8
30
|
npx agent-procedures init
|
|
9
31
|
```
|
|
10
32
|
|
|
11
|
-
|
|
33
|
+
By default this installs the Claude Code harness. If you were using a different one later, you'd run `npx agent-procedures init --harness cursor`.
|
|
34
|
+
|
|
35
|
+
Installing the package on its own won't do anything. You need `init`. It creates the folders, copies the runtime in, and adds a hook so your agent knows to call it.
|
|
36
|
+
|
|
37
|
+
## API key (for the background groomer)
|
|
38
|
+
|
|
39
|
+
Because the groomer uses an LLM to review procedures, it needs a cheap model key.
|
|
40
|
+
|
|
41
|
+
If you already have `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` or `GEMINI_API_KEY` exported in your terminal, it just uses that. Otherwise:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npx agent-procedures auth login # pick a provider, paste your key
|
|
45
|
+
npx agent-procedures auth status # check what it's using and the last groomer error
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The key gets checked before it's saved, so a typo fails right away instead of a week later in a log file. It gets stored in `~/.reflex/credentials.json` on your machine and only you can read it.
|
|
49
|
+
|
|
50
|
+
## What ends up in your repo
|
|
12
51
|
|
|
13
52
|
```
|
|
14
53
|
.reflex/
|
|
15
|
-
config.json # settings
|
|
16
|
-
procedures.jsonl # remembered
|
|
54
|
+
config.json # settings, not memory
|
|
55
|
+
procedures.jsonl # what it remembered
|
|
17
56
|
runs.jsonl # optional run outcomes
|
|
57
|
+
engine/ # the runtime
|
|
58
|
+
traces/ # scratch for the current turn
|
|
18
59
|
```
|
|
19
60
|
|
|
20
|
-
|
|
61
|
+
`traces/` gets added to `.gitignore`. **Commit everything else**, including `.claude/settings.json` (the hook wiring), so anyone who clones the repo gets the same memory. If you upgrade the package, run `init` again to refresh the engine.
|
|
62
|
+
|
|
63
|
+
## Adding a harness
|
|
64
|
+
|
|
65
|
+
If you want to build an adapter for something other than Claude Code:
|
|
66
|
+
|
|
67
|
+
Only three things change between harnesses: where hooks get registered, what the event payload looks like, and how context gets passed back to the agent. The rest of the engine doesn't care who called it.
|
|
68
|
+
|
|
69
|
+
Adding one is a single file in `lib/harnesses/` plus one line in `lib/harnesses/index.js`. `claude.js` is the reference.
|
|
70
|
+
|
|
71
|
+
## License
|
|
21
72
|
|
|
22
|
-
|
|
73
|
+
MIT
|
package/bin/cli.js
CHANGED
|
@@ -8,19 +8,48 @@ if (!command || command === "--help" || command === "-h") {
|
|
|
8
8
|
console.log(`agent-procedures — procedural memory for coding agents (Reflex)
|
|
9
9
|
|
|
10
10
|
Usage:
|
|
11
|
-
npx agent-procedures init
|
|
11
|
+
npx agent-procedures init [--harness claude] Create .reflex/ and wire hooks
|
|
12
|
+
npx agent-procedures auth login Store an API key for the background groomer
|
|
13
|
+
npx agent-procedures auth status Show which provider/key the groomer will use
|
|
12
14
|
`);
|
|
13
15
|
process.exit(command ? 0 : 1);
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
if (command === "init") {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
const args = process.argv.slice(3);
|
|
20
|
+
const i = args.indexOf("--harness");
|
|
21
|
+
const harness = i >= 0 ? args[i + 1] : undefined;
|
|
22
|
+
try {
|
|
23
|
+
const results = init(process.cwd(), { harness });
|
|
24
|
+
console.log("Initialized Reflex in the repository");
|
|
25
|
+
console.log(` .reflex/config.json ${results.config}`);
|
|
26
|
+
console.log(` .reflex/procedures.jsonl ${results.procedures}`);
|
|
27
|
+
console.log(` .reflex/runs.jsonl ${results.runs}`);
|
|
28
|
+
console.log(` .reflex/engine/ ${results.engine}`);
|
|
29
|
+
console.log(` .gitignore ${results.gitignore}`);
|
|
30
|
+
console.log(` hooks (${harness || "claude"}) ${results.hooks}`);
|
|
31
|
+
process.exit(0);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
console.error(`Error: ${e.message}`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (command === "auth") {
|
|
39
|
+
const { login, status, USAGE } = await import("../lib/auth.js");
|
|
40
|
+
const [sub, ...rest] = process.argv.slice(3);
|
|
41
|
+
try {
|
|
42
|
+
if (sub === "login") await login(rest);
|
|
43
|
+
else if (sub === "status") status();
|
|
44
|
+
else {
|
|
45
|
+
console.error(USAGE);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
process.exit(0);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
console.error(`Error: ${e.message}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
24
53
|
}
|
|
25
54
|
|
|
26
55
|
console.error(`Unknown command: ${command}`);
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
import { Writable } from "node:stream";
|
|
5
|
+
import {
|
|
6
|
+
PROVIDERS,
|
|
7
|
+
PROVIDER_NAMES,
|
|
8
|
+
credentialsFile,
|
|
9
|
+
readCredentials,
|
|
10
|
+
reflexHome,
|
|
11
|
+
resolveCredentials,
|
|
12
|
+
} from "./providers.js";
|
|
13
|
+
|
|
14
|
+
export const USAGE = `Usage:
|
|
15
|
+
npx agent-procedures auth login [--provider <name>] [--model <id>] [--key-stdin]
|
|
16
|
+
npx agent-procedures auth status
|
|
17
|
+
|
|
18
|
+
Providers: ${PROVIDER_NAMES.join(", ")}
|
|
19
|
+
|
|
20
|
+
Interactive by default. For scripts/CI, pipe the key on stdin:
|
|
21
|
+
echo "$OPENAI_API_KEY" | npx agent-procedures auth login --provider openai --key-stdin
|
|
22
|
+
|
|
23
|
+
The groomer also reads ${PROVIDER_NAMES.map((p) => PROVIDERS[p].envVar).join(" / ")}
|
|
24
|
+
directly, so \`auth login\` is optional if one of those is already set.`;
|
|
25
|
+
|
|
26
|
+
function parseArgs(args) {
|
|
27
|
+
const out = { provider: null, model: null, keyStdin: false };
|
|
28
|
+
for (let i = 0; i < args.length; i++) {
|
|
29
|
+
const a = args[i];
|
|
30
|
+
if (a === "--provider" && args[i + 1]) out.provider = args[++i];
|
|
31
|
+
else if (a === "--model" && args[i + 1]) out.model = args[++i];
|
|
32
|
+
else if (a === "--key-stdin") out.keyStdin = true;
|
|
33
|
+
else throw new Error(`Unknown argument: ${a}\n\n${USAGE}`);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ask(question, io) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
const rl = readline.createInterface({ input: io.stdin, output: io.stdout });
|
|
41
|
+
rl.question(question, (answer) => {
|
|
42
|
+
rl.close();
|
|
43
|
+
resolve(answer.trim());
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Readline with a muted output stream: line editing still works, nothing is echoed.
|
|
49
|
+
function askHidden(question, io) {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
io.stdout.write(question);
|
|
52
|
+
const muted = new Writable({ write(_chunk, _enc, cb) { cb(); } });
|
|
53
|
+
const rl = readline.createInterface({ input: io.stdin, output: muted, terminal: true });
|
|
54
|
+
rl.question("", (answer) => {
|
|
55
|
+
rl.close();
|
|
56
|
+
io.stdout.write("\n");
|
|
57
|
+
resolve(answer.trim());
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readAll(stream) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
let data = "";
|
|
65
|
+
stream.setEncoding("utf8");
|
|
66
|
+
stream.on("data", (c) => (data += c));
|
|
67
|
+
stream.on("end", () => resolve(data.trim()));
|
|
68
|
+
stream.on("error", reject);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function chooseProvider(io) {
|
|
73
|
+
const menu = PROVIDER_NAMES.map((p, i) => ` [${i + 1}] ${PROVIDERS[p].label}`).join("\n");
|
|
74
|
+
const answer = await ask(`Provider:\n${menu}\n> `, io);
|
|
75
|
+
const byIndex = PROVIDER_NAMES[Number(answer) - 1];
|
|
76
|
+
const byName = PROVIDER_NAMES.find((p) => p === answer.toLowerCase());
|
|
77
|
+
const provider = byIndex || byName;
|
|
78
|
+
if (!provider) throw new Error(`Unknown provider "${answer}". Choose one of: ${PROVIDER_NAMES.join(", ")}`);
|
|
79
|
+
return provider;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function saveCredentials(provider, apiKey, model) {
|
|
83
|
+
const dir = reflexHome();
|
|
84
|
+
const file = credentialsFile();
|
|
85
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
86
|
+
|
|
87
|
+
let creds = { version: 1, providers: {} };
|
|
88
|
+
try {
|
|
89
|
+
creds = { ...creds, ...(readCredentials() || {}) };
|
|
90
|
+
} catch (e) {
|
|
91
|
+
// corrupt file; start fresh
|
|
92
|
+
}
|
|
93
|
+
creds.providers = creds.providers || {};
|
|
94
|
+
creds.providers[provider] = { apiKey, model };
|
|
95
|
+
creds.defaultProvider = provider;
|
|
96
|
+
|
|
97
|
+
fs.writeFileSync(file, JSON.stringify(creds, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
98
|
+
fs.chmodSync(file, 0o600); // mode above only applies on create
|
|
99
|
+
return file;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function login(args, io = { stdin: process.stdin, stdout: process.stdout }) {
|
|
103
|
+
const opts = parseArgs(args);
|
|
104
|
+
const interactive = Boolean(io.stdin.isTTY) && !opts.keyStdin;
|
|
105
|
+
|
|
106
|
+
let provider = opts.provider;
|
|
107
|
+
if (provider && !PROVIDERS[provider]) {
|
|
108
|
+
throw new Error(`Unsupported provider "${provider}". Choose one of: ${PROVIDER_NAMES.join(", ")}`);
|
|
109
|
+
}
|
|
110
|
+
if (!provider) {
|
|
111
|
+
if (!interactive) throw new Error(`--provider is required when not running interactively.\n\n${USAGE}`);
|
|
112
|
+
provider = await chooseProvider(io);
|
|
113
|
+
}
|
|
114
|
+
const def = PROVIDERS[provider];
|
|
115
|
+
|
|
116
|
+
let apiKey;
|
|
117
|
+
if (opts.keyStdin) {
|
|
118
|
+
apiKey = await readAll(io.stdin);
|
|
119
|
+
} else if (interactive) {
|
|
120
|
+
apiKey = await askHidden(`${def.label} API key (get one at ${def.keysUrl}): `, io);
|
|
121
|
+
} else {
|
|
122
|
+
throw new Error(`No key given. Use --key-stdin when not running interactively.\n\n${USAGE}`);
|
|
123
|
+
}
|
|
124
|
+
if (!apiKey) throw new Error("No key given.");
|
|
125
|
+
|
|
126
|
+
if (!apiKey.startsWith(def.keyPrefix)) {
|
|
127
|
+
io.stdout.write(`Warning: ${def.label} keys usually start with "${def.keyPrefix}". Checking anyway.\n`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const model = opts.model || def.defaultModel;
|
|
131
|
+
|
|
132
|
+
io.stdout.write(`Checking key with ${def.label}... `);
|
|
133
|
+
try {
|
|
134
|
+
await def.verify(apiKey);
|
|
135
|
+
} catch (e) {
|
|
136
|
+
io.stdout.write("failed\n");
|
|
137
|
+
throw new Error(`${def.label} rejected the key: ${e.message}`);
|
|
138
|
+
}
|
|
139
|
+
io.stdout.write("ok\n");
|
|
140
|
+
|
|
141
|
+
const file = saveCredentials(provider, apiKey, model);
|
|
142
|
+
io.stdout.write(`Saved to ${file} (readable only by you)\n`);
|
|
143
|
+
io.stdout.write(`Provider: ${def.label}\nModel: ${model}\n`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function maskKey(key) {
|
|
147
|
+
if (key.length <= 8) return "****";
|
|
148
|
+
return `${key.slice(0, 4)}...${key.slice(-4)}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function status(io = { stdout: process.stdout }, cwd = process.cwd()) {
|
|
152
|
+
const resolved = resolveCredentials();
|
|
153
|
+
if (!resolved) {
|
|
154
|
+
io.stdout.write(
|
|
155
|
+
`Not configured. Run \`npx agent-procedures auth login\` or set one of: ${PROVIDER_NAMES.map((p) => PROVIDERS[p].envVar).join(", ")}\n`,
|
|
156
|
+
);
|
|
157
|
+
} else {
|
|
158
|
+
const def = PROVIDERS[resolved.provider];
|
|
159
|
+
const from = resolved.source === "env" ? `env ${def.envVar}` : credentialsFile();
|
|
160
|
+
io.stdout.write(`Provider: ${def.label}\nModel: ${resolved.model}\nKey: ${maskKey(resolved.apiKey)} (from ${from})\n`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const log = path.join(cwd, ".reflex", "worker.log");
|
|
164
|
+
if (fs.existsSync(log)) {
|
|
165
|
+
const lines = fs.readFileSync(log, "utf8").trim().split("\n");
|
|
166
|
+
const last = lines[lines.length - 1];
|
|
167
|
+
if (last) io.stdout.write(`Last groomer error: ${last}\n`);
|
|
168
|
+
}
|
|
169
|
+
}
|
package/lib/engine.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendProcedure,
|
|
3
|
+
appendTrace,
|
|
4
|
+
deleteTrace,
|
|
5
|
+
readProcedures,
|
|
6
|
+
readTrace,
|
|
7
|
+
startTrace,
|
|
8
|
+
} from "./store.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Harness-free hot path.
|
|
12
|
+
*
|
|
13
|
+
* Normalized event (produced by a harness adapter):
|
|
14
|
+
* { type: "prompt", sessionId, promptId, prompt }
|
|
15
|
+
* { type: "tool", sessionId, promptId, tool, target, ok }
|
|
16
|
+
* { type: "stop", sessionId, promptId, interrupted, busy }
|
|
17
|
+
*
|
|
18
|
+
* Returns { context: string | null }. `context` is text the harness should
|
|
19
|
+
* inject into the agent's next turn, if it knows how.
|
|
20
|
+
*/
|
|
21
|
+
export function handleEvent(evt, reflexDir, deps = {}) {
|
|
22
|
+
if (!evt || !evt.sessionId || !evt.promptId) return { context: null };
|
|
23
|
+
|
|
24
|
+
if (evt.type === "prompt") return onPrompt(evt, reflexDir);
|
|
25
|
+
if (evt.type === "tool") return onTool(evt, reflexDir);
|
|
26
|
+
if (evt.type === "stop") return onStop(evt, reflexDir, deps);
|
|
27
|
+
return { context: null };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const GROOM_THRESHOLD = 5;
|
|
31
|
+
|
|
32
|
+
// --- prompt: open turn + recall ------------------------------------------
|
|
33
|
+
|
|
34
|
+
function onPrompt(evt, reflexDir) {
|
|
35
|
+
const { sessionId, promptId, prompt } = evt;
|
|
36
|
+
startTrace(reflexDir, sessionId, promptId, { t: "Goal", prompt });
|
|
37
|
+
|
|
38
|
+
const hit = recall(prompt, readProcedures(reflexDir));
|
|
39
|
+
if (!hit) return { context: null };
|
|
40
|
+
|
|
41
|
+
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;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- tool: trace ----------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
function onTool(evt, reflexDir) {
|
|
76
|
+
appendTrace(reflexDir, evt.sessionId, evt.promptId, { t: evt.tool, target: evt.target || "", ok: evt.ok });
|
|
77
|
+
return { context: null };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- stop: judge → gate → synthesize → write ------------------------------
|
|
81
|
+
|
|
82
|
+
function onStop(evt, reflexDir, deps) {
|
|
83
|
+
const { sessionId, promptId } = evt;
|
|
84
|
+
const lines = readTrace(reflexDir, sessionId, promptId);
|
|
85
|
+
if (lines.length === 0) return { context: null };
|
|
86
|
+
|
|
87
|
+
let goal = "";
|
|
88
|
+
let recallHit = null;
|
|
89
|
+
for (const l of lines) {
|
|
90
|
+
if (l.t === "Goal") goal = l.prompt;
|
|
91
|
+
if (l.t === "Recall" && l.hit) recallHit = l.hit;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (judge(lines, evt)) {
|
|
95
|
+
const steps = synthesize(lines);
|
|
96
|
+
if (steps.length > 0) {
|
|
97
|
+
if (!recallHit) {
|
|
98
|
+
appendProcedure(reflexDir, {
|
|
99
|
+
id: Math.random().toString(36).slice(2, 11),
|
|
100
|
+
title: goal,
|
|
101
|
+
trigger: goal,
|
|
102
|
+
steps,
|
|
103
|
+
enabled: true,
|
|
104
|
+
hits: 0,
|
|
105
|
+
last_used_at: new Date().toISOString(),
|
|
106
|
+
created_at: new Date().toISOString(),
|
|
107
|
+
status: "pending_review",
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
const parent = readProcedures(reflexDir).find((p) => p.id === recallHit);
|
|
111
|
+
const parentSteps = parent ? parent.steps : [];
|
|
112
|
+
const same =
|
|
113
|
+
parentSteps.length === steps.length &&
|
|
114
|
+
parentSteps.every((s, i) => s.t === steps[i].t && s.target === steps[i].target);
|
|
115
|
+
if (!same) {
|
|
116
|
+
appendProcedure(reflexDir, {
|
|
117
|
+
parent_id: recallHit,
|
|
118
|
+
type: "alt",
|
|
119
|
+
steps,
|
|
120
|
+
created_at: new Date().toISOString(),
|
|
121
|
+
status: "pending_review",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const pending = readProcedures(reflexDir).filter((p) => p.status === "pending_review").length;
|
|
127
|
+
if (pending >= GROOM_THRESHOLD && deps.spawnGroomer) deps.spawnGroomer();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
deleteTrace(reflexDir, sessionId, promptId);
|
|
132
|
+
return { context: null };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const READ_ONLY_CMDS = ["cat", "ls", "git", "pwd", "head", "tail", "echo", "grep", "find"];
|
|
136
|
+
const PURE_READ_CMDS = ["cat", "ls", "pwd", "head", "tail"];
|
|
137
|
+
|
|
138
|
+
function isMutating(t, target) {
|
|
139
|
+
if (t === "Edit" || t === "Write" || t === "NotebookEdit") return true;
|
|
140
|
+
if (t === "Bash" || t === "bash") {
|
|
141
|
+
const cmd = (target || "").trim();
|
|
142
|
+
if (!cmd) return false;
|
|
143
|
+
const first = cmd.split(" ")[0];
|
|
144
|
+
if (READ_ONLY_CMDS.includes(first)) {
|
|
145
|
+
return first === "git" && /\s(commit|push|add|rm|mv)\b/.test(cmd);
|
|
146
|
+
}
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function judge(lines, evt) {
|
|
153
|
+
const steps = lines.filter((l) => l.t !== "Goal" && l.t !== "Recall");
|
|
154
|
+
if (!steps.some((l) => isMutating(l.t, l.target))) return false;
|
|
155
|
+
if (evt.interrupted || evt.busy) return false;
|
|
156
|
+
|
|
157
|
+
const failed = new Set();
|
|
158
|
+
for (const l of steps) {
|
|
159
|
+
const key = `${l.t}|${l.target}`;
|
|
160
|
+
if (l.ok === false) failed.add(key);
|
|
161
|
+
else if (l.ok === true) failed.delete(key);
|
|
162
|
+
}
|
|
163
|
+
return failed.size === 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function synthesize(lines) {
|
|
167
|
+
const steps = [];
|
|
168
|
+
for (const l of lines) {
|
|
169
|
+
if (l.t === "Goal" || l.t === "Recall" || l.ok === false) continue;
|
|
170
|
+
if (l.t === "Read" || l.t === "View") continue;
|
|
171
|
+
if (l.t === "Bash" || l.t === "bash") {
|
|
172
|
+
const target = l.target || "";
|
|
173
|
+
if (PURE_READ_CMDS.includes(target.trim().split(" ")[0])) continue;
|
|
174
|
+
if (/^git (status|log|diff)/.test(target)) continue;
|
|
175
|
+
}
|
|
176
|
+
steps.push({ t: l.t, target: l.target });
|
|
177
|
+
}
|
|
178
|
+
return steps;
|
|
179
|
+
}
|
package/lib/groomer.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { PROVIDERS, resolveCredentials } from "./providers.js";
|
|
4
|
+
|
|
5
|
+
// Silent Failure Logging
|
|
6
|
+
function logError(proceduresFile, msg) {
|
|
7
|
+
const reflexDir = path.dirname(proceduresFile);
|
|
8
|
+
const logFile = path.join(reflexDir, "worker.log");
|
|
9
|
+
const timestamp = new Date().toISOString();
|
|
10
|
+
try {
|
|
11
|
+
fs.appendFileSync(logFile, `[${timestamp}] ${msg}\n`, "utf8");
|
|
12
|
+
} catch (e) {
|
|
13
|
+
// nowhere to log, silently fail
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function runGroomer(proceduresFile) {
|
|
18
|
+
let creds;
|
|
19
|
+
try {
|
|
20
|
+
creds = resolveCredentials();
|
|
21
|
+
} catch (e) {
|
|
22
|
+
logError(proceduresFile, e.message);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (!creds) {
|
|
26
|
+
logError(
|
|
27
|
+
proceduresFile,
|
|
28
|
+
"No API key found. Run `npx agent-procedures auth login` or set ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY.",
|
|
29
|
+
);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
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
|
+
|
|
53
|
+
const prompt = `
|
|
54
|
+
Here are ${pending.length} new execution nodes/edges added by a coding agent.
|
|
55
|
+
|
|
56
|
+
<edges>
|
|
57
|
+
${JSON.stringify(pending, null, 2)}
|
|
58
|
+
</edges>
|
|
59
|
+
|
|
60
|
+
For each item:
|
|
61
|
+
1) Did the agent verify this worked? (e.g. ran a test, checked a log). If it just wrote code and blindly stopped without checking, it's risky.
|
|
62
|
+
2) Is this a generic/reusable workflow, or a one-off anomaly?
|
|
63
|
+
If it's unverified and risky, or a pure anomaly, decide to DROP it.
|
|
64
|
+
If it's good, KEEP it.
|
|
65
|
+
|
|
66
|
+
If you KEEP a root node (has no parent_id), generate 5-10 semantic aliases (synonyms) for its 'trigger' text so we can match variations of the user's prompt later.
|
|
67
|
+
|
|
68
|
+
Return ONLY a JSON array of objects with this schema:
|
|
69
|
+
[
|
|
70
|
+
{
|
|
71
|
+
"id": "node_id",
|
|
72
|
+
"action": "KEEP" | "DROP",
|
|
73
|
+
"aliases": ["alias1", "alias2"] // only if KEEP and root node
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
let responseText;
|
|
79
|
+
try {
|
|
80
|
+
responseText = await PROVIDERS[provider].complete(apiKey, model, prompt);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
logError(proceduresFile, err.message);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let actions = [];
|
|
87
|
+
try {
|
|
88
|
+
const jsonStr = responseText
|
|
89
|
+
.replace(/```json/g, "")
|
|
90
|
+
.replace(/```/g, "")
|
|
91
|
+
.trim();
|
|
92
|
+
actions = JSON.parse(jsonStr);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
logError(
|
|
95
|
+
proceduresFile,
|
|
96
|
+
"Failed to parse LLM JSON response: " + responseText,
|
|
97
|
+
);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const newNodes = [];
|
|
102
|
+
for (const node of nodes) {
|
|
103
|
+
if (node.status === "pending_review") {
|
|
104
|
+
const actionDef = actions.find((a) => a.id === node.id);
|
|
105
|
+
if (actionDef) {
|
|
106
|
+
if (actionDef.action === "DROP") {
|
|
107
|
+
continue;
|
|
108
|
+
} else {
|
|
109
|
+
delete node.status;
|
|
110
|
+
if (!node.parent_id && actionDef.aliases) {
|
|
111
|
+
node.aliases = actionDef.aliases;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
newNodes.push(node);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Atomic Writes for the Groomer
|
|
120
|
+
const newFileContent =
|
|
121
|
+
newNodes.map((n) => JSON.stringify(n)).join("\n") + "\n";
|
|
122
|
+
const tmpFile = proceduresFile + ".tmp";
|
|
123
|
+
try {
|
|
124
|
+
fs.writeFileSync(tmpFile, newFileContent, "utf8");
|
|
125
|
+
fs.renameSync(tmpFile, proceduresFile);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
logError(proceduresFile, `Failed atomic write: ${err.message}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Claude Code adapter. Everything Claude-specific lives here.
|
|
5
|
+
// Docs: https://code.claude.com/docs/en/hooks
|
|
6
|
+
|
|
7
|
+
const EVENTS = ["UserPromptSubmit", "PostToolUse", "PostToolUseFailure", "Stop"];
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
id: "claude",
|
|
11
|
+
|
|
12
|
+
/** Where the generated hook shim goes, relative to the repo root. */
|
|
13
|
+
hookFile: ".claude/hooks/reflex.mjs",
|
|
14
|
+
|
|
15
|
+
/** Merge our hooks into .claude/settings.json without touching anything else. */
|
|
16
|
+
register(cwd) {
|
|
17
|
+
const settingsPath = path.join(cwd, ".claude", "settings.json");
|
|
18
|
+
let settings = {};
|
|
19
|
+
if (fs.existsSync(settingsPath)) {
|
|
20
|
+
try {
|
|
21
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
22
|
+
} catch (e) {
|
|
23
|
+
console.warn("Failed to parse existing .claude/settings.json, overwriting...");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
settings.hooks = settings.hooks || {};
|
|
27
|
+
|
|
28
|
+
for (const event of EVENTS) {
|
|
29
|
+
if (!Array.isArray(settings.hooks[event])) settings.hooks[event] = [];
|
|
30
|
+
const command = `node "$CLAUDE_PROJECT_DIR/${this.hookFile}" ${event}`;
|
|
31
|
+
const wired = settings.hooks[event].some((g) => (g.hooks || []).some((h) => h.command === command));
|
|
32
|
+
if (!wired) settings.hooks[event].push({ hooks: [{ type: "command", command }] });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
36
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
/** Claude's stdin payload → normalized engine event, or null to ignore. */
|
|
40
|
+
normalize(eventName, payload) {
|
|
41
|
+
const name = payload.hook_event_name || eventName;
|
|
42
|
+
const base = { sessionId: payload.session_id, promptId: payload.prompt_id };
|
|
43
|
+
|
|
44
|
+
if (name === "UserPromptSubmit") {
|
|
45
|
+
return { type: "prompt", ...base, prompt: payload.prompt };
|
|
46
|
+
}
|
|
47
|
+
if (name === "PostToolUse" || name === "PostToolUseFailure") {
|
|
48
|
+
const tool = payload.tool_name;
|
|
49
|
+
const input = payload.tool_input || {};
|
|
50
|
+
let target = "";
|
|
51
|
+
if (tool === "Bash" || tool === "bash") target = input.command || "";
|
|
52
|
+
else if (typeof input === "string") target = input;
|
|
53
|
+
else target = input.target || input.file_path || input.path || "";
|
|
54
|
+
return { type: "tool", ...base, tool, target, ok: name === "PostToolUse" };
|
|
55
|
+
}
|
|
56
|
+
if (name === "Stop") {
|
|
57
|
+
return {
|
|
58
|
+
type: "stop",
|
|
59
|
+
...base,
|
|
60
|
+
interrupted: payload.stop_hook_active === true,
|
|
61
|
+
busy: Array.isArray(payload.background_tasks) && payload.background_tasks.length > 0,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
/** Engine result → what to print on stdout for Claude. */
|
|
68
|
+
render(evt, result) {
|
|
69
|
+
if (evt.type !== "prompt") return "";
|
|
70
|
+
if (!result.context) return "{}";
|
|
71
|
+
return JSON.stringify({
|
|
72
|
+
hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: result.context },
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import claude from "./claude.js";
|
|
2
|
+
|
|
3
|
+
// To add a harness: write lib/harnesses/<id>.js with the same shape as claude.js
|
|
4
|
+
// (id, hookFile, register, normalize, render) and list it here.
|
|
5
|
+
export const HARNESSES = { claude };
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_HARNESS = "claude";
|
|
8
|
+
|
|
9
|
+
export function getHarness(id = DEFAULT_HARNESS) {
|
|
10
|
+
const h = HARNESSES[id];
|
|
11
|
+
if (!h) {
|
|
12
|
+
throw new Error(`Unknown harness "${id}". Available: ${Object.keys(HARNESSES).join(", ")}`);
|
|
13
|
+
}
|
|
14
|
+
return h;
|
|
15
|
+
}
|
package/lib/init.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { DEFAULT_HARNESS, getHarness } from "./harnesses/index.js";
|
|
5
|
+
|
|
6
|
+
const LIB_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
3
7
|
|
|
4
8
|
const DEFAULT_CONFIG = {
|
|
5
9
|
version: 1,
|
|
6
|
-
harness: "
|
|
7
|
-
recall: true,
|
|
8
|
-
capture: true,
|
|
10
|
+
harness: "claude",
|
|
9
11
|
minConfidence: 0.7,
|
|
10
12
|
store: {
|
|
11
13
|
procedures: "procedures.jsonl",
|
|
@@ -29,9 +31,14 @@ function writeIfMissing(filePath, contents) {
|
|
|
29
31
|
return "created";
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
function writeAlways(filePath, contents) {
|
|
35
|
+
fs.writeFileSync(filePath, contents, "utf8");
|
|
36
|
+
return "updated";
|
|
37
|
+
}
|
|
38
|
+
|
|
32
39
|
function ensureGitignore(cwd) {
|
|
33
40
|
const gitignorePath = path.join(cwd, ".gitignore");
|
|
34
|
-
const entry = ".reflex/";
|
|
41
|
+
const entry = ".reflex/traces/";
|
|
35
42
|
|
|
36
43
|
if (!fs.existsSync(gitignorePath)) {
|
|
37
44
|
fs.writeFileSync(gitignorePath, `${entry}\n`, "utf8");
|
|
@@ -41,7 +48,8 @@ function ensureGitignore(cwd) {
|
|
|
41
48
|
const current = fs.readFileSync(gitignorePath, "utf8");
|
|
42
49
|
const lines = current.split(/\r?\n/);
|
|
43
50
|
const alreadyIgnored = lines.some(
|
|
44
|
-
(line) =>
|
|
51
|
+
(line) =>
|
|
52
|
+
line.trim() === ".reflex/traces/" || line.trim() === ".reflex/traces",
|
|
45
53
|
);
|
|
46
54
|
|
|
47
55
|
if (alreadyIgnored) {
|
|
@@ -53,18 +61,71 @@ function ensureGitignore(cwd) {
|
|
|
53
61
|
return "updated";
|
|
54
62
|
}
|
|
55
63
|
|
|
56
|
-
|
|
64
|
+
// Runtime that gets copied into <repo>/.reflex/engine/. npx doesn't leave the
|
|
65
|
+
// package behind, so the repo has to carry the engine. Harness-neutral location
|
|
66
|
+
// so several harnesses in one repo share a single copy.
|
|
67
|
+
const ENGINE_FILES = [
|
|
68
|
+
"main.js",
|
|
69
|
+
"engine.js",
|
|
70
|
+
"store.js",
|
|
71
|
+
"groomer.js",
|
|
72
|
+
"providers.js",
|
|
73
|
+
"harnesses/index.js",
|
|
74
|
+
"harnesses/claude.js",
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
function installEngine(reflexDir) {
|
|
78
|
+
const engineDir = path.join(reflexDir, "engine");
|
|
79
|
+
for (const f of ENGINE_FILES) {
|
|
80
|
+
const dest = path.join(engineDir, f);
|
|
81
|
+
ensureDir(path.dirname(dest));
|
|
82
|
+
fs.copyFileSync(path.join(LIB_DIR, f), dest);
|
|
83
|
+
}
|
|
84
|
+
return "updated";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A few lines that point the harness's hook at the shared engine.
|
|
88
|
+
function writeShim(cwd, harness) {
|
|
89
|
+
const hookPath = path.join(cwd, harness.hookFile);
|
|
90
|
+
const enginePath = path.join(cwd, ".reflex", "engine", "main.js");
|
|
91
|
+
let rel = path.relative(path.dirname(hookPath), enginePath).split(path.sep).join("/");
|
|
92
|
+
if (!rel.startsWith(".")) rel = "./" + rel;
|
|
93
|
+
|
|
94
|
+
ensureDir(path.dirname(hookPath));
|
|
95
|
+
writeAlways(
|
|
96
|
+
hookPath,
|
|
97
|
+
`#!/usr/bin/env node
|
|
98
|
+
// Generated by agent-procedures init. Re-run init to refresh; do not edit.
|
|
99
|
+
import { run } from "${rel}";
|
|
100
|
+
await run(${JSON.stringify(harness.id)});
|
|
101
|
+
`,
|
|
102
|
+
);
|
|
103
|
+
fs.chmodSync(hookPath, 0o755);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function init(cwd = process.cwd(), { harness: harnessId = DEFAULT_HARNESS } = {}) {
|
|
107
|
+
const harness = getHarness(harnessId);
|
|
57
108
|
const reflexDir = path.join(cwd, ".reflex");
|
|
109
|
+
const tracesDir = path.join(reflexDir, "traces");
|
|
110
|
+
|
|
111
|
+
ensureDir(reflexDir);
|
|
112
|
+
ensureDir(tracesDir);
|
|
113
|
+
|
|
58
114
|
const results = {
|
|
59
|
-
dir:
|
|
115
|
+
dir: "exists",
|
|
60
116
|
config: writeIfMissing(
|
|
61
117
|
path.join(reflexDir, "config.json"),
|
|
62
|
-
`${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n
|
|
118
|
+
`${JSON.stringify({ ...DEFAULT_CONFIG, harness: harness.id }, null, 2)}\n`,
|
|
63
119
|
),
|
|
64
120
|
procedures: writeIfMissing(path.join(reflexDir, "procedures.jsonl"), ""),
|
|
65
121
|
runs: writeIfMissing(path.join(reflexDir, "runs.jsonl"), ""),
|
|
66
122
|
gitignore: ensureGitignore(cwd),
|
|
123
|
+
engine: installEngine(reflexDir),
|
|
67
124
|
};
|
|
68
125
|
|
|
126
|
+
writeShim(cwd, harness);
|
|
127
|
+
harness.register(cwd);
|
|
128
|
+
results.hooks = "updated";
|
|
129
|
+
|
|
69
130
|
return results;
|
|
70
131
|
}
|
package/lib/main.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { handleEvent } from "./engine.js";
|
|
5
|
+
import { getHarness } from "./harnesses/index.js";
|
|
6
|
+
|
|
7
|
+
// Entry point for the generated hook shim. Lives in <repo>/.reflex/engine/,
|
|
8
|
+
// so .reflex/ is one level up.
|
|
9
|
+
const REFLEX_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
|
|
11
|
+
function readStdin() {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
let data = "";
|
|
14
|
+
process.stdin.setEncoding("utf8");
|
|
15
|
+
process.stdin.on("data", (c) => (data += c));
|
|
16
|
+
process.stdin.on("end", () => resolve(data));
|
|
17
|
+
process.stdin.on("error", () => resolve(data));
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function spawnGroomer() {
|
|
22
|
+
const child = spawn(process.argv[0], [process.argv[1], "Groom"], { detached: true, stdio: "ignore" });
|
|
23
|
+
child.unref();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function run(harnessId, argv = process.argv.slice(2)) {
|
|
27
|
+
const [eventName] = argv;
|
|
28
|
+
if (!eventName) return;
|
|
29
|
+
|
|
30
|
+
if (eventName === "Groom") {
|
|
31
|
+
const { runGroomer } = await import("./groomer.js");
|
|
32
|
+
await runGroomer(path.join(REFLEX_DIR, "procedures.jsonl"));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const raw = await readStdin();
|
|
37
|
+
if (!raw) return;
|
|
38
|
+
let payload;
|
|
39
|
+
try {
|
|
40
|
+
payload = JSON.parse(raw);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const harness = getHarness(harnessId);
|
|
46
|
+
const evt = harness.normalize(eventName, payload);
|
|
47
|
+
if (!evt) return;
|
|
48
|
+
|
|
49
|
+
const result = handleEvent(evt, REFLEX_DIR, { spawnGroomer });
|
|
50
|
+
const out = harness.render(evt, result);
|
|
51
|
+
if (out) process.stdout.write(out + "\n");
|
|
52
|
+
}
|
package/lib/providers.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
// Shared by `auth` (CLI) and the groomer (background hook). Keep dependency-free.
|
|
6
|
+
|
|
7
|
+
export function reflexHome() {
|
|
8
|
+
return process.env.REFLEX_HOME || path.join(os.homedir(), ".reflex");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function credentialsFile() {
|
|
12
|
+
return path.join(reflexHome(), "credentials.json");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SYSTEM_PROMPT =
|
|
16
|
+
"You are the Reflex graph groomer. Your job is to review execution edges from coding agents, drop bad ones, and add semantic aliases.";
|
|
17
|
+
|
|
18
|
+
async function expectOk(res, label) {
|
|
19
|
+
if (res.ok) return;
|
|
20
|
+
let detail = await res.text().catch(() => "");
|
|
21
|
+
try {
|
|
22
|
+
// All three providers return { error: { message } } on failure.
|
|
23
|
+
detail = JSON.parse(detail).error?.message || detail;
|
|
24
|
+
} catch (e) {}
|
|
25
|
+
detail = detail.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
26
|
+
throw new Error(`${label} API error: ${res.status} ${res.statusText}${detail ? ` - ${detail}` : ""}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const PROVIDERS = {
|
|
30
|
+
anthropic: {
|
|
31
|
+
label: "Anthropic",
|
|
32
|
+
envVar: "ANTHROPIC_API_KEY",
|
|
33
|
+
defaultModel: "claude-haiku-4-5",
|
|
34
|
+
keyPrefix: "sk-ant-",
|
|
35
|
+
keysUrl: "https://console.anthropic.com/settings/keys",
|
|
36
|
+
async verify(apiKey) {
|
|
37
|
+
const res = await fetch("https://api.anthropic.com/v1/models?limit=1", {
|
|
38
|
+
headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
|
|
39
|
+
});
|
|
40
|
+
await expectOk(res, "Anthropic");
|
|
41
|
+
},
|
|
42
|
+
async complete(apiKey, model, prompt) {
|
|
43
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: {
|
|
46
|
+
"x-api-key": apiKey,
|
|
47
|
+
"anthropic-version": "2023-06-01",
|
|
48
|
+
"content-type": "application/json",
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify({
|
|
51
|
+
model,
|
|
52
|
+
max_tokens: 1024,
|
|
53
|
+
system: SYSTEM_PROMPT,
|
|
54
|
+
messages: [{ role: "user", content: prompt }],
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
await expectOk(res, "Anthropic");
|
|
58
|
+
const data = await res.json();
|
|
59
|
+
return data.content[0].text;
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
openai: {
|
|
64
|
+
label: "OpenAI",
|
|
65
|
+
envVar: "OPENAI_API_KEY",
|
|
66
|
+
defaultModel: "gpt-6-luna",
|
|
67
|
+
keyPrefix: "sk-",
|
|
68
|
+
keysUrl: "https://platform.openai.com/api-keys",
|
|
69
|
+
async verify(apiKey) {
|
|
70
|
+
const res = await fetch("https://api.openai.com/v1/models", {
|
|
71
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
72
|
+
});
|
|
73
|
+
await expectOk(res, "OpenAI");
|
|
74
|
+
},
|
|
75
|
+
async complete(apiKey, model, prompt) {
|
|
76
|
+
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: {
|
|
79
|
+
Authorization: `Bearer ${apiKey}`,
|
|
80
|
+
"content-type": "application/json",
|
|
81
|
+
},
|
|
82
|
+
body: JSON.stringify({
|
|
83
|
+
model,
|
|
84
|
+
max_tokens: 1024,
|
|
85
|
+
messages: [
|
|
86
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
87
|
+
{ role: "user", content: prompt },
|
|
88
|
+
],
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
await expectOk(res, "OpenAI");
|
|
92
|
+
const data = await res.json();
|
|
93
|
+
return data.choices[0].message.content;
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
gemini: {
|
|
98
|
+
label: "Gemini",
|
|
99
|
+
envVar: "GEMINI_API_KEY",
|
|
100
|
+
defaultModel: "gemini-3.5-flash-lite",
|
|
101
|
+
keyPrefix: "AIza",
|
|
102
|
+
keysUrl: "https://aistudio.google.com/apikey",
|
|
103
|
+
async verify(apiKey) {
|
|
104
|
+
const res = await fetch(
|
|
105
|
+
"https://generativelanguage.googleapis.com/v1beta/models?pageSize=1",
|
|
106
|
+
{ headers: { "x-goog-api-key": apiKey } },
|
|
107
|
+
);
|
|
108
|
+
await expectOk(res, "Gemini");
|
|
109
|
+
},
|
|
110
|
+
async complete(apiKey, model, prompt) {
|
|
111
|
+
const res = await fetch(
|
|
112
|
+
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
|
|
113
|
+
{
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: {
|
|
116
|
+
"x-goog-api-key": apiKey,
|
|
117
|
+
"content-type": "application/json",
|
|
118
|
+
},
|
|
119
|
+
body: JSON.stringify({
|
|
120
|
+
system_instruction: { parts: [{ text: SYSTEM_PROMPT }] },
|
|
121
|
+
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
|
122
|
+
generationConfig: { maxOutputTokens: 1024 },
|
|
123
|
+
}),
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
await expectOk(res, "Gemini");
|
|
127
|
+
const data = await res.json();
|
|
128
|
+
return data.candidates[0].content.parts[0].text;
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const PROVIDER_NAMES = Object.keys(PROVIDERS);
|
|
134
|
+
|
|
135
|
+
export function readCredentials() {
|
|
136
|
+
const file = credentialsFile();
|
|
137
|
+
if (!fs.existsSync(file)) return null;
|
|
138
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolve which provider/key/model the groomer should use.
|
|
143
|
+
*
|
|
144
|
+
* Provider: credentials.json's defaultProvider if present, else the first
|
|
145
|
+
* provider with an env var set.
|
|
146
|
+
* Key: the provider's env var wins over the stored key.
|
|
147
|
+
* Model: stored model, else the provider default.
|
|
148
|
+
*
|
|
149
|
+
* Returns null if nothing usable is found.
|
|
150
|
+
*/
|
|
151
|
+
export function resolveCredentials(env = process.env) {
|
|
152
|
+
let stored = null;
|
|
153
|
+
try {
|
|
154
|
+
stored = readCredentials();
|
|
155
|
+
} catch (e) {
|
|
156
|
+
throw new Error(`Failed to parse ${credentialsFile()}: ${e.message}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let provider = stored?.defaultProvider;
|
|
160
|
+
if (!provider) {
|
|
161
|
+
provider = PROVIDER_NAMES.find((p) => env[PROVIDERS[p].envVar]);
|
|
162
|
+
}
|
|
163
|
+
if (!provider || !PROVIDERS[provider]) return null;
|
|
164
|
+
|
|
165
|
+
const def = PROVIDERS[provider];
|
|
166
|
+
const entry = stored?.providers?.[provider] || {};
|
|
167
|
+
|
|
168
|
+
const envKey = env[def.envVar];
|
|
169
|
+
const apiKey = envKey || entry.apiKey;
|
|
170
|
+
if (!apiKey) return null;
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
provider,
|
|
174
|
+
apiKey,
|
|
175
|
+
model: entry.model || def.defaultModel,
|
|
176
|
+
source: envKey ? "env" : "file",
|
|
177
|
+
};
|
|
178
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Disk layout under .reflex/. Harness-free.
|
|
5
|
+
|
|
6
|
+
export function paths(reflexDir) {
|
|
7
|
+
return {
|
|
8
|
+
reflexDir,
|
|
9
|
+
tracesDir: path.join(reflexDir, "traces"),
|
|
10
|
+
proceduresFile: path.join(reflexDir, "procedures.jsonl"),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readJsonl(file) {
|
|
15
|
+
if (!fs.existsSync(file)) return [];
|
|
16
|
+
return fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function traceFile(reflexDir, sessionId, promptId) {
|
|
20
|
+
return path.join(paths(reflexDir).tracesDir, sessionId, `${promptId}.jsonl`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function startTrace(reflexDir, sessionId, promptId, goal) {
|
|
24
|
+
const file = traceFile(reflexDir, sessionId, promptId);
|
|
25
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
26
|
+
fs.writeFileSync(file, JSON.stringify(goal) + "\n", "utf8");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function appendTrace(reflexDir, sessionId, promptId, line) {
|
|
30
|
+
const file = traceFile(reflexDir, sessionId, promptId);
|
|
31
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
32
|
+
fs.appendFileSync(file, JSON.stringify(line) + "\n", "utf8");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readTrace(reflexDir, sessionId, promptId) {
|
|
36
|
+
return readJsonl(traceFile(reflexDir, sessionId, promptId));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function deleteTrace(reflexDir, sessionId, promptId) {
|
|
40
|
+
try {
|
|
41
|
+
fs.unlinkSync(traceFile(reflexDir, sessionId, promptId));
|
|
42
|
+
} catch (e) {}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readProcedures(reflexDir) {
|
|
46
|
+
return readJsonl(paths(reflexDir).proceduresFile);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function appendProcedure(reflexDir, node) {
|
|
50
|
+
const file = paths(reflexDir).proceduresFile;
|
|
51
|
+
if (!fs.existsSync(file)) fs.writeFileSync(file, "", "utf8");
|
|
52
|
+
fs.appendFileSync(file, JSON.stringify(node) + "\n", "utf8");
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-procedures",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Procedural memory for coding agents",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin":
|
|
6
|
+
"bin": {
|
|
7
|
+
"agent-procedures": "./bin/cli.js"
|
|
8
|
+
},
|
|
7
9
|
"files": [
|
|
8
10
|
"bin",
|
|
9
11
|
"lib"
|
|
@@ -11,6 +13,9 @@
|
|
|
11
13
|
"engines": {
|
|
12
14
|
"node": ">=18"
|
|
13
15
|
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "vitest run"
|
|
18
|
+
},
|
|
14
19
|
"keywords": [
|
|
15
20
|
"agents",
|
|
16
21
|
"coding-agents",
|
|
@@ -27,5 +32,8 @@
|
|
|
27
32
|
"bugs": {
|
|
28
33
|
"url": "https://github.com/itskrishna21/reflex/issues"
|
|
29
34
|
},
|
|
30
|
-
"homepage": "https://github.com/itskrishna21/reflex#readme"
|
|
35
|
+
"homepage": "https://github.com/itskrishna21/reflex#readme",
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"vitest": "^5.0.3"
|
|
38
|
+
}
|
|
31
39
|
}
|