@basein/runner 0.2.5 → 0.2.7
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 -6
- package/dist/bin/bir-hooks.d.ts +6 -5
- package/dist/bin/bir-hooks.js +12 -6
- package/dist/bin/bir.js +44 -5
- package/dist/bin/investigate.d.ts +195 -0
- package/dist/bin/investigate.js +576 -0
- package/dist/control/server.js +8 -0
- package/dist/record/recorder.d.ts +6 -0
- package/dist/record/remote-recorder.js +2 -0
- package/dist/replay/controller.d.ts +3 -1
- package/dist/replay/controller.js +3 -1
- package/dist/util/journal.d.ts +38 -0
- package/dist/util/journal.js +97 -0
- package/dist/util/log.js +4 -0
- package/docs/calculatedReplay.md +3 -3
- package/docs/calculatedReplayGuide.md +37 -5
- package/docs/my-first-sample.md +2 -2
- package/package.json +1 -1
|
@@ -146,6 +146,7 @@ export class ReplayController {
|
|
|
146
146
|
state.declined = code;
|
|
147
147
|
logLine("replay.decision", {
|
|
148
148
|
verdict: "no-steer",
|
|
149
|
+
code,
|
|
149
150
|
run: match.runId,
|
|
150
151
|
scenario: match.scenarioId ?? undefined,
|
|
151
152
|
similarity: match.similarity.toFixed(3),
|
|
@@ -165,7 +166,7 @@ export class ReplayController {
|
|
|
165
166
|
}
|
|
166
167
|
// Gate 2 — replay enabled.
|
|
167
168
|
if (!this.enabled)
|
|
168
|
-
return decline("BIR_REPLAY
|
|
169
|
+
return decline("BIR_REPLAY=0", "replay_disabled");
|
|
169
170
|
// Gate 3 — a ready scenario with steps.
|
|
170
171
|
const scenario = match.scenario;
|
|
171
172
|
if (!match.scenarioId || !isReadyScenario(scenario)) {
|
|
@@ -726,6 +727,7 @@ export class ReplayController {
|
|
|
726
727
|
const blame = this.blameStep(steps);
|
|
727
728
|
return {
|
|
728
729
|
scenarioId: state.scenarioId,
|
|
730
|
+
runId: d.runId,
|
|
729
731
|
ticket: state.ticket,
|
|
730
732
|
outcome: state.outcome,
|
|
731
733
|
deriveCostUsd: state.deriveCostUsd,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* journal — the audit lines that explain a turn, kept on disk for
|
|
3
|
+
* `bir investigate` (docs/calculatedReplayGuide.md §9.1).
|
|
4
|
+
*
|
|
5
|
+
* `logLine` prints to stderr and is gone when the terminal scrolls. The
|
|
6
|
+
* questions people ask afterwards — why did this prompt not run its scenario,
|
|
7
|
+
* which gate declined, what mode did the plan arm in, what did the execution
|
|
8
|
+
* report — are answered by exactly those lines. So `bir-hooks` opens a journal
|
|
9
|
+
* for its directory and every audit line whose event is in {@link isJournaled}
|
|
10
|
+
* lands in it too, as one JSON object per line with the same fields.
|
|
11
|
+
*
|
|
12
|
+
* One file per directory, keyed like the discovery file, so two projects on
|
|
13
|
+
* one machine never mix. Rotated at {@link JOURNAL_MAX_BYTES} by keeping the
|
|
14
|
+
* newest half. Written best-effort and synchronously: a full disk must never be
|
|
15
|
+
* the reason a hook fails (design §1, rule 4), and a hook's answer must not
|
|
16
|
+
* race the line that explains it.
|
|
17
|
+
*/
|
|
18
|
+
export interface JournalEntry {
|
|
19
|
+
/** ISO timestamp, written by the journal, not by the caller. */
|
|
20
|
+
at: string;
|
|
21
|
+
event: string;
|
|
22
|
+
[field: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
export declare function isJournaled(event: string): boolean;
|
|
25
|
+
export declare const JOURNAL_MAX_BYTES: number;
|
|
26
|
+
export declare function journalPath(cwd: string): string;
|
|
27
|
+
/** Start journaling this directory's audit lines. Returns the file, for the startup log. */
|
|
28
|
+
export declare function openJournal(cwd: string): string;
|
|
29
|
+
export declare function closeJournal(): void;
|
|
30
|
+
/**
|
|
31
|
+
* Append one entry, if a journal is open and the event is one we keep.
|
|
32
|
+
* Called by `logLine` for every audit line, and directly for the few facts
|
|
33
|
+
* that belong in the journal but not on stderr (a prompt preview).
|
|
34
|
+
*/
|
|
35
|
+
export declare function journal(event: string, fields?: Record<string, unknown>): void;
|
|
36
|
+
/** Every entry in a journal file, oldest first. A malformed line is skipped, not fatal. */
|
|
37
|
+
export declare function readJournal(path: string): JournalEntry[];
|
|
38
|
+
//# sourceMappingURL=journal.d.ts.map
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* journal — the audit lines that explain a turn, kept on disk for
|
|
3
|
+
* `bir investigate` (docs/calculatedReplayGuide.md §9.1).
|
|
4
|
+
*
|
|
5
|
+
* `logLine` prints to stderr and is gone when the terminal scrolls. The
|
|
6
|
+
* questions people ask afterwards — why did this prompt not run its scenario,
|
|
7
|
+
* which gate declined, what mode did the plan arm in, what did the execution
|
|
8
|
+
* report — are answered by exactly those lines. So `bir-hooks` opens a journal
|
|
9
|
+
* for its directory and every audit line whose event is in {@link isJournaled}
|
|
10
|
+
* lands in it too, as one JSON object per line with the same fields.
|
|
11
|
+
*
|
|
12
|
+
* One file per directory, keyed like the discovery file, so two projects on
|
|
13
|
+
* one machine never mix. Rotated at {@link JOURNAL_MAX_BYTES} by keeping the
|
|
14
|
+
* newest half. Written best-effort and synchronously: a full disk must never be
|
|
15
|
+
* the reason a hook fails (design §1, rule 4), and a hook's answer must not
|
|
16
|
+
* race the line that explains it.
|
|
17
|
+
*/
|
|
18
|
+
import { appendFileSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
import { controlDir, controlKey, ensureDir } from "../control/paths.js";
|
|
21
|
+
/** Events worth keeping: the run boundaries, every replay decision, the money. */
|
|
22
|
+
const JOURNALED_PREFIXES = ["run.", "replay.", "plan.", "execution."];
|
|
23
|
+
const JOURNALED_EVENTS = new Set(["session.start", "control.listening", "proxy.registered"]);
|
|
24
|
+
export function isJournaled(event) {
|
|
25
|
+
return JOURNALED_EVENTS.has(event) || JOURNALED_PREFIXES.some((p) => event.startsWith(p));
|
|
26
|
+
}
|
|
27
|
+
export const JOURNAL_MAX_BYTES = 4 * 1024 * 1024;
|
|
28
|
+
export function journalPath(cwd) {
|
|
29
|
+
return join(controlDir(), "journal", `${controlKey(cwd)}.jsonl`);
|
|
30
|
+
}
|
|
31
|
+
let current;
|
|
32
|
+
/** Start journaling this directory's audit lines. Returns the file, for the startup log. */
|
|
33
|
+
export function openJournal(cwd) {
|
|
34
|
+
const path = journalPath(cwd);
|
|
35
|
+
ensureDir(dirname(path));
|
|
36
|
+
current = path;
|
|
37
|
+
return path;
|
|
38
|
+
}
|
|
39
|
+
export function closeJournal() {
|
|
40
|
+
current = undefined;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Append one entry, if a journal is open and the event is one we keep.
|
|
44
|
+
* Called by `logLine` for every audit line, and directly for the few facts
|
|
45
|
+
* that belong in the journal but not on stderr (a prompt preview).
|
|
46
|
+
*/
|
|
47
|
+
export function journal(event, fields = {}) {
|
|
48
|
+
if (!current || !isJournaled(event))
|
|
49
|
+
return;
|
|
50
|
+
const entry = { at: new Date().toISOString(), event };
|
|
51
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
52
|
+
if (value === undefined || value === null)
|
|
53
|
+
continue;
|
|
54
|
+
entry[key] = value;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
appendFileSync(current, `${JSON.stringify(entry)}\n`);
|
|
58
|
+
rotate(current);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* best effort — never the reason a hook fails */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function rotate(path) {
|
|
65
|
+
let size = 0;
|
|
66
|
+
try {
|
|
67
|
+
size = statSync(path).size;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (size <= JOURNAL_MAX_BYTES)
|
|
73
|
+
return;
|
|
74
|
+
const lines = readFileSync(path, "utf8").split("\n").filter(Boolean);
|
|
75
|
+
const keep = lines.slice(Math.floor(lines.length / 2));
|
|
76
|
+
writeFileSync(path, keep.length ? `${keep.join("\n")}\n` : "");
|
|
77
|
+
}
|
|
78
|
+
/** Every entry in a journal file, oldest first. A malformed line is skipped, not fatal. */
|
|
79
|
+
export function readJournal(path) {
|
|
80
|
+
if (!existsSync(path))
|
|
81
|
+
return [];
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
84
|
+
if (!line.trim())
|
|
85
|
+
continue;
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(line);
|
|
88
|
+
if (typeof parsed.event === "string" && typeof parsed.at === "string")
|
|
89
|
+
out.push(parsed);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
/* a torn last line from a crash, or a hand edit — ignore it */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=journal.js.map
|
package/dist/util/log.js
CHANGED
|
@@ -18,8 +18,12 @@ export const VERBOSE = process.env.BIR_VERBOSE === "1";
|
|
|
18
18
|
* session: the audit log is the only record of what the recorder decided.
|
|
19
19
|
*/
|
|
20
20
|
export const QUIET = process.env.BIR_QUIET === "1";
|
|
21
|
+
import { journal } from "./journal.js";
|
|
21
22
|
/** Print one audit line: `[bir] <iso ts> <event> key=value …`. */
|
|
22
23
|
export function logLine(event, fields = {}) {
|
|
24
|
+
// The journal keeps the decision lines for `bir investigate` (journal.ts).
|
|
25
|
+
// Before the QUIET check on purpose: silence is for the terminal, not the record.
|
|
26
|
+
journal(event, fields);
|
|
23
27
|
if (QUIET)
|
|
24
28
|
return;
|
|
25
29
|
const pairs = [];
|
package/docs/calculatedReplay.md
CHANGED
|
@@ -59,7 +59,7 @@ Continuing v1's D1–D8.
|
|
|
59
59
|
| **D11** | Execution ownership for MCP steps | **The proxy that owns the upstream** | No second connection, no double-spawned browser, no Agent SDK dependency |
|
|
60
60
|
| **D12** | Control → proxy channel | **Long-poll**, proxy dials out | No new listeners, no new ports, no new tokens (§13.3, §19.2) |
|
|
61
61
|
| **D13** | Result delivery in direct mode | A **first-party MCP server** (`bir`), one tool | The model reads a genuine `tool_result`, never a `deny` reason it distrusts (§19.1) |
|
|
62
|
-
| **D14** | Default state | **
|
|
62
|
+
| **D14** | Default state | **On** once `bir install --replay` has run; `BIR_REPLAY=0` turns it off | Replay bypasses permission prompts (§13.2); the install flag is the opt-in, the env var is the kill switch |
|
|
63
63
|
| **D15** | Parameter derivation | One Haiku call over raw `fetch`; **no API key ⇒ recorded sample values** | Preserves v1's zero-runtime-dependency property, and degrades to a free replay |
|
|
64
64
|
| **D16** | Savings reporting | Every armed match reports to `POST /scenarios/:id/executions`, including declines | A decline is a *baseline sample*, not silence — it is what keeps the ledger honest |
|
|
65
65
|
|
|
@@ -189,7 +189,7 @@ Every gate that declines logs **one** `replay.decision` line carrying the reason
|
|
|
189
189
|
| # | Gate | Declines when | Outcome floor |
|
|
190
190
|
|---|---|---|---|
|
|
191
191
|
| 1 | **A match arrived** | `getMatch()` resolved `null`, or the budget expired (§10) | (no report — nothing matched) |
|
|
192
|
-
| 2 | **Replay enabled** | `BIR_REPLAY
|
|
192
|
+
| 2 | **Replay enabled** | `BIR_REPLAY === "0"` | `not_steered` |
|
|
193
193
|
| 3 | **Scenario ready** | `scenario == null`, `state !== "ready"`, or `steps.length === 0` | `not_steered` |
|
|
194
194
|
| 4 | **Similarity** | `similarity < BIR_MIN_STEER_SIMILARITY` (default **0.92**) | `not_steered` |
|
|
195
195
|
| 5 | **Tool coverage** | No step is executable anywhere (§5 → `mode: "none"`) | `not_steered` |
|
|
@@ -828,7 +828,7 @@ So a scenario is, in effect, a **pre-approved list of tool calls with computed a
|
|
|
828
828
|
precisely what makes it valuable, and precisely what makes it dangerous. Four mitigations, all
|
|
829
829
|
required:
|
|
830
830
|
|
|
831
|
-
1. `
|
|
831
|
+
1. `bir install --replay` a distinct, non-default flag; `BIR_REPLAY=0` a one-line kill switch once installed.
|
|
832
832
|
2. `BIR_REPLAY_ALLOW_SERVERS` — a comma-separated allowlist of server keys eligible for **direct**
|
|
833
833
|
execution. Unset means *all wrapped servers*; the guide (§4 of `calculatedReplayGuide.md`)
|
|
834
834
|
recommends setting it.
|
|
@@ -206,7 +206,7 @@ That is what makes replay fast, and it is the whole risk. Two things follow:
|
|
|
206
206
|
|
|
207
207
|
```bash
|
|
208
208
|
bir install --replay # adds the `bir` MCP server; raises the prompt-hook timeout
|
|
209
|
-
|
|
209
|
+
unset BIR_REPLAY # on by default; only BIR_REPLAY=0 turns it off
|
|
210
210
|
bir-hooks # restart it
|
|
211
211
|
```
|
|
212
212
|
|
|
@@ -243,7 +243,7 @@ there is exactly one switch and it cannot get out of step with itself.
|
|
|
243
243
|
|
|
244
244
|
| Variable | Default | What it does |
|
|
245
245
|
|---|---|---|
|
|
246
|
-
| `BIR_REPLAY` | *(
|
|
246
|
+
| `BIR_REPLAY` | *(on)* | `0` disables replay. Unset or any other value keeps it on; nothing below matters while it is `0` |
|
|
247
247
|
| `BIR_REPLAY_ALLOW_SERVERS` | *(all wrapped)* | Comma-separated server keys eligible for **direct** execution. **Set this** |
|
|
248
248
|
| `BIR_MIN_STEER_SIMILARITY` | `0.92` | Below this a match is detected but not replayed (§8) |
|
|
249
249
|
| `ANTHROPIC_API_KEY` | *(unset)* | **Not required.** Derivation — reading what this request acts on — is done by the service on its key for a signed-in runner. Set this to keep the reading on this machine instead: the prompt then never leaves it, and it is one round trip faster. Signed out *and* unset, only a scenario with nothing to work out replays |
|
|
@@ -381,7 +381,7 @@ first prompt that states the task plainly.
|
|
|
381
381
|
| Symptom | Cause | Fix |
|
|
382
382
|
|---|---|---|
|
|
383
383
|
| No `run.matched`, ever | Prompt below the server's `SIMILARITY_THRESHOLD` | Rephrase closer, or lower it server-side. Confirm the original run is in the list — a sub-`RECORDING_MIN_ACTIONS` run is never embedded |
|
|
384
|
-
| `run.matched` but no `plan.armed` | A gate declined | Read the `replay.decision` line's `why=`. Ranked by frequency: `BIR_REPLAY`
|
|
384
|
+
| `run.matched` but no `plan.armed` | A gate declined | Read the `replay.decision` line's `why=`. Ranked by frequency: `BIR_REPLAY=0` · scenario not `ready` · similarity below threshold · no step is executable |
|
|
385
385
|
| `why="no step is executable"` | None of the scenario's tools is a wrapped MCP server and none is a built-in reachable in this session | Wrap the servers the scenario uses (`bir install --server …`) and recalculate |
|
|
386
386
|
| `plan.armed mode=steer` where you expected `direct` | At least one step is a built-in, an unwrapped MCP server, or `claude-in-chrome` | `bir status` shows what is wrapped. `claude-in-chrome` is `scope: "dynamic"` and can never be wrapped |
|
|
387
387
|
| Model ignores the directive and diverges every turn | Steering is advisory — the model chooses; `bir` only pins arguments | Expected occasionally. Persistent divergence usually means the scenario's tools do not fit the live task; check `intent` |
|
|
@@ -395,6 +395,38 @@ first prompt that states the task plainly.
|
|
|
395
395
|
|
|
396
396
|
---
|
|
397
397
|
|
|
398
|
+
### 9.1 `bir investigate` — the table above, applied for you
|
|
399
|
+
|
|
400
|
+
Every audit line that explains a turn is also kept in a **journal**, one JSON
|
|
401
|
+
object per line, under `~/.baseinstrunner/control/journal/` (one file per
|
|
402
|
+
directory, rotated at 4 MB). `bir-hooks` prints its path at start. Ask the
|
|
403
|
+
journal and the service together:
|
|
404
|
+
|
|
405
|
+
```
|
|
406
|
+
bir investigate # the newest turn in this directory
|
|
407
|
+
bir investigate <run_|scn_|sexec_ id> # that run, scenario or execution
|
|
408
|
+
bir investigate list [--limit n] # recent turns and their verdicts
|
|
409
|
+
bir investigate executions [--limit n] [--user <email|id>] # the ledger
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
The output is the turn as the journal tells it (matched what, which gate
|
|
413
|
+
declined and why, what mode armed with which coverage, what the execution
|
|
414
|
+
reported), then **runner findings**, then the service's view (the recording,
|
|
415
|
+
the scenario, the baseline and its sample count, the executions with their
|
|
416
|
+
saved $ and %) and **service findings**. Every finding is *problem → cause →
|
|
417
|
+
fix*. The three questions it answers:
|
|
418
|
+
|
|
419
|
+
| Question | Where the answer comes from |
|
|
420
|
+
|---|---|
|
|
421
|
+
| Why did this turn not run its calculated scenario? | `replay.decision code=…` in the journal, mapped to the cause and the fix; and the service: is the run a recording, is it embedded, how many hits, is the scenario ready / failed / switched off, which step is parked |
|
|
422
|
+
| Why did it save so little, or cost more? | the execution's cost breakdown (derive, session, fallback) against the baseline; steer mode with built-in steps is the usual answer, a single-sample baseline the second |
|
|
423
|
+
| What happened, step by step? | the turn's lines in order, the per-step verdicts on the execution, and `replay.*_failed`, `replay.diverge`, `replay.handover` as incidents |
|
|
424
|
+
|
|
425
|
+
You see your own data. An admin of the service sees everyone's and may pass
|
|
426
|
+
`--user` to `executions`. `--json` on any form prints the raw merge.
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
398
430
|
## 10. What is built
|
|
399
431
|
|
|
400
432
|
All of it. Phases R0–R7 of [calculatedReplay.md](calculatedReplay.md) §17 are implemented and
|
|
@@ -439,10 +471,10 @@ Three things the implementation settled that the design left open:
|
|
|
439
471
|
Three levels, least to most.
|
|
440
472
|
|
|
441
473
|
```bash
|
|
442
|
-
|
|
474
|
+
export BIR_REPLAY=0 # stop arming. Matches still detected; recording still stops on a match
|
|
443
475
|
bir uninstall --replay # remove the `bir` MCP server, restore the prompt-hook timeout
|
|
444
476
|
bir uninstall # remove everything: proxies, hooks, files restored byte-for-byte
|
|
445
477
|
```
|
|
446
478
|
|
|
447
|
-
With `BIR_REPLAY`
|
|
479
|
+
With `BIR_REPLAY=0` the system is exactly v1 again: it recognises a repeated prompt, declines to
|
|
448
480
|
record it a second time, and lets the model do the work.
|
package/docs/my-first-sample.md
CHANGED
|
@@ -357,7 +357,7 @@ Leave this running in its own terminal, **in the run folder**:
|
|
|
357
357
|
|
|
358
358
|
```bash
|
|
359
359
|
export BIR_AUTH_URL=https://api.your-domain.com
|
|
360
|
-
|
|
360
|
+
unset BIR_REPLAY # replay is on by default; BIR_REPLAY=0 turns it off
|
|
361
361
|
export BIR_REPLAY_ALLOW_SERVERS=postgres # servers allowed to run unattended
|
|
362
362
|
bir-hooks 2>&1 | tee -a bir-hooks.log
|
|
363
363
|
```
|
|
@@ -539,7 +539,7 @@ real work for zero tokens, and the saving is real and positive.**
|
|
|
539
539
|
- **[quickstart.md](quickstart.md)** — setting up a fresh machine to record from
|
|
540
540
|
your *own* work, not just this benchmark.
|
|
541
541
|
|
|
542
|
-
> **Turning replay off** is one line: `
|
|
542
|
+
> **Turning replay off** is one line: `export BIR_REPLAY=0`. The system is then
|
|
543
543
|
> exactly a recorder again — it recognises a repeated prompt and declines to
|
|
544
544
|
> record it twice, and the model does the work. Nothing is skipped, nothing is
|
|
545
545
|
> risked.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basein/runner",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "A recording MCP proxy: sits between any MCP client and its MCP servers, executes each call on the client's behalf, and records the run as a reusable BaseIn scenario.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|