@sawbuck/reins 0.3.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, aggregateCues } 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")) {
@@ -88,22 +92,26 @@ Free on the local ollama default. Seed from your whole history once with: reins
88
92
  }
89
93
  if (flag("--judge")) process.env.SAWBUCK_JUDGE = flag("--judge");
90
94
  const b = backend();
91
- const sess = resolveSessionPath(flag("--session"));
95
+ const sess = H.resolveSessionPath(flag("--session"));
92
96
  if (!sess) { process.stderr.write("reins cues: no session transcript found\n"); process.exit(2); }
93
97
  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);
98
+ const allC = H.pairs(ctext);
95
99
  const cout = join(process.cwd(), ".sawbuck", "cues.json");
96
100
  let prior = { session: "", lastPairs: 0, items: [] };
97
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 */ } }
98
- const startIdx = has("--append") && prior.session === sess ? prior.lastPairs : 0; // same session = resume; new session = score all its pairs
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
99
103
  let fresh = allC.slice(startIdx);
100
- if (!has("--append")) { const m = parseInt(flag("--max"), 10) || 400; if (fresh.length > m) fresh = fresh.slice(-m); }
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;
101
109
  const items = has("--append") ? prior.items.slice() : [];
102
110
  const sid = sess.split("/").pop();
103
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 }); }
104
112
  const capped = items.slice(-1500);
105
113
  const agg = aggregateCues(capped);
106
- const rec = { at: new Date().toISOString(), backend: b, session: sess, lastPairs: allC.length, pairs: capped.length, ...agg, items: capped };
114
+ const rec = { at: new Date().toISOString(), backend: b, session: sess, lastPairs: has("--append") ? consumed : allC.length, pairs: capped.length, ...agg, items: capped };
107
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"); }
108
116
  if (has("--json")) { const { items: _omit, ...pub } = rec; process.stdout.write(JSON.stringify(pub, null, 2) + "\n"); process.exit(0); }
109
117
  const top = agg.cues.filter((c) => c.count >= 2 || c.gen > 0).slice(0, 10);
@@ -128,13 +136,16 @@ Usage: reins [--session <path.jsonl>] [--judge <backend>] [-n N] [--json] [--wri
128
136
  --label <term> the word for the stat on the share card (default "steer rate";
129
137
  e.g. "yank rate" for a playful channel - keep it neutral for LinkedIn)
130
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.
131
142
  Backend is SAWBUCK_JUDGE (default ollama:qwen2.5:7b). Local = free + private.
132
143
  `);
133
144
  process.exit(0);
134
145
  }
135
146
 
136
147
  if (flag("--judge")) process.env.SAWBUCK_JUDGE = flag("--judge");
137
- const sess = resolveSessionPath(flag("--session"));
148
+ const sess = H.resolveSessionPath(flag("--session"));
138
149
  if (!sess) { process.stderr.write("reins: no session transcript found (pass --session <path.jsonl>)\n"); process.exit(2); }
139
150
 
140
151
  let text = "";
@@ -151,9 +162,14 @@ if (has("--append")) {
151
162
  const p = JSON.parse(readFileSync(out, "utf8"));
152
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 };
153
164
  } catch { /* no prior tally = fresh */ }
154
- const all = pairs(text);
165
+ const all = H.pairs(text);
155
166
  let added = 0;
156
- 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)) {
157
173
  let s = null; try { s = await score(a, u); } catch {}
158
174
  if (!s) continue;
159
175
  prior.distribution[s.pressure] = (prior.distribution[s.pressure] || 0) + 1;
@@ -161,7 +177,7 @@ if (has("--append")) {
161
177
  if (s.pressure >= 1) prior.steering++;
162
178
  }
163
179
  const rate = prior.scored ? prior.steering / prior.scored : 0;
164
- 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 };
165
181
  try { mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, JSON.stringify(rec) + "\n"); } catch { process.stderr.write("reins: could not write " + out + "\n"); }
166
182
  if (has("--json")) process.stdout.write(JSON.stringify(rec) + "\n");
167
183
  else process.stdout.write(`reins: +${added} new turn(s), steer rate ${Math.round(rate * 100)}% over ${prior.scored} turns\n`);
@@ -169,7 +185,7 @@ if (has("--append")) {
169
185
  }
170
186
 
171
187
  const n = parseInt(flag("-n"), 10) || 0;
172
- const r = await scan(text, score, { limit: n });
188
+ const r = await scan(text, score, { limit: n, pairsFn: H.pairs });
173
189
 
174
190
  // --write [path]: persist the summary so an OFFLINE reader (the sawbuck statusline)
175
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.3.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
  }