@sawbuck/reins 0.2.0 → 0.4.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 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
@@ -5,13 +5,17 @@
5
5
 
6
6
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
7
7
  import { join, dirname } from "node:path";
8
- import { scan, resolveSessionPath, pairs } from "../scan.mjs";
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, resolveSessions } from "../wizard.mjs";
12
+ import { harvest, aggregateCues } from "../wizard.mjs";
11
13
 
12
14
  const argv = process.argv.slice(2);
13
15
  const flag = (n) => { const i = argv.indexOf(n); return i > -1 ? argv[i + 1] : null; };
14
16
  const has = (n) => argv.includes(n);
17
+ // harness adapter: whose transcripts to read. --harness codex (or SAWBUCK_HARNESS=codex); default Claude Code.
18
+ const H = (flag("--harness") || process.env.SAWBUCK_HARNESS) === "codex" ? codexHarness : claudeHarness;
15
19
 
16
20
  // ---- `reins wizard` / `reins harvest`: read your old chats, rank the cues you keep giving ----
17
21
  if (argv[0] === "wizard" || argv[0] === "harvest") {
@@ -38,7 +42,7 @@ It reads every turn-pair with a model. On the local ollama default that is free;
38
42
  const maxPairs = parseInt(flag("--max"), 10) || 400;
39
43
  const sinceDays = parseInt(flag("--since"), 10) || 0;
40
44
  const sinceMs = sinceDays > 0 ? Date.now() - sinceDays * 86400000 : 0;
41
- const sessions = resolveSessions({ limit: sessionsN, sinceMs });
45
+ const sessions = H.resolveSessions({ limit: sessionsN, sinceMs });
42
46
  if (!sessions.length) { process.stderr.write("reins wizard: no Claude Code sessions found under ~/.claude/projects\n"); process.exit(2); }
43
47
  const hosted = !b.startsWith("ollama:");
44
48
  if (hosted && !has("--yes")) {
@@ -46,7 +50,7 @@ It reads every turn-pair with a model. On the local ollama default that is free;
46
50
  process.exit(2);
47
51
  }
48
52
  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 });
53
+ const rw = await harvest(harvestScore, { sessions, maxPairs, pairsFn: H.pairs });
50
54
  const wf = flag("--write");
51
55
  const out = wf && !wf.startsWith("--") ? wf : join(process.cwd(), ".sawbuck", "cues.json");
52
56
  if (has("--write") || !has("--json")) {
@@ -67,6 +71,56 @@ It reads every turn-pair with a model. On the local ollama default that is free;
67
71
  process.exit(0);
68
72
  }
69
73
 
74
+ // ---- `reins cues`: real-time cue inventory. Classify THIS session's turns into the cue
75
+ // structure and accumulate .sawbuck/cues.json. --append scores only the new turns since last
76
+ // run (the per-turn path), and carries cues across sessions in this repo. One judge call per
77
+ // new turn; free on local ollama. The batch cross-session cold-start is `reins wizard`. ----
78
+ if (argv[0] === "cues") {
79
+ if (has("--help") || has("-h")) {
80
+ process.stdout.write(`reins cues: accumulate your cue inventory as you work
81
+
82
+ Usage: reins cues [--append] [--session <path>] [--judge <b>] [--max N] [--json]
83
+
84
+ --append score only the new turns since the last run, merge into .sawbuck/cues.json
85
+ (the per-turn path; carries cues across sessions in this repo)
86
+ --max N without --append, cap the turns scored (default 400)
87
+ --judge <b> ollama:<model> (default, free, local) | haiku | anthropic:<model> | openai:<model>
88
+
89
+ Free on the local ollama default. Seed from your whole history once with: reins wizard.
90
+ `);
91
+ process.exit(0);
92
+ }
93
+ if (flag("--judge")) process.env.SAWBUCK_JUDGE = flag("--judge");
94
+ const b = backend();
95
+ const sess = H.resolveSessionPath(flag("--session"));
96
+ if (!sess) { process.stderr.write("reins cues: no session transcript found\n"); process.exit(2); }
97
+ let ctext = ""; try { ctext = readFileSync(sess, "utf8"); } catch { process.stderr.write("reins cues: cannot read " + sess + "\n"); process.exit(2); }
98
+ const allC = H.pairs(ctext);
99
+ const cout = join(process.cwd(), ".sawbuck", "cues.json");
100
+ let prior = { session: "", lastPairs: 0, items: [] };
101
+ 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 */ } }
102
+ const startIdx = has("--append") && prior.session === sess ? Math.min(prior.lastPairs, allC.length) : 0; // same session = resume; new/changed session = score from its start
103
+ let fresh = allC.slice(startIdx);
104
+ // bounded per call: --append scores at most PER new pairs (oldest-first, so a backlog drains over
105
+ // turns); a full scan caps at --max newest. Keeps the per-turn background call cheap.
106
+ const PER = has("--append") ? 40 : (parseInt(flag("--max"), 10) || 400);
107
+ if (fresh.length > PER) fresh = has("--append") ? fresh.slice(0, PER) : fresh.slice(-PER);
108
+ const consumed = startIdx + fresh.length;
109
+ const items = has("--append") ? prior.items.slice() : [];
110
+ const sid = sess.split("/").pop();
111
+ for (const { prior: a, user: u } of fresh) { let r = null; try { r = await harvestScore(a, u); } catch {} items.push({ session: sid, result: r }); }
112
+ const capped = items.slice(-1500);
113
+ const agg = aggregateCues(capped);
114
+ const rec = { at: new Date().toISOString(), backend: b, session: sess, lastPairs: has("--append") ? consumed : allC.length, pairs: capped.length, ...agg, items: capped };
115
+ 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"); }
116
+ if (has("--json")) { const { items: _omit, ...pub } = rec; process.stdout.write(JSON.stringify(pub, null, 2) + "\n"); process.exit(0); }
117
+ const top = agg.cues.filter((c) => c.count >= 2 || c.gen > 0).slice(0, 10);
118
+ process.stdout.write(`\n reins · cues (judge: ${b})\n ${agg.steers} steers over ${agg.scored} scored turns · ${top.length} cues\n`);
119
+ for (const c of top) process.stdout.write(` ${("[" + c.axis + "]").padEnd(12)} ${(c.cue || "(unnamed)").padEnd(32)} · ${c.sessions} sess · ${c.count}x\n`);
120
+ process.stdout.write(` wrote ${cout}\n\n`);
121
+ process.exit(0);
122
+ }
123
+
70
124
  if (has("--help") || has("-h")) {
71
125
  process.stdout.write(`reins: measure how much you steer your agent this session
72
126
 
@@ -82,13 +136,16 @@ Usage: reins [--session <path.jsonl>] [--judge <backend>] [-n N] [--json] [--wri
82
136
  --label <term> the word for the stat on the share card (default "steer rate";
83
137
  e.g. "yank rate" for a playful channel - keep it neutral for LinkedIn)
84
138
 
139
+ --harness <h> whose transcripts to read: claude (default) | codex (reads ~/.codex/sessions)
140
+
141
+ Commands: 'reins wizard' (harvest history into cues), 'reins cues' (the live cue inventory). --help on each.
85
142
  Backend is SAWBUCK_JUDGE (default ollama:qwen2.5:7b). Local = free + private.
86
143
  `);
87
144
  process.exit(0);
88
145
  }
