@sawbuck/reins 0.0.0-stage → 0.1.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 +65 -2
- package/bin/reins.mjs +127 -0
- package/judge.mjs +104 -0
- package/package.json +17 -4
- package/scan.mjs +103 -0
package/README.md
CHANGED
|
@@ -1,3 +1,66 @@
|
|
|
1
|
-
#
|
|
1
|
+
# reins (engine)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The engine behind reins: measure how much you steer your AI coding agent. It reads
|
|
4
|
+
a Claude Code session transcript, scores each of your turns for **steering
|
|
5
|
+
pressure** (0 passive → 1 light refine → 2 redirect → 3 hard yank) with a small LLM,
|
|
6
|
+
and prints a seismograph of the session plus the distribution.
|
|
7
|
+
|
|
8
|
+
This is the LLM half of reins. The deterministic brake-slam counter (`sawbuck yank`)
|
|
9
|
+
lives in sawbuck's offline core; the continuous pressure judge needs a model, so it
|
|
10
|
+
lives here, keeping sawbuck's core model-free. (The reins *plugin* — the Claude Code
|
|
11
|
+
surface — lives alongside this in `plugins/sawbuck-reins/`.)
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
$ reins
|
|
15
|
+
reins · steering this session (judge: ollama:qwen2.5:7b)
|
|
16
|
+
..--+-+---+---.--.--..+--+.--
|
|
17
|
+
. passive - refine + redirect # yank
|
|
18
|
+
|
|
19
|
+
you steered 22 of 29 turns (76%) · mean pressure 0.93/3
|
|
20
|
+
17 light refine · 5 redirect · 0 hard yank · 7 passive
|
|
21
|
+
the check guards the 7 turns you didn't steer - a miss there is the risk.
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Backend (SAWBUCK_JUDGE)
|
|
25
|
+
|
|
26
|
+
The judge model is a socket. Set `SAWBUCK_JUDGE` (default `ollama:qwen2.5:7b`):
|
|
27
|
+
|
|
28
|
+
- `ollama:<model>` — local, free, private (nothing leaves your box). `OLLAMA_HOST`
|
|
29
|
+
overrides the endpoint. Benchmarked default `qwen2.5:7b`: fastest, smallest, and
|
|
30
|
+
tied-best accuracy against an opus-labeled gold — model size did not help.
|
|
31
|
+
- `haiku` — Anthropic `claude-haiku-5-5` (needs `ANTHROPIC_API_KEY`); zero VRAM,
|
|
32
|
+
about $0.00005 per turn.
|
|
33
|
+
- `anthropic:<model>` — any Anthropic model.
|
|
34
|
+
- `openai:<model>` — any OpenAI-compatible endpoint (`OPENAI_BASE_URL`,
|
|
35
|
+
`OPENAI_API_KEY`).
|
|
36
|
+
|
|
37
|
+
Output is constrained to a JSON schema where the backend supports it (ollama
|
|
38
|
+
structured outputs), so a thinking model can't break the parse.
|
|
39
|
+
|
|
40
|
+
## Live on the statusline
|
|
41
|
+
|
|
42
|
+
`--write [path]` writes the summary JSON (default `.sawbuck/steer.json`). The
|
|
43
|
+
offline `sawbuck statusline` reads that file and shows `🤠 steer 76%` next to its
|
|
44
|
+
own segment — the LLM work happens here, sawbuck's core stays model-free (it only
|
|
45
|
+
reads a file). Keep it fresh by running `reins --write` on a cadence (a `/loop`, a
|
|
46
|
+
watch, or a post-turn hook):
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
reins --write # writes .sawbuck/steer.json; the statusline picks it up
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
reins [--session <path.jsonl>] [--judge <backend>] [-n N] [--json] [--write [path]]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Defaults to your newest Claude Code session. `-n` scores only the most recent N
|
|
59
|
+
turns. `--json` is machine-readable. Zero-dep, Node 18+.
|
|
60
|
+
|
|
61
|
+
## Honest limits
|
|
62
|
+
|
|
63
|
+
A single-turn judge: it sees the agent action and your reply, not the whole thread.
|
|
64
|
+
Models agree on the *shape* (pressure within one level ~95% of the time) more than
|
|
65
|
+
on exact per-turn scores, so read the seismograph and the distribution, not any one
|
|
66
|
+
cell.
|
package/bin/reins.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// reins: how much did you steer your agent this session? Reads your Claude Code
|
|
3
|
+
// transcript, scores each turn's steering pressure via SAWBUCK_JUDGE, and prints
|
|
4
|
+
// the seismograph + distribution. The check guards the turns you did not steer.
|
|
5
|
+
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
|
+
import { scan, resolveSessionPath, pairs } from "../scan.mjs";
|
|
9
|
+
import { score, backend } from "../judge.mjs";
|
|
10
|
+
|
|
11
|
+
const argv = process.argv.slice(2);
|
|
12
|
+
const flag = (n) => { const i = argv.indexOf(n); return i > -1 ? argv[i + 1] : null; };
|
|
13
|
+
const has = (n) => argv.includes(n);
|
|
14
|
+
|
|
15
|
+
if (has("--help") || has("-h")) {
|
|
16
|
+
process.stdout.write(`reins: measure how much you steer your agent this session
|
|
17
|
+
|
|
18
|
+
Usage: reins [--session <path.jsonl>] [--judge <backend>] [-n N] [--json] [--write [path]] [--share [--label <term>]]
|
|
19
|
+
|
|
20
|
+
--session <path> a Claude Code transcript (default: your newest session)
|
|
21
|
+
--judge <backend> override SAWBUCK_JUDGE: ollama:<model> | haiku | anthropic:<model> | openai:<model>
|
|
22
|
+
-n <N> score only the most recent N turns
|
|
23
|
+
--json machine-readable output
|
|
24
|
+
--write [path] also write the summary JSON (default .sawbuck/steer.json) for the
|
|
25
|
+
offline sawbuck statusline to read - run it on a cadence for a live readout
|
|
26
|
+
--share print a paste-ready card for posting (LinkedIn / X / Reddit)
|
|
27
|
+
--label <term> the word for the stat on the share card (default "steer rate";
|
|
28
|
+
e.g. "yank rate" for a playful channel - keep it neutral for LinkedIn)
|
|
29
|
+
|
|
30
|
+
Backend is SAWBUCK_JUDGE (default ollama:qwen2.5:7b). Local = free + private.
|
|
31
|
+
`);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (flag("--judge")) process.env.SAWBUCK_JUDGE = flag("--judge");
|
|
36
|
+
const sess = resolveSessionPath(flag("--session"));
|
|
37
|
+
if (!sess) { process.stderr.write("reins: no session transcript found (pass --session <path.jsonl>)\n"); process.exit(2); }
|
|
38
|
+
|
|
39
|
+
let text = "";
|
|
40
|
+
try { text = readFileSync(sess, "utf8"); } catch { process.stderr.write("reins: cannot read " + sess + "\n"); process.exit(2); }
|
|
41
|
+
|
|
42
|
+
// --append: incremental, BOUNDED per-turn scoring for the live watcher. Reads the
|
|
43
|
+
// prior tally from .sawbuck/steer.json, scores only the NEW turn-pairs since the last
|
|
44
|
+
// run, merges, rewrites. One new pair per turn - not a full re-scan (which would be
|
|
45
|
+
// quadratic over a session). The sawbuck statusline reads the same steer.json offline.
|
|
46
|
+
if (has("--append")) {
|
|
47
|
+
const out = join(process.cwd(), ".sawbuck", "steer.json");
|
|
48
|
+
let prior = { lastPairs: 0, scored: 0, steering: 0, wsum: 0, distribution: { 0: 0, 1: 0, 2: 0, 3: 0 } };
|
|
49
|
+
try {
|
|
50
|
+
const p = JSON.parse(readFileSync(out, "utf8"));
|
|
51
|
+
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 };
|
|
52
|
+
} catch { /* no prior tally = fresh */ }
|
|
53
|
+
const all = pairs(text);
|
|
54
|
+
let added = 0;
|
|
55
|
+
for (const { prior: a, user: u } of all.slice(prior.lastPairs)) {
|
|
56
|
+
let s = null; try { s = await score(a, u); } catch {}
|
|
57
|
+
if (!s) continue;
|
|
58
|
+
prior.distribution[s.pressure] = (prior.distribution[s.pressure] || 0) + 1;
|
|
59
|
+
prior.scored++; prior.wsum += s.pressure; added++;
|
|
60
|
+
if (s.pressure >= 1) prior.steering++;
|
|
61
|
+
}
|
|
62
|
+
const rate = prior.scored ? prior.steering / prior.scored : 0;
|
|
63
|
+
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 };
|
|
64
|
+
try { mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, JSON.stringify(rec) + "\n"); } catch { process.stderr.write("reins: could not write " + out + "\n"); }
|
|
65
|
+
if (has("--json")) process.stdout.write(JSON.stringify(rec) + "\n");
|
|
66
|
+
else process.stdout.write(`reins: +${added} new turn(s), steer rate ${Math.round(rate * 100)}% over ${prior.scored} turns\n`);
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const n = parseInt(flag("-n"), 10) || 0;
|
|
71
|
+
const r = await scan(text, score, { limit: n });
|
|
72
|
+
|
|
73
|
+
// --write [path]: persist the summary so an OFFLINE reader (the sawbuck statusline)
|
|
74
|
+
// can display the live steering number without ever calling a model itself.
|
|
75
|
+
if (has("--write")) {
|
|
76
|
+
const wf = flag("--write");
|
|
77
|
+
const out = wf && !wf.startsWith("--") ? wf : join(process.cwd(), ".sawbuck", "steer.json");
|
|
78
|
+
try {
|
|
79
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
80
|
+
writeFileSync(out, JSON.stringify({ at: new Date().toISOString(), backend: backend(), turns: r.turns, scored: r.scored, steering: r.steering, steeringRate: r.steeringRate, meanPressure: r.meanPressure, distribution: r.distribution }) + "\n");
|
|
81
|
+
} catch { process.stderr.write("reins: could not write " + out + "\n"); }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (has("--json")) { process.stdout.write(JSON.stringify({ backend: backend(), ...r }) + "\n"); process.exit(0); }
|
|
85
|
+
|
|
86
|
+
// --share: a tasteful, paste-anywhere card (plain text works on LinkedIn / X / Reddit).
|
|
87
|
+
// --label swaps the stat's word; default stays neutral ("steer rate") so it's safe for a
|
|
88
|
+
// professional feed, e.g. --label "yank rate" for a playful channel.
|
|
89
|
+
if (has("--share")) {
|
|
90
|
+
if (r.scored === 0) { process.stdout.write("reins: nothing to share yet (no turns scored).\n"); process.exit(0); }
|
|
91
|
+
const lf = flag("--label");
|
|
92
|
+
const label = lf && !lf.startsWith("--") ? lf : "steer rate";
|
|
93
|
+
const pct = Math.round(r.steeringRate * 100);
|
|
94
|
+
const d = r.distribution;
|
|
95
|
+
const texture = d[1] >= d[2] + d[3] ? "mostly light refinements" : d[3] > 0 ? "a few hard pulls" : "real redirects";
|
|
96
|
+
const grip = pct >= 60 ? "hands-on" : pct >= 30 ? "a steady hand" : "a loose rein";
|
|
97
|
+
process.stdout.write(
|
|
98
|
+
`my ${label} this session 🐴
|
|
99
|
+
|
|
100
|
+
${r.seismograph}
|
|
101
|
+
· let it ride - nudge + redirect # hard pull
|
|
102
|
+
|
|
103
|
+
I steered ${r.steering} of ${r.scored} turns (${pct}%) — ${grip}, ${texture}.
|
|
104
|
+
The ${r.scored - r.steering} I let ride, the check had my back.
|
|
105
|
+
|
|
106
|
+
— measured with reins · sawbuck
|
|
107
|
+
`);
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const pct = Math.round(r.steeringRate * 100);
|
|
112
|
+
const d = r.distribution;
|
|
113
|
+
process.stdout.write(`\n reins · steering this session (judge: ${backend()})\n`);
|
|
114
|
+
if (r.scored === 0) {
|
|
115
|
+
process.stdout.write(` no turns scored${r.fails ? ` (${r.fails} judge errors - is the backend reachable?)` : ""}.\n\n`);
|
|
116
|
+
process.exit(r.fails ? 2 : 0);
|
|
117
|
+
}
|
|
118
|
+
process.stdout.write(` ${r.seismograph}\n`);
|
|
119
|
+
process.stdout.write(` . passive - refine + redirect # yank\n\n`);
|
|
120
|
+
process.stdout.write(` you steered ${r.steering} of ${r.scored} turns (${pct}%) · mean pressure ${r.meanPressure.toFixed(2)}/3\n`);
|
|
121
|
+
process.stdout.write(` ${d[1]} light refine · ${d[2]} redirect · ${d[3]} hard yank · ${d[0]} passive${r.fails ? ` · ${r.fails} skipped` : ""}\n`);
|
|
122
|
+
process.stdout.write(` the check guards the ${r.scored - r.steering} turns you didn't steer - a miss there is the risk.\n`);
|
|
123
|
+
if (r.recent.length) {
|
|
124
|
+
process.stdout.write(`\n recent steers:\n`);
|
|
125
|
+
for (const s of r.recent) process.stdout.write(` ${["", "-", "+", "#"][s.pressure]}${s.pressure} ${(s.axis || "").padEnd(12)} ${s.nudge}\n`);
|
|
126
|
+
}
|
|
127
|
+
process.stdout.write("\n");
|
package/judge.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// judge.mjs - the steering-PRESSURE judge's backend socket.
|
|
2
|
+
//
|
|
3
|
+
// score(prior, user) -> {pressure 0-3, axis, nudge}, via the backend SAWBUCK_JUDGE
|
|
4
|
+
// selects. This file does network/LLM calls ON PURPOSE - it is a STANDALONE tool,
|
|
5
|
+
// never imported into sawbuck's offline core (which is stabilizer-enforced
|
|
6
|
+
// model-free). The deterministic yank meter lives in sawbuck; this LLM judge lives
|
|
7
|
+
// here, the way steering lives in the harness layer, not the git core.
|
|
8
|
+
//
|
|
9
|
+
// Backends (SAWBUCK_JUDGE):
|
|
10
|
+
// ollama:<model> local, free, private - the default (ollama:qwen2.5:7b),
|
|
11
|
+
// "put your GPU to work"; OLLAMA_HOST overrides the endpoint.
|
|
12
|
+
// haiku Anthropic claude-haiku-5-5 (needs ANTHROPIC_API_KEY); cheap
|
|
13
|
+
// hosted, zero VRAM (~$0.00005/turn).
|
|
14
|
+
// anthropic:<model> any Anthropic model.
|
|
15
|
+
// openai:<model> any OpenAI-compatible endpoint (OPENAI_BASE_URL + OPENAI_API_KEY).
|
|
16
|
+
//
|
|
17
|
+
// Benchmarked default = qwen2.5:7b: tied-best accuracy vs an opus gold, fastest
|
|
18
|
+
// (0.2s/turn), smallest, 0 parse-fails; model size did not help (see the bench).
|
|
19
|
+
|
|
20
|
+
// JSON Schema that constrains the model's output (ollama structured outputs). This
|
|
21
|
+
// is the robust fix for valid JSON - a grammar, not a prayer - so no backend
|
|
22
|
+
// parse-fails the way free-form prompting did (qwen3's <think> truncation, etc.).
|
|
23
|
+
const PRESSURE_SCHEMA = {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
pressure: { type: "integer", enum: [0, 1, 2, 3] },
|
|
27
|
+
axis: { type: "string" },
|
|
28
|
+
nudge: { type: "string" },
|
|
29
|
+
},
|
|
30
|
+
required: ["pressure", "axis", "nudge"],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const PROMPT = (a, u) =>
|
|
34
|
+
`You rate how much a developer's message STEERS an AI coding agent's current work.
|
|
35
|
+
Output ONLY one JSON object: {"pressure":0-3,"axis":"one-word","nudge":"<=8 words"}
|
|
36
|
+
pressure: 0 passive (approval like "yes/go/do it", a pure question, or a brand-new unrelated task; NOT steering current work). 1 light refine (small adjustment to the SAME work: tone, length, scope, emphasis, "a bit more X", "also Y"). 2 redirect (change the approach/direction of current work). 3 hard-stop ("no/stop/undo/revert", reject and restart).
|
|
37
|
+
AGENT just did: ${a}
|
|
38
|
+
USER replied: ${u}
|
|
39
|
+
JSON:`;
|
|
40
|
+
|
|
41
|
+
// lenient parse: strip reasoning-model <think> blocks, take the last JSON object.
|
|
42
|
+
// (With a constrained-output backend the response is already clean; this is a net.)
|
|
43
|
+
export function parse(txt) {
|
|
44
|
+
txt = String(txt).replace(/<think>[\s\S]*?<\/think>/g, "");
|
|
45
|
+
const cands = txt.match(/\{[^{}]*\}/g) || [];
|
|
46
|
+
for (const c of cands.reverse()) {
|
|
47
|
+
try {
|
|
48
|
+
const j = JSON.parse(c);
|
|
49
|
+
if (j && j.pressure != null) {
|
|
50
|
+
return {
|
|
51
|
+
pressure: Math.max(0, Math.min(3, parseInt(j.pressure, 10) || 0)),
|
|
52
|
+
axis: String(j.axis || "").slice(0, 16),
|
|
53
|
+
nudge: String(j.nudge || "").slice(0, 48),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
} catch { /* keep scanning */ }
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function ollama(model, a, u) {
|
|
62
|
+
const host = process.env.OLLAMA_HOST || "http://localhost:11434";
|
|
63
|
+
const res = await fetch(host + "/api/generate", {
|
|
64
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
65
|
+
body: JSON.stringify({ model, prompt: PROMPT(a, u), stream: false, format: PRESSURE_SCHEMA, options: { temperature: 0, num_predict: 800 } }),
|
|
66
|
+
});
|
|
67
|
+
const j = await res.json();
|
|
68
|
+
return parse(j.response || "");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function anthropic(model, a, u) {
|
|
72
|
+
const key = process.env.ANTHROPIC_API_KEY;
|
|
73
|
+
if (!key) throw new Error("ANTHROPIC_API_KEY not set (SAWBUCK_JUDGE=haiku needs it)");
|
|
74
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
75
|
+
method: "POST", headers: { "Content-Type": "application/json", "x-api-key": key, "anthropic-version": "2023-06-01" },
|
|
76
|
+
body: JSON.stringify({ model, max_tokens: 200, messages: [{ role: "user", content: PROMPT(a, u) }] }),
|
|
77
|
+
});
|
|
78
|
+
const j = await res.json();
|
|
79
|
+
const t = (j.content || []).filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
80
|
+
return parse(t);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function openai(model, a, u) {
|
|
84
|
+
const base = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";
|
|
85
|
+
const key = process.env.OPENAI_API_KEY;
|
|
86
|
+
if (!key) throw new Error("OPENAI_API_KEY not set");
|
|
87
|
+
const res = await fetch(base.replace(/\/$/, "") + "/chat/completions", {
|
|
88
|
+
method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer " + key },
|
|
89
|
+
body: JSON.stringify({ model, temperature: 0, max_tokens: 200, response_format: { type: "json_object" }, messages: [{ role: "user", content: PROMPT(a, u) }] }),
|
|
90
|
+
});
|
|
91
|
+
const j = await res.json();
|
|
92
|
+
return parse(j.choices?.[0]?.message?.content || "");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function backend() { return process.env.SAWBUCK_JUDGE || "ollama:qwen2.5:7b"; }
|
|
96
|
+
|
|
97
|
+
export async function score(prior, user) {
|
|
98
|
+
const b = backend();
|
|
99
|
+
if (b === "haiku") return anthropic("claude-haiku-5-5", prior, user);
|
|
100
|
+
if (b.startsWith("anthropic:")) return anthropic(b.slice(10), prior, user);
|
|
101
|
+
if (b.startsWith("ollama:")) return ollama(b.slice(7), prior, user);
|
|
102
|
+
if (b.startsWith("openai:")) return openai(b.slice(7), prior, user);
|
|
103
|
+
throw new Error("unknown SAWBUCK_JUDGE backend: " + b + " (use ollama:<model> | haiku | anthropic:<model> | openai:<model>)");
|
|
104
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sawbuck/reins",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"reins": "bin/reins.mjs"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin/reins.mjs",
|
|
15
|
+
"judge.mjs",
|
|
16
|
+
"scan.mjs",
|
|
17
|
+
"README.md"
|
|
18
|
+
]
|
|
19
|
+
}
|
package/scan.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// scan.mjs - turn a Claude Code session transcript into a steering-pressure
|
|
2
|
+
// reading: score each of your turns (given the agent action it followed) and
|
|
3
|
+
// aggregate into a distribution + a seismograph. The scorer is injected so the
|
|
4
|
+
// aggregation is testable without a live model.
|
|
5
|
+
|
|
6
|
+
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
|
|
10
|
+
function humanText(o) {
|
|
11
|
+
if (!o || o.type !== "user" || o.isMeta) return null;
|
|
12
|
+
if (o.userType && o.userType !== "external") return null;
|
|
13
|
+
const c = o.message && o.message.content;
|
|
14
|
+
if (typeof c === "string") return c;
|
|
15
|
+
if (Array.isArray(c)) {
|
|
16
|
+
if (c.some((b) => b && b.type === "tool_result")) return null;
|
|
17
|
+
const t = c.filter((b) => b && b.type === "text").map((b) => b.text || "").join(" ");
|
|
18
|
+
return t || null;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
function assistantText(o) {
|
|
23
|
+
const c = o.message && o.message.content;
|
|
24
|
+
if (Array.isArray(c)) return c.filter((b) => b && b.type === "text").map((b) => b.text || "").join(" ").trim();
|
|
25
|
+
if (typeof c === "string") return c.trim();
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
const noise = (t) => {
|
|
29
|
+
const s = t.trim();
|
|
30
|
+
if (s.includes("<task-notification>") || s.startsWith("<task-id>")) return true;
|
|
31
|
+
if (s.replace(/\[Image #\d+\]/g, "").trim().length < 3) return true;
|
|
32
|
+
return false;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Extract the (prior-agent, user-turn) pairs to score: every non-opener human turn
|
|
36
|
+
// that followed an agent action.
|
|
37
|
+
export function pairs(text) {
|
|
38
|
+
const out = [];
|
|
39
|
+
let prev = "", seen = false;
|
|
40
|
+
for (const line of String(text).split("\n")) {
|
|
41
|
+
if (!line.trim()) continue;
|
|
42
|
+
let o; try { o = JSON.parse(line); } catch { continue; }
|
|
43
|
+
if (o.type === "assistant") { prev = assistantText(o); continue; }
|
|
44
|
+
const t = humanText(o);
|
|
45
|
+
if (t === null || noise(t)) continue;
|
|
46
|
+
if (!seen) { seen = true; continue; } // opener = a request, not a steer
|
|
47
|
+
if (prev) out.push({ prior: prev.slice(0, 600), user: t.replace(/\s+/g, " ").slice(0, 800) });
|
|
48
|
+
prev = "";
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const GLYPH = { 0: ".", 1: "-", 2: "+", 3: "#" };
|
|
54
|
+
|
|
55
|
+
// 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);
|
|
58
|
+
if (limit > 0 && ps.length > limit) ps = ps.slice(-limit); // most recent N
|
|
59
|
+
const dist = { 0: 0, 1: 0, 2: 0, 3: 0 };
|
|
60
|
+
let scored = 0, fails = 0, wsum = 0, spark = "";
|
|
61
|
+
const recent = [];
|
|
62
|
+
for (const { prior, user } of ps) {
|
|
63
|
+
let r = null;
|
|
64
|
+
try { r = await scoreFn(prior, user); } catch { r = null; }
|
|
65
|
+
if (!r) { fails++; spark += "?"; continue; }
|
|
66
|
+
dist[r.pressure]++; scored++; wsum += r.pressure; spark += GLYPH[r.pressure];
|
|
67
|
+
if (r.pressure >= 1) recent.push({ pressure: r.pressure, axis: r.axis, nudge: r.nudge, user: user.slice(0, 60) });
|
|
68
|
+
}
|
|
69
|
+
const turns = ps.length;
|
|
70
|
+
const steering = dist[1] + dist[2] + dist[3];
|
|
71
|
+
return {
|
|
72
|
+
turns, scored, fails,
|
|
73
|
+
distribution: dist,
|
|
74
|
+
steering, steeringRate: scored ? steering / scored : 0,
|
|
75
|
+
meanPressure: scored ? wsum / scored : 0,
|
|
76
|
+
seismograph: spark,
|
|
77
|
+
recent: recent.slice(-6),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Resolve the newest session transcript for a cwd (Claude Code: ~/.claude/projects/
|
|
82
|
+
// <encoded-cwd>/<id>.jsonl). Best-effort; prefer the cwd's dir, else newest overall.
|
|
83
|
+
export function resolveSessionPath(explicit, cwd = process.cwd()) {
|
|
84
|
+
if (explicit) return existsSync(explicit) ? explicit : null;
|
|
85
|
+
const root = join(homedir(), ".claude", "projects");
|
|
86
|
+
if (!existsSync(root)) return null;
|
|
87
|
+
const key = cwd.replace(/[^A-Za-z0-9]/g, "-");
|
|
88
|
+
let dirs = [];
|
|
89
|
+
try { dirs = readdirSync(root).map((d) => join(root, d)).filter((p) => { try { return statSync(p).isDirectory(); } catch { return false; } }); }
|
|
90
|
+
catch { return null; }
|
|
91
|
+
const matched = dirs.filter((p) => p.endsWith("/" + key));
|
|
92
|
+
const pool = matched.length ? matched : dirs;
|
|
93
|
+
let best = null, bestT = 0;
|
|
94
|
+
for (const full of pool) {
|
|
95
|
+
let files = []; try { files = readdirSync(full); } catch { continue; }
|
|
96
|
+
for (const f of files) {
|
|
97
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
98
|
+
const p = join(full, f);
|
|
99
|
+
try { const st = statSync(p); if (st.mtimeMs > bestT) { bestT = st.mtimeMs; best = p; } } catch {}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return best;
|
|
103
|
+
}
|