@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 +7 -0
- package/bin/reins.mjs +71 -9
- package/codex.mjs +99 -0
- package/package.json +2 -1
- package/scan.mjs +10 -2
- package/wizard.mjs +2 -2
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
|
|
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";
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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 =
|
|
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
|
|
47
|
+
for (const p of pairsFn(text)) {
|
|
48
48
|
gathered.push({ ...p, session: path });
|
|
49
49
|
if (gathered.length >= maxPairs) break;
|
|
50
50
|
}
|