89
146
 
90
147
  if (flag("--judge")) process.env.SAWBUCK_JUDGE = flag("--judge");
91
- const sess = resolveSessionPath(flag("--session"));
148
+ const sess = H.resolveSessionPath(flag("--session"));
92
149
  if (!sess) { process.stderr.write("reins: no session transcript found (pass --session <path.jsonl>)\n"); process.exit(2); }
93
150
 
94
151
  let text = "";
@@ -105,9 +162,14 @@ if (has("--append")) {
105
162
  const p = JSON.parse(readFileSync(out, "utf8"));
106
163
  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 };
107
164
  } catch { /* no prior tally = fresh */ }
108
- const all = pairs(text);
165
+ const all = H.pairs(text);
109
166
  let added = 0;
110
- for (const { prior: a, user: u } of all.slice(prior.lastPairs)) {
167
+ // bounded per call (graceful): at most PER new pairs this run, so the first --append on a long
168
+ // session never blocks on hundreds of judge calls at once; a backlog drains over the next turns.
169
+ const PER = 40;
170
+ const start = Math.min(prior.lastPairs, all.length);
171
+ const consumed = Math.min(start + PER, all.length);
172
+ for (const { prior: a, user: u } of all.slice(start, consumed)) {
111
173
  let s = null; try { s = await score(a, u); } catch {}
112
174
  if (!s) continue;
113
175
  prior.distribution[s.pressure] = (prior.distribution[s.pressure] || 0) + 1;
@@ -115,7 +177,7 @@ if (has("--append")) {
115
177
  if (s.pressure >= 1) prior.steering++;
116
178
  }
117
179
  const rate = prior.scored ? prior.steering / prior.scored : 0;
118
- const rec = { at: new Date().toISOString(), backend: backend(), lastPairs: all.length, 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 };
180
+ 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 };
119
181
  try { mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, JSON.stringify(rec) + "\n"); } catch { process.stderr.write("reins: could not write " + out + "\n"); }
120
182
  if (has("--json")) process.stdout.write(JSON.stringify(rec) + "\n");
121
183
  else process.stdout.write(`reins: +${added} new turn(s), steer rate ${Math.round(rate * 100)}% over ${prior.scored} turns\n`);
@@ -123,7 +185,7 @@ if (has("--append")) {
123
185
  }
124
186
 
125
187
  const n = parseInt(flag("-n"), 10) || 0;
126
- const r = await scan(text, score, { limit: n });
188
+ const r = await scan(text, score, { limit: n, pairsFn: H.pairs });
127
189
 
128
190
  // --write [path]: persist the summary so an OFFLINE reader (the sawbuck statusline)
129
191
  // can display the live steering number without ever calling a model itself.
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sawbuck/reins",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
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 = pairs(text);
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
@@ -39,12 +39,12 @@ export function aggregateCues(items) {
39
39
 
40
40
  // harvest(scoreFn, { sessions, maxPairs }) -> aggregateCues over the pairs in those session
41
41
  // files, newest first, capped at maxPairs (cost control). scoreFn(prior, user) -> result | null.
42
- export async function harvest(scoreFn, { sessions = [], maxPairs = 400 } = {}) {
42
+ export async function harvest(scoreFn, { sessions = [], maxPairs = 400, pairsFn = pairs } = {}) {
43
43
  const items = [];
44
44
  const gathered = [];
45
45
  for (const path of sessions) {
46
46
  let text = ""; try { text = readFileSync(path, "utf8"); } catch { continue; }
47
- for (const p of pairs(text)) {
47
+ for (const p of pairsFn(text)) {
48
48
  gathered.push({ ...p, session: path });
49
49
  if (gathered.length >= maxPairs) break;
50
50
  }