Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,32 @@ 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.
- 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
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`), 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. 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

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion docs/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 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.
Expand Down
12 changes: 11 additions & 1 deletion plugins/openclaw/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -255,6 +257,14 @@ left as is with one warning per lane.
- `strands/<lane>.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: for a new message 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.

## Safety properties

- Only the listed sessions are touched. Heartbeats, cron runs and inter-session messages never produce events.
Expand Down
9 changes: 9 additions & 0 deletions plugins/openclaw/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export function registerStance(api: OpenClawPluginApi, deps: StanceDeps = {}): S
hostVersion = undefined;
}
const warnedLabels = new Set<string>();
const warnedSealed = new Set<string>();
// 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);

Expand Down Expand Up @@ -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})`);
Expand Down
74 changes: 61 additions & 13 deletions plugins/openclaw/src/probbit.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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}`). */
Expand Down Expand Up @@ -208,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).
Expand All @@ -229,6 +254,17 @@ export async function runLiveEvent(opts: {
}): Promise<LiveRun> {
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);
Expand Down Expand Up @@ -275,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 };
Expand Down
37 changes: 32 additions & 5 deletions plugins/openclaw/src/senses.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -110,14 +110,41 @@ 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 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.
*/
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 (coversHour(gaps) || window >= size || window >= STRAND_TAIL_MAX) return gaps;
}
}

/** 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;
}

/** 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 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 } };
if (typeof obj.n !== "number") continue;
const obj = JSON.parse(line) as Record<string, unknown> & { 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 {
Expand Down
Loading
Loading