@sawbuck/reins 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/bin/reins.mjs +46 -23
- package/codex.mjs +99 -0
- package/judge.mjs +13 -3
- package/package.json +2 -1
- package/scan.mjs +10 -2
- package/wizard.mjs +8 -3
package/README.md
CHANGED
|
@@ -21,6 +21,13 @@ $ reins
|
|
|
21
21
|
the check guards the 7 turns you didn't steer - a miss there is the risk.
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
## Harnesses
|
|
25
|
+
|
|
26
|
+
reins reads **Claude Code** transcripts today. The parser lives in `scan.mjs`; everything
|
|
27
|
+
downstream works on abstract `{ prior, user }` turn-pairs, so another harness (Codex, Cursor)
|
|
28
|
+
is a parallel module that emits the same pairs plus its own session resolver, not a rewrite.
|
|
29
|
+
`pairs()` is the seam.
|
|
30
|
+
|
|
24
31
|
## Backend (SAWBUCK_JUDGE)
|
|
25
32
|
|
|
26
33
|
The judge model is a socket. Set `SAWBUCK_JUDGE` (default `ollama:qwen2.5:7b`):
|
package/bin/reins.mjs
CHANGED
|
@@ -4,14 +4,22 @@
|
|
|
4
4
|
// the seismograph + distribution. The check guards the turns you did not steer.
|
|
5
5
|
|
|
6
6
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
7
|
-
import { join, dirname } from "node:path";
|
|
8
|
-
import
|
|
7
|
+
import { join, dirname, relative } from "node:path";
|
|
8
|
+
import * as claudeHarness from "../scan.mjs";
|
|
9
|
+
import * as codexHarness from "../codex.mjs";
|
|
10
|
+
import { scan } from "../scan.mjs";
|
|
9
11
|
import { score, backend, harvestScore } from "../judge.mjs";
|
|
10
|
-
import { harvest,
|
|
12
|
+
import { harvest, aggregateCues } from "../wizard.mjs";
|
|
13
|
+
|
|
14
|
+
// show a path relative to the cwd when it lives under it (clean output, no leaked home dir);
|
|
15
|
+
// fall back to the absolute path for anything outside the tree.
|
|
16
|
+
const rel = (p) => { const r = relative(process.cwd(), p); return r && !r.startsWith("..") ? r : p; };
|
|
11
17
|
|
|
12
18
|
const argv = process.argv.slice(2);
|
|
13
19
|
const flag = (n) => { const i = argv.indexOf(n); return i > -1 ? argv[i + 1] : null; };
|
|
14
20
|
const has = (n) => argv.includes(n);
|
|
21
|
+
// harness adapter: whose transcripts to read. --harness codex (or SAWBUCK_HARNESS=codex); default Claude Code.
|
|
22
|
+
const H = (flag("--harness") || process.env.SAWBUCK_HARNESS) === "codex" ? codexHarness : claudeHarness;
|
|
15
23
|
|
|
16
24
|
// ---- `reins wizard` / `reins harvest`: read your old chats, rank the cues you keep giving ----
|
|
17
25
|
if (argv[0] === "wizard" || argv[0] === "harvest") {
|
|
@@ -38,7 +46,7 @@ It reads every turn-pair with a model. On the local ollama default that is free;
|
|
|
38
46
|
const maxPairs = parseInt(flag("--max"), 10) || 400;
|
|
39
47
|
const sinceDays = parseInt(flag("--since"), 10) || 0;
|
|
40
48
|
const sinceMs = sinceDays > 0 ? Date.now() - sinceDays * 86400000 : 0;
|
|
41
|
-
const sessions = resolveSessions({ limit: sessionsN, sinceMs });
|
|
49
|
+
const sessions = H.resolveSessions({ limit: sessionsN, sinceMs });
|
|
42
50
|
if (!sessions.length) { process.stderr.write("reins wizard: no Claude Code sessions found under ~/.claude/projects\n"); process.exit(2); }
|
|
43
51
|
const hosted = !b.startsWith("ollama:");
|
|
44
52
|
if (hosted && !has("--yes")) {
|
|
@@ -46,7 +54,7 @@ It reads every turn-pair with a model. On the local ollama default that is free;
|
|
|
46
54
|
process.exit(2);
|
|
47
55
|
}
|
|
48
56
|
process.stderr.write(`reins wizard: harvesting up to ${maxPairs} turn-pairs from ${sessions.length} session(s) via ${b}${hosted ? " (hosted, paid)" : " (local, free)"}...\n`);
|
|
49
|
-
const rw = await harvest(harvestScore, { sessions, maxPairs });
|
|
57
|
+
const rw = await harvest(harvestScore, { sessions, maxPairs, pairsFn: H.pairs });
|
|
50
58
|
const wf = flag("--write");
|
|
51
59
|
const out = wf && !wf.startsWith("--") ? wf : join(process.cwd(), ".sawbuck", "cues.json");
|
|
52
60
|
if (has("--write") || !has("--json")) {
|
|
@@ -56,14 +64,16 @@ It reads every turn-pair with a model. On the local ollama default that is free;
|
|
|
56
64
|
if (has("--json")) { process.stdout.write(JSON.stringify({ backend: b, ...rw }, null, 2) + "\n"); process.exit(0); }
|
|
57
65
|
const pct = rw.scored ? Math.round((rw.steers / rw.scored) * 100) : 0;
|
|
58
66
|
process.stdout.write(`\n reins · wizard (judge: ${b})\n`);
|
|
59
|
-
process.stdout.write(` read ${rw.pairs} turn-pairs from ${rw.sessionsRead} session(s) · ${rw.steers} steers (${pct}%)\n
|
|
67
|
+
process.stdout.write(` read ${rw.pairs} turn-pairs from ${rw.sessionsRead} session(s) · ${rw.steers} steers (${pct}%)\n`);
|
|
68
|
+
if (rw.axes && rw.axes.length) process.stdout.write(` you steer most on: ${rw.axes.slice(0, 5).map((a) => `${a.axis} (${a.count})`).join(" · ")}\n`);
|
|
69
|
+
process.stdout.write("\n");
|
|
60
70
|
const top = rw.cues.filter((c) => c.count >= 2 || c.gen > 0).slice(0, 12);
|
|
61
|
-
if (!top.length) { process.stdout.write(" no repeated cues
|
|
71
|
+
if (!top.length) { process.stdout.write(" no repeated specific cues yet (the axis fingerprint above is the durable signal) - harvest more with --sessions / --since.\n\n"); process.exit(0); }
|
|
62
72
|
process.stdout.write(" your top cues (candidates from your history, review before trusting):\n");
|
|
63
73
|
for (const c of top) {
|
|
64
74
|
process.stdout.write(` ${("[" + c.axis + "]").padEnd(13)} ${(c.cue || "(unnamed)").padEnd(34)} · ${c.sessions} sess · gen ${c.genShare} · ${c.count}x\n`);
|
|
65
75
|
}
|
|
66
|
-
process.stdout.write(`\n wrote ${out}\n these are LEADS. Promote the ones that are really you; the check still guards the rest.\n\n`);
|
|
76
|
+
process.stdout.write(`\n wrote ${rel(out)}\n these are LEADS. Promote the ones that are really you; the check still guards the rest.\n\n`);
|
|
67
77
|
process.exit(0);
|
|
68
78
|
}
|
|
69
79
|
|
|
@@ -88,28 +98,33 @@ Free on the local ollama default. Seed from your whole history once with: reins
|
|
|
88
98
|
}
|
|
89
99
|
if (flag("--judge")) process.env.SAWBUCK_JUDGE = flag("--judge");
|
|
90
100
|
const b = backend();
|
|
91
|
-
const sess = resolveSessionPath(flag("--session"));
|
|
101
|
+
const sess = H.resolveSessionPath(flag("--session"));
|
|
92
102
|
if (!sess) { process.stderr.write("reins cues: no session transcript found\n"); process.exit(2); }
|
|
93
103
|
let ctext = ""; try { ctext = readFileSync(sess, "utf8"); } catch { process.stderr.write("reins cues: cannot read " + sess + "\n"); process.exit(2); }
|
|
94
|
-
const allC = pairs(ctext);
|
|
104
|
+
const allC = H.pairs(ctext);
|
|
95
105
|
const cout = join(process.cwd(), ".sawbuck", "cues.json");
|
|
96
106
|
let prior = { session: "", lastPairs: 0, items: [] };
|
|
97
107
|
if (has("--append")) { try { const p = JSON.parse(readFileSync(cout, "utf8")); if (p && Array.isArray(p.items)) prior = { session: p.session || "", lastPairs: p.lastPairs || 0, items: p.items }; } catch { /* fresh */ } }
|
|
98
|
-
const startIdx = has("--append") && prior.session === sess ? prior.lastPairs : 0; // same session = resume; new session = score
|
|
108
|
+
const startIdx = has("--append") && prior.session === sess ? Math.min(prior.lastPairs, allC.length) : 0; // same session = resume; new/changed session = score from its start
|
|
99
109
|
let fresh = allC.slice(startIdx);
|
|
100
|
-
|
|
110
|
+
// bounded per call: --append scores at most PER new pairs (oldest-first, so a backlog drains over
|
|
111
|
+
// turns); a full scan caps at --max newest. Keeps the per-turn background call cheap.
|
|
112
|
+
const PER = has("--append") ? 40 : (parseInt(flag("--max"), 10) || 400);
|
|
113
|
+
if (fresh.length > PER) fresh = has("--append") ? fresh.slice(0, PER) : fresh.slice(-PER);
|
|
114
|
+
const consumed = startIdx + fresh.length;
|
|
101
115
|
const items = has("--append") ? prior.items.slice() : [];
|
|
102
116
|
const sid = sess.split("/").pop();
|
|
103
117
|
for (const { prior: a, user: u } of fresh) { let r = null; try { r = await harvestScore(a, u); } catch {} items.push({ session: sid, result: r }); }
|
|
104
118
|
const capped = items.slice(-1500);
|
|
105
119
|
const agg = aggregateCues(capped);
|
|
106
|
-
const rec = { at: new Date().toISOString(), backend: b, session: sess, lastPairs: allC.length, pairs: capped.length, ...agg, items: capped };
|
|
120
|
+
const rec = { at: new Date().toISOString(), backend: b, session: sess, lastPairs: has("--append") ? consumed : allC.length, pairs: capped.length, ...agg, items: capped };
|
|
107
121
|
try { mkdirSync(dirname(cout), { recursive: true }); writeFileSync(cout, JSON.stringify(rec, null, 2) + "\n"); } catch { process.stderr.write("reins cues: could not write " + cout + "\n"); }
|
|
108
122
|
if (has("--json")) { const { items: _omit, ...pub } = rec; process.stdout.write(JSON.stringify(pub, null, 2) + "\n"); process.exit(0); }
|
|
109
123
|
const top = agg.cues.filter((c) => c.count >= 2 || c.gen > 0).slice(0, 10);
|
|
110
|
-
process.stdout.write(`\n reins · cues (judge: ${b})\n ${agg.steers} steers over ${agg.scored} scored turns
|
|
111
|
-
|
|
112
|
-
process.stdout.write(`
|
|
124
|
+
process.stdout.write(`\n reins · cues (judge: ${b})\n ${agg.steers} steers over ${agg.scored} scored turns\n`);
|
|
125
|
+
if (agg.axes && agg.axes.length) process.stdout.write(` you steer most on: ${agg.axes.slice(0, 4).map((a) => `${a.axis} (${a.count})`).join(" · ")}\n`);
|
|
126
|
+
if (top.length) { process.stdout.write(` candidate cues (review before trusting):\n`); for (const c of top) process.stdout.write(` ${("[" + c.axis + "]").padEnd(12)} ${(c.cue || "(unnamed)").padEnd(32)} · ${c.count}x\n`); }
|
|
127
|
+
process.stdout.write(` wrote ${rel(cout)}\n\n`);
|
|
113
128
|
process.exit(0);
|
|
114
129
|
}
|
|
115
130
|
|
|
@@ -128,13 +143,16 @@ Usage: reins [--session <path.jsonl>] [--judge <backend>] [-n N] [--json] [--wri
|
|
|
128
143
|
--label <term> the word for the stat on the share card (default "steer rate";
|
|
129
144
|
e.g. "yank rate" for a playful channel - keep it neutral for LinkedIn)
|
|
130
145
|
|
|
146
|
+
--harness <h> whose transcripts to read: claude (default) | codex (reads ~/.codex/sessions)
|
|
147
|
+
|
|
148
|
+
Commands: 'reins wizard' (harvest history into cues), 'reins cues' (the live cue inventory). --help on each.
|
|
131
149
|
Backend is SAWBUCK_JUDGE (default ollama:qwen2.5:7b). Local = free + private.
|
|
132
150
|
`);
|
|
133
151
|
process.exit(0);
|
|
134
152
|
}
|
|
135
153
|
|
|
136
154
|
if (flag("--judge")) process.env.SAWBUCK_JUDGE = flag("--judge");
|
|
137
|
-
const sess = resolveSessionPath(flag("--session"));
|
|
155
|
+
const sess = H.resolveSessionPath(flag("--session"));
|
|
138
156
|
if (!sess) { process.stderr.write("reins: no session transcript found (pass --session <path.jsonl>)\n"); process.exit(2); }
|
|
139
157
|
|
|
140
158
|
let text = "";
|
|
@@ -151,9 +169,14 @@ if (has("--append")) {
|
|
|
151
169
|
const p = JSON.parse(readFileSync(out, "utf8"));
|
|
152
170
|
if (p && p.lastPairs != null) prior = { lastPairs: p.lastPairs, scored: p.scored || 0, steering: p.steering || 0, wsum: p._wsum || 0, distribution: p.distribution || prior.distribution };
|
|
153
171
|
} catch { /* no prior tally = fresh */ }
|
|
154
|
-
const all = pairs(text);
|
|
172
|
+
const all = H.pairs(text);
|
|
155
173
|
let added = 0;
|
|
156
|
-
|
|
174
|
+
// bounded per call (graceful): at most PER new pairs this run, so the first --append on a long
|
|
175
|
+
// session never blocks on hundreds of judge calls at once; a backlog drains over the next turns.
|
|
176
|
+
const PER = 40;
|
|
177
|
+
const start = Math.min(prior.lastPairs, all.length);
|
|
178
|
+
const consumed = Math.min(start + PER, all.length);
|
|
179
|
+
for (const { prior: a, user: u } of all.slice(start, consumed)) {
|
|
157
180
|
let s = null; try { s = await score(a, u); } catch {}
|
|
158
181
|
if (!s) continue;
|
|
159
182
|
prior.distribution[s.pressure] = (prior.distribution[s.pressure] || 0) + 1;
|
|
@@ -161,7 +184,7 @@ if (has("--append")) {
|
|
|
161
184
|
if (s.pressure >= 1) prior.steering++;
|
|
162
185
|
}
|
|
163
186
|
const rate = prior.scored ? prior.steering / prior.scored : 0;
|
|
164
|
-
const rec = { at: new Date().toISOString(), backend: backend(), lastPairs:
|
|
187
|
+
const rec = { at: new Date().toISOString(), backend: backend(), lastPairs: consumed, turns: all.length, scored: prior.scored, steering: prior.steering, steeringRate: rate, meanPressure: prior.scored ? prior.wsum / prior.scored : 0, distribution: prior.distribution, _wsum: prior.wsum };
|
|
165
188
|
try { mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, JSON.stringify(rec) + "\n"); } catch { process.stderr.write("reins: could not write " + out + "\n"); }
|
|
166
189
|
if (has("--json")) process.stdout.write(JSON.stringify(rec) + "\n");
|
|
167
190
|
else process.stdout.write(`reins: +${added} new turn(s), steer rate ${Math.round(rate * 100)}% over ${prior.scored} turns\n`);
|
|
@@ -169,7 +192,7 @@ if (has("--append")) {
|
|
|
169
192
|
}
|
|
170
193
|
|
|
171
194
|
const n = parseInt(flag("-n"), 10) || 0;
|
|
172
|
-
const r = await scan(text, score, { limit: n });
|
|
195
|
+
const r = await scan(text, score, { limit: n, pairsFn: H.pairs });
|
|
173
196
|
|
|
174
197
|
// --write [path]: persist the summary so an OFFLINE reader (the sawbuck statusline)
|
|
175
198
|
// can display the live steering number without ever calling a model itself.
|
|
@@ -201,10 +224,10 @@ if (has("--share")) {
|
|
|
201
224
|
${r.seismograph}
|
|
202
225
|
· let it ride - nudge + redirect # hard pull
|
|
203
226
|
|
|
204
|
-
I steered ${r.steering} of ${r.scored} turns (${pct}%)
|
|
227
|
+
I steered ${r.steering} of ${r.scored} turns (${pct}%): ${grip}, ${texture}.
|
|
205
228
|
The ${r.scored - r.steering} I let ride, the check had my back.
|
|
206
229
|
|
|
207
|
-
|
|
230
|
+
measured with reins · sawbuck
|
|
208
231
|
`);
|
|
209
232
|
process.exit(0);
|
|
210
233
|
}
|
package/codex.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// codex.mjs - the OpenAI Codex transcript adapter. The HARNESS sibling of scan.mjs: it reads
|
|
2
|
+
// Codex's rollout logs and emits the SAME { prior, user } turn-pairs, so the judge (judge.mjs),
|
|
3
|
+
// the cue aggregation (wizard.mjs), and the CLI work on Codex sessions unchanged. Exports the
|
|
4
|
+
// same interface as scan.mjs (pairs / resolveSessionPath / resolveSessions) so the CLI swaps
|
|
5
|
+
// adapters by --harness. Format verified against codex-cli 0.147.0 on disk + the openai/codex
|
|
6
|
+
// source (rollout envelope { timestamp, type, payload }, serde snake_case tags).
|
|
7
|
+
//
|
|
8
|
+
// sessions: $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<iso>-<thread>.jsonl (+ legacy flat layout)
|
|
9
|
+
// turns: prefer type=="event_msg" payload.type in {user_message, agent_message} (clean,
|
|
10
|
+
// already scaffolding-free); fall back to type=="response_item" message role
|
|
11
|
+
// user/assistant for older sessions, filtering the injected <environment_context> /
|
|
12
|
+
// AGENTS.md / permissions / collaboration_mode pseudo-user turns.
|
|
13
|
+
|
|
14
|
+
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
|
|
18
|
+
const codexHome = () => process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
19
|
+
|
|
20
|
+
// role:user response_items are polluted with injected scaffolding - not real user turns.
|
|
21
|
+
const SCAFFOLD = /^\s*<(environment_context|permissions|collaboration_mode|user_instructions|system|tools?)\b/i;
|
|
22
|
+
const isNoise = (t) => { const s = String(t).trim(); return s.length < 3 || SCAFFOLD.test(s); };
|
|
23
|
+
|
|
24
|
+
function textOf(content) {
|
|
25
|
+
if (typeof content === "string") return content;
|
|
26
|
+
if (Array.isArray(content)) return content.filter((b) => b && (b.type === "input_text" || b.type === "output_text" || b.type === "text") && b.text).map((b) => b.text).join(" ");
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Codex rollout jsonl -> ordered { role, text } turns, from whichever layer the file has.
|
|
31
|
+
function turnsFromRollout(text) {
|
|
32
|
+
const objs = [];
|
|
33
|
+
for (const line of String(text).split("\n")) { const l = line.trim(); if (!l) continue; try { objs.push(JSON.parse(l)); } catch {} }
|
|
34
|
+
// newer builds carry the clean event_msg layer; if present, use it EXCLUSIVELY (the response_item
|
|
35
|
+
// layer duplicates each turn and carries scaffolding, so mixing them double-counts).
|
|
36
|
+
const hasEvent = objs.some((o) => o && o.type === "event_msg" && o.payload && (o.payload.type === "user_message" || o.payload.type === "agent_message"));
|
|
37
|
+
const turns = [];
|
|
38
|
+
for (const o of objs) {
|
|
39
|
+
if (!o || !o.payload) continue;
|
|
40
|
+
if (hasEvent) {
|
|
41
|
+
if (o.type !== "event_msg") continue;
|
|
42
|
+
if (o.payload.type === "user_message") turns.push({ role: "user", text: o.payload.message });
|
|
43
|
+
else if (o.payload.type === "agent_message") turns.push({ role: "assistant", text: o.payload.message });
|
|
44
|
+
} else {
|
|
45
|
+
if (o.type !== "response_item" || o.payload.type !== "message") continue;
|
|
46
|
+
const r = o.payload.role;
|
|
47
|
+
if (r === "user" || r === "assistant") turns.push({ role: r, text: textOf(o.payload.content) });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return turns;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Same pairing contract as scan.mjs: every non-opener user turn that followed an agent turn.
|
|
54
|
+
export function pairs(text) {
|
|
55
|
+
const out = [];
|
|
56
|
+
let prev = "", seen = false;
|
|
57
|
+
for (const { role, text: t } of turnsFromRollout(text)) {
|
|
58
|
+
const s = String(t == null ? "" : t);
|
|
59
|
+
if (role === "assistant") { if (s.trim()) prev = s.slice(0, 600); continue; }
|
|
60
|
+
if (isNoise(s)) continue; // skip injected scaffolding / empties
|
|
61
|
+
if (!seen) { seen = true; continue; } // opener = a request, not a steer
|
|
62
|
+
if (prev) out.push({ prior: prev, user: s.replace(/\s+/g, " ").slice(0, 800) });
|
|
63
|
+
prev = "";
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// recursively list rollout-*.jsonl under $CODEX_HOME/sessions (dated tree + legacy flat)
|
|
69
|
+
function listRollouts() {
|
|
70
|
+
const root = join(codexHome(), "sessions");
|
|
71
|
+
if (!existsSync(root)) return [];
|
|
72
|
+
const found = [];
|
|
73
|
+
const walk = (dir, depth) => {
|
|
74
|
+
if (depth > 5) return;
|
|
75
|
+
let ents = []; try { ents = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
76
|
+
for (const e of ents) {
|
|
77
|
+
const p = join(dir, e.name);
|
|
78
|
+
if (e.isDirectory()) walk(p, depth + 1);
|
|
79
|
+
else if (e.isFile() && /^rollout-.*\.jsonl$/.test(e.name)) { try { found.push({ path: p, mtime: statSync(p).mtimeMs }); } catch {} }
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
walk(root, 0);
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function resolveSessionPath(explicit) {
|
|
87
|
+
if (explicit) return existsSync(explicit) ? explicit : null;
|
|
88
|
+
const f = listRollouts();
|
|
89
|
+
if (!f.length) return null;
|
|
90
|
+
f.sort((a, b) => b.mtime - a.mtime);
|
|
91
|
+
return f[0].path;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resolveSessions({ limit = 0, sinceMs = 0 } = {}) {
|
|
95
|
+
let f = listRollouts();
|
|
96
|
+
if (sinceMs) f = f.filter((x) => x.mtime >= sinceMs);
|
|
97
|
+
f.sort((a, b) => b.mtime - a.mtime);
|
|
98
|
+
return (limit > 0 ? f.slice(0, limit) : f).map((x) => x.path);
|
|
99
|
+
}
|
package/judge.mjs
CHANGED
|
@@ -95,15 +95,25 @@ async function complete(promptStr, schema) {
|
|
|
95
95
|
throw new Error("unknown SAWBUCK_JUDGE backend: " + b + " (use ollama:<model> | haiku | anthropic:<model> | openai:<model>)");
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
async function
|
|
99
|
-
const
|
|
98
|
+
async function ollamaCall(host, model, promptStr, format) {
|
|
99
|
+
const body = { model, prompt: promptStr, stream: false, options: { temperature: 0, num_predict: 800 } };
|
|
100
|
+
if (format) body.format = format;
|
|
100
101
|
const res = await fetch(host + "/api/generate", {
|
|
101
102
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
102
|
-
body: JSON.stringify(
|
|
103
|
+
body: JSON.stringify(body),
|
|
103
104
|
});
|
|
104
105
|
const j = await res.json();
|
|
105
106
|
return j.response || "";
|
|
106
107
|
}
|
|
108
|
+
async function ollama(model, promptStr, schema) {
|
|
109
|
+
const host = process.env.OLLAMA_HOST || "http://localhost:11434";
|
|
110
|
+
// Grammar-constrained first: clean JSON on most models (qwen2.5, llama). But reasoning models
|
|
111
|
+
// (gpt-oss, deepseek-r1) spend their budget in a hidden channel and return an EMPTY response
|
|
112
|
+
// under a format grammar - so on empty we retry free-form; the parser lifts JSON from raw text.
|
|
113
|
+
let out = await ollamaCall(host, model, promptStr, schema);
|
|
114
|
+
if (!out.trim()) out = await ollamaCall(host, model, promptStr, null);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
107
117
|
async function anthropic(model, promptStr) {
|
|
108
118
|
const key = process.env.ANTHROPIC_API_KEY;
|
|
109
119
|
if (!key) throw new Error("ANTHROPIC_API_KEY not set (SAWBUCK_JUDGE=haiku needs it)");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sawbuck/reins",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Reins: measure how much you steer your AI coding agent. A per-turn steering-pressure judge over your session transcript, pluggable backend (Haiku / local ollama / any OpenAI-compatible). The LLM half of the reins steering layer; keeps sawbuck's core offline.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"bin/reins.mjs",
|
|
15
15
|
"judge.mjs",
|
|
16
16
|
"scan.mjs",
|
|
17
|
+
"codex.mjs",
|
|
17
18
|
"wizard.mjs",
|
|
18
19
|
"README.md",
|
|
19
20
|
"LICENSE",
|
package/scan.mjs
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
// reading: score each of your turns (given the agent action it followed) and
|
|
3
3
|
// aggregate into a distribution + a seismograph. The scorer is injected so the
|
|
4
4
|
// aggregation is testable without a live model.
|
|
5
|
+
//
|
|
6
|
+
// HARNESS ADAPTER SEAM. This file is the CLAUDE CODE adapter: humanText/assistantText/
|
|
7
|
+
// noise parse Claude Code's .jsonl schema, and resolveSessions finds them under
|
|
8
|
+
// ~/.claude/projects. Everything downstream (the judge in judge.mjs, the cue aggregation
|
|
9
|
+
// in wizard.mjs, the CLI) operates ONLY on the abstract { prior, user } turn-pairs that
|
|
10
|
+
// pairs() emits and knows nothing about Claude Code. So supporting another harness (Codex,
|
|
11
|
+
// Cursor, ...) is a PARALLEL module that produces the same { prior, user } pairs + its own
|
|
12
|
+
// session resolver, not a refactor. pairs() is the seam; keep it harness-agnostic.
|
|
5
13
|
|
|
6
14
|
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
7
15
|
import { join } from "node:path";
|
|
@@ -53,8 +61,8 @@ export function pairs(text) {
|
|
|
53
61
|
const GLYPH = { 0: ".", 1: "-", 2: "+", 3: "#" };
|
|
54
62
|
|
|
55
63
|
// scan(text, scoreFn) -> aggregate. scoreFn(prior, user) -> {pressure, axis, nudge} | null.
|
|
56
|
-
export async function scan(text, scoreFn, { limit = 0 } = {}) {
|
|
57
|
-
let ps =
|
|
64
|
+
export async function scan(text, scoreFn, { limit = 0, pairsFn = pairs } = {}) {
|
|
65
|
+
let ps = pairsFn(text);
|
|
58
66
|
if (limit > 0 && ps.length > limit) ps = ps.slice(-limit); // most recent N
|
|
59
67
|
const dist = { 0: 0, 1: 0, 2: 0, 3: 0 };
|
|
60
68
|
let scored = 0, fails = 0, wsum = 0, spark = "";
|
package/wizard.mjs
CHANGED
|
@@ -15,12 +15,16 @@ import { pairs, resolveSessions } from "./scan.mjs";
|
|
|
15
15
|
// one-session spike never outranks a cue you give across many sessions - the study's lesson).
|
|
16
16
|
export function aggregateCues(items) {
|
|
17
17
|
const clusters = new Map();
|
|
18
|
+
const axisTally = new Map(); // the robust signal: WHICH axes you steer on (research: the fingerprint
|
|
19
|
+
// replicates across judges; exact cue text does not, so it rarely clusters)
|
|
18
20
|
let scored = 0, steers = 0, fails = 0;
|
|
19
21
|
for (const { session, result } of items) {
|
|
20
22
|
if (!result) { fails++; continue; }
|
|
21
23
|
scored++;
|
|
22
24
|
if (!result.steer) continue;
|
|
23
25
|
steers++;
|
|
26
|
+
const ax = result.axis || "other";
|
|
27
|
+
axisTally.set(ax, (axisTally.get(ax) || 0) + 1);
|
|
24
28
|
const key = (result.axis || "other") + "|" + String(result.cue || "").toLowerCase().replace(/\s+/g, " ").trim().slice(0, 40);
|
|
25
29
|
const c = clusters.get(key) || { axis: result.axis || "other", cue: result.cue || "", count: 0, gen: 0, sessions: new Set(), mag: { refine: 0, redirect: 0, "hard-stop": 0 } };
|
|
26
30
|
c.count++;
|
|
@@ -34,17 +38,18 @@ export function aggregateCues(items) {
|
|
|
34
38
|
const cues = [...clusters.values()]
|
|
35
39
|
.map((c) => ({ axis: c.axis, cue: c.cue, count: c.count, gen: c.gen, genShare: c.count ? +(c.gen / c.count).toFixed(2) : 0, sessions: c.sessions.size, magnitude: c.mag }))
|
|
36
40
|
.sort((a, b) => (b.sessions - a.sessions) || (b.gen - a.gen) || (b.count - a.count));
|
|
37
|
-
|
|
41
|
+
const axes = [...axisTally.entries()].map(([axis, count]) => ({ axis, count })).sort((a, b) => b.count - a.count);
|
|
42
|
+
return { scored, steers, fails, steerRate: scored ? +(steers / scored).toFixed(3) : 0, axes, cues };
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
// harvest(scoreFn, { sessions, maxPairs }) -> aggregateCues over the pairs in those session
|
|
41
46
|
// files, newest first, capped at maxPairs (cost control). scoreFn(prior, user) -> result | null.
|
|
42
|
-
export async function harvest(scoreFn, { sessions = [], maxPairs = 400 } = {}) {
|
|
47
|
+
export async function harvest(scoreFn, { sessions = [], maxPairs = 400, pairsFn = pairs } = {}) {
|
|
43
48
|
const items = [];
|
|
44
49
|
const gathered = [];
|
|
45
50
|
for (const path of sessions) {
|
|
46
51
|
let text = ""; try { text = readFileSync(path, "utf8"); } catch { continue; }
|
|
47
|
-
for (const p of
|
|
52
|
+
for (const p of pairsFn(text)) {
|
|
48
53
|
gathered.push({ ...p, session: path });
|
|
49
54
|
if (gathered.length >= maxPairs) break;
|
|
50
55
|
}
|