@gfrmin/credence-pi-openclaw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +93 -0
- package/dist/cost.d.ts +21 -0
- package/dist/cost.js +97 -0
- package/dist/daemon-client.d.ts +27 -0
- package/dist/daemon-client.js +145 -0
- package/dist/features.d.ts +15 -0
- package/dist/features.js +122 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +258 -0
- package/dist/openclaw-types.d.ts +96 -0
- package/dist/openclaw-types.js +15 -0
- package/openclaw.plugin.json +45 -0
- package/package.json +58 -0
- package/src/cost.ts +130 -0
- package/src/daemon-client.ts +204 -0
- package/src/features.ts +152 -0
- package/src/index.ts +342 -0
- package/src/openclaw-types.ts +158 -0
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# credence-pi — OpenClaw plugin body
|
|
2
|
+
|
|
3
|
+
The **body** that lets the credence-pi Bayesian brain govern a real
|
|
4
|
+
pi/OpenClaw agent. It registers an OpenClaw `before_tool_call` hook,
|
|
5
|
+
forwards each proposed tool call to the credence-pi daemon (the opaque
|
|
6
|
+
brain), and maps the brain's decision back to OpenClaw:
|
|
7
|
+
|
|
8
|
+
| brain effector | OpenClaw result |
|
|
9
|
+
|---|---|
|
|
10
|
+
| `proceed` | allow |
|
|
11
|
+
| `block` | `{ block: true, blockReason }` |
|
|
12
|
+
| `ask` | `{ requireApproval }` — OpenClaw's native approval dialog; the user's choice is posted back so the brain learns |
|
|
13
|
+
|
|
14
|
+
It also logs tool **outcomes** (`after_tool_call`) and reconstructs
|
|
15
|
+
**per-turn cost** (`llm_output` token counts × a price table) so the
|
|
16
|
+
observation log accumulates the data the dollars-saved surface needs.
|
|
17
|
+
|
|
18
|
+
**Fail-open:** if the daemon is unreachable or slow, the tool proceeds
|
|
19
|
+
(one warning per outage). Governance never blocks the agent on
|
|
20
|
+
infrastructure failure.
|
|
21
|
+
|
|
22
|
+
This is one of two bodies over the same brain; the other,
|
|
23
|
+
`apps/credence-pi/extension/`, targets the pi coding agent directly.
|
|
24
|
+
Brain + wire (POST `/sensor`, SSE `/signals`) are shared and unchanged.
|
|
25
|
+
|
|
26
|
+
## Why a plugin (not a pi extension)
|
|
27
|
+
|
|
28
|
+
Current OpenClaw vendors pi's coding-agent and runs its gateway agent with
|
|
29
|
+
`noExtensions: true`, so a pi `ExtensionFactory` never loads. The supported
|
|
30
|
+
interception point is an OpenClaw **plugin** `before_tool_call` hook. See
|
|
31
|
+
`docs/credence-pi-pass-2/move-1-design.md`.
|
|
32
|
+
|
|
33
|
+
## Install (operator)
|
|
34
|
+
|
|
35
|
+
1. Start the brain daemon — it listens on `http://127.0.0.1:8787`. Either run
|
|
36
|
+
the published image
|
|
37
|
+
(`docker run -p 8787:8787 -v ~/.credence-pi:/root/.credence-pi ghcr.io/gfrmin/credence-pi-daemon`)
|
|
38
|
+
or run it from source
|
|
39
|
+
(`julia --project=<repo-root> apps/credence-pi/daemon/main.jl`).
|
|
40
|
+
See `apps/credence-pi/daemon/README.md`.
|
|
41
|
+
2. Build the plugin: `cd apps/credence-pi/openclaw-plugin && npm install && npm run build`.
|
|
42
|
+
3. Install it into OpenClaw. From a published registry:
|
|
43
|
+
`openclaw plugins install @gfrmin/credence-pi-openclaw`; or link a local
|
|
44
|
+
checkout for development: `openclaw plugins install -l apps/credence-pi/openclaw-plugin`.
|
|
45
|
+
Then `openclaw plugins enable credence-pi`.
|
|
46
|
+
4. **Per-turn cost signal.** On current OpenClaw (≥ 2026.6.2) the `llm_output`
|
|
47
|
+
cost hook is active out of the box — no extra config. (Older builds gated it
|
|
48
|
+
behind a since-removed `plugins.entries.credence-pi.hooks.allowConversationAccess`
|
|
49
|
+
flag; that key is now rejected by the config schema.) Governance —
|
|
50
|
+
allow/block/ask — never depended on it.
|
|
51
|
+
5. Restart the gateway so it picks up the plugin.
|
|
52
|
+
6. Verify it loaded: `openclaw plugins list` shows `credence-pi` as `loaded`.
|
|
53
|
+
|
|
54
|
+
## Config (`openclaw.plugin.json` → `configSchema`)
|
|
55
|
+
|
|
56
|
+
| key | default | meaning |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `daemonUrl` | `http://127.0.0.1:8787` | credence-pi daemon base URL |
|
|
59
|
+
| `hookTimeoutMs` | `3000` | max wait for the daemon decision before failing open |
|
|
60
|
+
| `approvalTimeoutMs` | `120000` | how long OpenClaw waits for the user on an `ask` before denying |
|
|
61
|
+
| `redactToolInputs` | `false` | omit tool-call inputs from sensor events (they can carry secrets); ask-preview becomes generic |
|
|
62
|
+
| `silent` | `false` | suppress info/warn logs |
|
|
63
|
+
| `pricing` | — | per-model USD/Mtok overrides: `{ "<model>": { "input": n, "output": n, "cacheRead": n, "cacheWrite": n } }` |
|
|
64
|
+
|
|
65
|
+
**Privacy:** by default the daemon logs `proposed_call.input` (commands, paths) to
|
|
66
|
+
`~/.credence-pi/observations.jsonl`. Set `redactToolInputs: true` to keep inputs out of the log.
|
|
67
|
+
Fail-open warnings are emitted once per outage (re-armed when the daemon recovers).
|
|
68
|
+
|
|
69
|
+
The built-in price table is approximate; set `pricing` for an exact
|
|
70
|
+
dollars-saved figure for your providers. Unknown/unpriced models log
|
|
71
|
+
`usd: null` (token counts still recorded; the Move-2 surface applies a
|
|
72
|
+
token×price fallback).
|
|
73
|
+
|
|
74
|
+
## Develop
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
npm install
|
|
78
|
+
npm run build # tsc → dist/
|
|
79
|
+
npm run typecheck # tsc --noEmit
|
|
80
|
+
npm test # node --test (tsx) over tests/*.test.ts
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Known limitations (Move 1 / MVP-0)
|
|
84
|
+
|
|
85
|
+
- `working-directory-relative` and `time-since-last-user-message` features
|
|
86
|
+
are best-effort (OpenClaw doesn't put cwd or message timestamps on the
|
|
87
|
+
tool ctx); the loop-relevant features (tool-name, parent, repetition)
|
|
88
|
+
are exact. In Move 1 the brain does not condition on features at decision
|
|
89
|
+
time, so this only affects future feature-conditioned learning.
|
|
90
|
+
- Cost USD is reconstructed from a local price table (no host
|
|
91
|
+
`calculateCost` dependency); override via `pricing`.
|
|
92
|
+
- The per-run feature buffer is bounded per run but the run map is not
|
|
93
|
+
evicted on a long-lived gateway — a PASS-2 cleanup item.
|
package/dist/cost.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { LlmOutputEvent } from "./openclaw-types.js";
|
|
2
|
+
export interface ModelPrice {
|
|
3
|
+
input: number;
|
|
4
|
+
output: number;
|
|
5
|
+
cacheRead?: number;
|
|
6
|
+
cacheWrite?: number;
|
|
7
|
+
}
|
|
8
|
+
export type PriceTable = Record<string, ModelPrice>;
|
|
9
|
+
export declare const DEFAULT_PRICES: PriceTable;
|
|
10
|
+
export interface TurnCost {
|
|
11
|
+
usd: number | null;
|
|
12
|
+
total_tokens: number | null;
|
|
13
|
+
input_tokens: number | null;
|
|
14
|
+
output_tokens: number | null;
|
|
15
|
+
cache_read: number | null;
|
|
16
|
+
cache_write: number | null;
|
|
17
|
+
model: string | null;
|
|
18
|
+
}
|
|
19
|
+
/** Merge operator `pricing` config over the built-in table. */
|
|
20
|
+
export declare function buildPriceTable(overrides: unknown): PriceTable;
|
|
21
|
+
export declare function computeTurnCost(event: LlmOutputEvent, table: PriceTable): TurnCost;
|
package/dist/cost.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// cost.ts — reconstruct per-turn USD from llm_output token counts.
|
|
2
|
+
//
|
|
3
|
+
// OpenClaw does not hand plugins a dollar figure (only token counts +
|
|
4
|
+
// model id on llm_output). The host computes USD internally via
|
|
5
|
+
// calculateCost(model, usage), but does not expose it to plugins, and we
|
|
6
|
+
// stay dependency-free (no `openclaw` runtime import). So we reconstruct
|
|
7
|
+
// USD from a small, CONFIG-OVERRIDABLE price table (USD per million
|
|
8
|
+
// tokens). The built-in numbers are approximate defaults — the operator
|
|
9
|
+
// should verify/override via the plugin's `pricing` config for an exact
|
|
10
|
+
// dollars-saved figure. When a model can't be priced, usd is null and the
|
|
11
|
+
// Move-2 surface falls back to token counts.
|
|
12
|
+
// Built-in defaults, keyed by a lowercase family substring matched against
|
|
13
|
+
// the model id. Approximate; override via config `pricing`. Ordered by
|
|
14
|
+
// specificity is not required — we pick the longest matching key.
|
|
15
|
+
export const DEFAULT_PRICES = {
|
|
16
|
+
"claude-opus": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
|
|
17
|
+
"claude-sonnet": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
18
|
+
"claude-haiku": { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
|
|
19
|
+
"gpt-4o-mini": { input: 0.15, output: 0.6 },
|
|
20
|
+
"gpt-4o": { input: 2.5, output: 10 },
|
|
21
|
+
"gpt-4.1": { input: 2, output: 8 },
|
|
22
|
+
"o3": { input: 2, output: 8 },
|
|
23
|
+
"gemini-2.5-pro": { input: 1.25, output: 10 },
|
|
24
|
+
"gemini-2.5-flash": { input: 0.3, output: 2.5 },
|
|
25
|
+
};
|
|
26
|
+
function escapeRegExp(s) {
|
|
27
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
28
|
+
}
|
|
29
|
+
function resolvePrice(model, table) {
|
|
30
|
+
if (!model)
|
|
31
|
+
return undefined;
|
|
32
|
+
const m = model.toLowerCase();
|
|
33
|
+
// Exact match first.
|
|
34
|
+
if (table[m])
|
|
35
|
+
return table[m];
|
|
36
|
+
// Longest key that appears as a SEGMENT of the id — anchored at the
|
|
37
|
+
// start or after a non-alphanumeric separator. So "o3" matches
|
|
38
|
+
// "o3-mini" and "openai/o3" but NOT "foo3"; longest match wins.
|
|
39
|
+
let best;
|
|
40
|
+
for (const [key, price] of Object.entries(table)) {
|
|
41
|
+
const re = new RegExp(`(^|[^a-z0-9])${escapeRegExp(key)}`);
|
|
42
|
+
if (re.test(m) && (best === undefined || key.length > best.key.length)) {
|
|
43
|
+
best = { key, price };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return best?.price;
|
|
47
|
+
}
|
|
48
|
+
/** Merge operator `pricing` config over the built-in table. */
|
|
49
|
+
export function buildPriceTable(overrides) {
|
|
50
|
+
const table = { ...DEFAULT_PRICES };
|
|
51
|
+
if (overrides && typeof overrides === "object") {
|
|
52
|
+
for (const [k, v] of Object.entries(overrides)) {
|
|
53
|
+
if (v && typeof v === "object") {
|
|
54
|
+
const o = v;
|
|
55
|
+
const input = typeof o.input === "number" ? o.input : undefined;
|
|
56
|
+
const output = typeof o.output === "number" ? o.output : undefined;
|
|
57
|
+
if (input !== undefined && output !== undefined) {
|
|
58
|
+
table[k.toLowerCase()] = {
|
|
59
|
+
input,
|
|
60
|
+
output,
|
|
61
|
+
cacheRead: typeof o.cacheRead === "number" ? o.cacheRead : undefined,
|
|
62
|
+
cacheWrite: typeof o.cacheWrite === "number" ? o.cacheWrite : undefined,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return table;
|
|
69
|
+
}
|
|
70
|
+
export function computeTurnCost(event, table) {
|
|
71
|
+
const u = event.usage ?? {};
|
|
72
|
+
const input = u.input ?? 0;
|
|
73
|
+
const output = u.output ?? 0;
|
|
74
|
+
const cacheRead = u.cacheRead ?? 0;
|
|
75
|
+
const cacheWrite = u.cacheWrite ?? 0;
|
|
76
|
+
const total = u.total ?? input + output + cacheRead + cacheWrite;
|
|
77
|
+
const model = event.model ?? null;
|
|
78
|
+
const price = resolvePrice(event.model, table);
|
|
79
|
+
let usd = null;
|
|
80
|
+
if (price) {
|
|
81
|
+
usd =
|
|
82
|
+
(input * price.input +
|
|
83
|
+
output * price.output +
|
|
84
|
+
cacheRead * (price.cacheRead ?? price.input) +
|
|
85
|
+
cacheWrite * (price.cacheWrite ?? price.input)) /
|
|
86
|
+
1_000_000;
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
usd,
|
|
90
|
+
total_tokens: total,
|
|
91
|
+
input_tokens: input,
|
|
92
|
+
output_tokens: output,
|
|
93
|
+
cache_read: cacheRead,
|
|
94
|
+
cache_write: cacheWrite,
|
|
95
|
+
model,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface SignalEnvelope {
|
|
2
|
+
signal_type: string;
|
|
3
|
+
signal_id: string;
|
|
4
|
+
in_response_to: string;
|
|
5
|
+
effector: string;
|
|
6
|
+
parameters: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
export type Logger = (msg: string, err?: unknown) => void;
|
|
9
|
+
export interface ClientOptions {
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
initialBackoffMs?: number;
|
|
13
|
+
maxBackoffMs?: number;
|
|
14
|
+
logger?: Logger;
|
|
15
|
+
}
|
|
16
|
+
export interface PostResult {
|
|
17
|
+
ok: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface DaemonClient {
|
|
20
|
+
postSensor: (event: object) => Promise<PostResult>;
|
|
21
|
+
connectSignalsStream: (onSignal: (sig: SignalEnvelope) => void) => SignalsConnection;
|
|
22
|
+
}
|
|
23
|
+
export interface SignalsConnection {
|
|
24
|
+
close: () => void;
|
|
25
|
+
done: Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export declare function createDaemonClient(opts: ClientOptions): DaemonClient;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// daemon-client.ts — body-side HTTP client for the credence-pi daemon.
|
|
2
|
+
//
|
|
3
|
+
// shared-source: apps/credence-pi/extension/src/client.ts
|
|
4
|
+
// This is a vendored copy (verbatim behaviour) so the OpenClaw plugin
|
|
5
|
+
// is a self-contained, installable package. The pi-extension body and
|
|
6
|
+
// this OpenClaw body MUST keep the same daemon wire (POST /sensor,
|
|
7
|
+
// GET /signals SSE). If you change one, change the other; client.test.ts
|
|
8
|
+
// in the extension covers the reference behaviour.
|
|
9
|
+
//
|
|
10
|
+
// Two responsibilities, both fail-open:
|
|
11
|
+
// postSensor(event) — POST /sensor with a timeout; never
|
|
12
|
+
// throws, never hangs; logs once on
|
|
13
|
+
// failure and returns {ok:false}.
|
|
14
|
+
// connectSignalsStream(onSignal) — streaming GET /signals consumer;
|
|
15
|
+
// parses `data:` SSE frames; auto-
|
|
16
|
+
// reconnects with exponential backoff
|
|
17
|
+
// until close().
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
19
|
+
const DEFAULT_INITIAL_BACKOFF_MS = 500;
|
|
20
|
+
const DEFAULT_MAX_BACKOFF_MS = 30_000;
|
|
21
|
+
export function createDaemonClient(opts) {
|
|
22
|
+
const baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
23
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
24
|
+
const initialBackoff = opts.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF_MS;
|
|
25
|
+
const maxBackoff = opts.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
26
|
+
const log = opts.logger ??
|
|
27
|
+
((m, e) => (e === undefined ? console.warn(m) : console.warn(m, e)));
|
|
28
|
+
return {
|
|
29
|
+
postSensor: (event) => postSensor(baseUrl, event, timeoutMs, log),
|
|
30
|
+
connectSignalsStream: (onSignal) => connectSignalsStream(baseUrl, onSignal, initialBackoff, maxBackoff, log),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async function postSensor(baseUrl, event, timeoutMs, log) {
|
|
34
|
+
const ctrl = new AbortController();
|
|
35
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
36
|
+
try {
|
|
37
|
+
const resp = await fetch(`${baseUrl}/sensor`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { "Content-Type": "application/json" },
|
|
40
|
+
body: JSON.stringify(event),
|
|
41
|
+
signal: ctrl.signal,
|
|
42
|
+
});
|
|
43
|
+
try {
|
|
44
|
+
await resp.text();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
/* noop */
|
|
48
|
+
}
|
|
49
|
+
if (!resp.ok) {
|
|
50
|
+
log(`credence-pi: daemon /sensor returned status ${resp.status}; failing open`);
|
|
51
|
+
return { ok: false };
|
|
52
|
+
}
|
|
53
|
+
return { ok: true };
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
log("credence-pi: daemon unreachable on /sensor; failing open", err);
|
|
57
|
+
return { ok: false };
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function connectSignalsStream(baseUrl, onSignal, initialBackoff, maxBackoff, log) {
|
|
64
|
+
const ctrl = new AbortController();
|
|
65
|
+
let closed = false;
|
|
66
|
+
const done = (async () => {
|
|
67
|
+
let backoff = initialBackoff;
|
|
68
|
+
while (!closed) {
|
|
69
|
+
try {
|
|
70
|
+
await consumeOnce(baseUrl, onSignal, ctrl.signal, log);
|
|
71
|
+
if (closed)
|
|
72
|
+
break;
|
|
73
|
+
log("credence-pi: /signals stream ended; reconnecting");
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (closed)
|
|
77
|
+
break;
|
|
78
|
+
log("credence-pi: /signals stream error; reconnecting", err);
|
|
79
|
+
}
|
|
80
|
+
await new Promise((resolve) => {
|
|
81
|
+
const t = setTimeout(resolve, backoff);
|
|
82
|
+
ctrl.signal.addEventListener("abort", () => {
|
|
83
|
+
clearTimeout(t);
|
|
84
|
+
resolve();
|
|
85
|
+
}, { once: true });
|
|
86
|
+
});
|
|
87
|
+
backoff = Math.min(backoff * 2, maxBackoff);
|
|
88
|
+
}
|
|
89
|
+
})();
|
|
90
|
+
return {
|
|
91
|
+
close: () => {
|
|
92
|
+
closed = true;
|
|
93
|
+
ctrl.abort();
|
|
94
|
+
},
|
|
95
|
+
done,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
async function consumeOnce(baseUrl, onSignal, signal, log) {
|
|
99
|
+
const resp = await fetch(`${baseUrl}/signals`, {
|
|
100
|
+
method: "GET",
|
|
101
|
+
headers: { Accept: "text/event-stream" },
|
|
102
|
+
signal,
|
|
103
|
+
});
|
|
104
|
+
if (!resp.ok || !resp.body) {
|
|
105
|
+
throw new Error(`/signals returned ${resp.status}`);
|
|
106
|
+
}
|
|
107
|
+
const reader = resp.body.getReader();
|
|
108
|
+
const decoder = new TextDecoder();
|
|
109
|
+
let buffer = "";
|
|
110
|
+
try {
|
|
111
|
+
while (true) {
|
|
112
|
+
const { done, value } = await reader.read();
|
|
113
|
+
if (done)
|
|
114
|
+
return;
|
|
115
|
+
buffer += decoder.decode(value, { stream: true });
|
|
116
|
+
let idx;
|
|
117
|
+
while ((idx = buffer.indexOf("\n\n")) >= 0) {
|
|
118
|
+
const frame = buffer.slice(0, idx);
|
|
119
|
+
buffer = buffer.slice(idx + 2);
|
|
120
|
+
dispatchFrame(frame, onSignal, log);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
try {
|
|
126
|
+
reader.releaseLock();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* noop */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function dispatchFrame(frame, onSignal, log) {
|
|
134
|
+
for (const line of frame.split("\n")) {
|
|
135
|
+
if (line.startsWith("data: ")) {
|
|
136
|
+
const payload = line.slice(6);
|
|
137
|
+
try {
|
|
138
|
+
onSignal(JSON.parse(payload));
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
log("credence-pi: /signals dispatch dropped malformed frame", err);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { BeforeToolCallEvent, ToolContext } from "./openclaw-types.js";
|
|
2
|
+
export type Features = Record<string, string>;
|
|
3
|
+
export declare class FeatureTracker {
|
|
4
|
+
private runs;
|
|
5
|
+
/** Stamp the most recent user message for a run (best-effort; wired
|
|
6
|
+
* from a message hook if one is available). */
|
|
7
|
+
markUserMessage(ctx: ToolContext, ts: number): void;
|
|
8
|
+
/** Compute features from the run's history BEFORE this call, then record
|
|
9
|
+
* the current call. `now` is injectable for deterministic tests. */
|
|
10
|
+
extractAndRecord(event: BeforeToolCallEvent, ctx: ToolContext, now?: number): Features;
|
|
11
|
+
/** Drop a run's buffer (call on agent_end to bound memory). */
|
|
12
|
+
clearRun(ctx: ToolContext): void;
|
|
13
|
+
/** Test/inspection accessor. */
|
|
14
|
+
runCount(): number;
|
|
15
|
+
}
|
package/dist/features.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// features.ts — the body's sensory periphery for the OpenClaw plugin.
|
|
2
|
+
//
|
|
3
|
+
// Produces the brain's declared kebab-case feature vocabulary
|
|
4
|
+
// (apps/credence-pi/bdsl/features.bdsl) from the before_tool_call event +
|
|
5
|
+
// a small per-run history buffer. In Move 1 the brain does NOT condition
|
|
6
|
+
// on features at decision time (global Beta), so these are logged for the
|
|
7
|
+
// dollars-saved surface and future feature-conditioned learning; the
|
|
8
|
+
// loop-relevant ones (tool-name, parent, repetition) are exact, while
|
|
9
|
+
// working-directory-relative and time-since-last-user-message are
|
|
10
|
+
// best-effort (OpenClaw does not put cwd or message timestamps on the
|
|
11
|
+
// tool ctx — see move-1-design.md OQ-d).
|
|
12
|
+
const KNOWN_TOOLS = new Set([
|
|
13
|
+
"read",
|
|
14
|
+
"write",
|
|
15
|
+
"edit",
|
|
16
|
+
"bash",
|
|
17
|
+
"exec",
|
|
18
|
+
"process",
|
|
19
|
+
"apply_patch",
|
|
20
|
+
"grep",
|
|
21
|
+
"find",
|
|
22
|
+
"ls",
|
|
23
|
+
]);
|
|
24
|
+
const HISTORY_CAP = 50;
|
|
25
|
+
const REPETITION_WINDOW = 5;
|
|
26
|
+
function bucketTool(name) {
|
|
27
|
+
if (!name)
|
|
28
|
+
return "other";
|
|
29
|
+
const t = name.toLowerCase();
|
|
30
|
+
return KNOWN_TOOLS.has(t) ? t : "other";
|
|
31
|
+
}
|
|
32
|
+
function runKey(ctx) {
|
|
33
|
+
return ctx.runId ?? ctx.sessionKey ?? ctx.sessionId ?? "default";
|
|
34
|
+
}
|
|
35
|
+
function timeSinceUserBucket(elapsedMs) {
|
|
36
|
+
if (elapsedMs === undefined)
|
|
37
|
+
return "gt-10m";
|
|
38
|
+
const s = elapsedMs / 1000;
|
|
39
|
+
if (s < 30)
|
|
40
|
+
return "lt-30s";
|
|
41
|
+
if (s < 120)
|
|
42
|
+
return "lt-2m";
|
|
43
|
+
if (s < 600)
|
|
44
|
+
return "lt-10m";
|
|
45
|
+
return "gt-10m";
|
|
46
|
+
}
|
|
47
|
+
function workingDirRelative(ctx, event) {
|
|
48
|
+
const root = ctx.workspaceDir;
|
|
49
|
+
const paths = event.derivedPaths ?? [];
|
|
50
|
+
if (paths.length === 0) {
|
|
51
|
+
// No path-bearing tool (e.g. a bare bash with no file args we can see).
|
|
52
|
+
return "no-path";
|
|
53
|
+
}
|
|
54
|
+
if (!root)
|
|
55
|
+
return "no-path";
|
|
56
|
+
const norm = (p) => p.replace(/\/+$/, "");
|
|
57
|
+
const r = norm(root);
|
|
58
|
+
let sawOutside = false;
|
|
59
|
+
let sawRootExact = false;
|
|
60
|
+
let sawSub = false;
|
|
61
|
+
for (const raw of paths) {
|
|
62
|
+
const p = norm(raw);
|
|
63
|
+
if (p === r) {
|
|
64
|
+
sawRootExact = true;
|
|
65
|
+
}
|
|
66
|
+
else if (p.startsWith(r + "/")) {
|
|
67
|
+
sawSub = true;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
sawOutside = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (sawOutside)
|
|
74
|
+
return "outside-project";
|
|
75
|
+
if (sawRootExact && !sawSub)
|
|
76
|
+
return "project-root";
|
|
77
|
+
return "subdirectory";
|
|
78
|
+
}
|
|
79
|
+
export class FeatureTracker {
|
|
80
|
+
runs = new Map();
|
|
81
|
+
/** Stamp the most recent user message for a run (best-effort; wired
|
|
82
|
+
* from a message hook if one is available). */
|
|
83
|
+
markUserMessage(ctx, ts) {
|
|
84
|
+
const k = runKey(ctx);
|
|
85
|
+
const st = this.runs.get(k) ?? { history: [] };
|
|
86
|
+
st.lastUserTs = ts;
|
|
87
|
+
this.runs.set(k, st);
|
|
88
|
+
}
|
|
89
|
+
/** Compute features from the run's history BEFORE this call, then record
|
|
90
|
+
* the current call. `now` is injectable for deterministic tests. */
|
|
91
|
+
extractAndRecord(event, ctx, now = Date.now()) {
|
|
92
|
+
const k = runKey(ctx);
|
|
93
|
+
const st = this.runs.get(k) ?? { history: [] };
|
|
94
|
+
const tool = bucketTool(event.toolName);
|
|
95
|
+
const parent = st.history.length > 0 ? st.history[st.history.length - 1].tool : "none";
|
|
96
|
+
const recent = st.history.slice(-REPETITION_WINDOW);
|
|
97
|
+
const reps = recent.filter((e) => e.tool === tool).length;
|
|
98
|
+
const repBucket = reps === 0 ? "rep-0" : reps === 1 ? "rep-1" : reps === 2 ? "rep-2" : "rep-3plus";
|
|
99
|
+
const elapsed = st.lastUserTs === undefined ? undefined : now - st.lastUserTs;
|
|
100
|
+
const features = {
|
|
101
|
+
"tool-name": tool,
|
|
102
|
+
"working-directory-relative": workingDirRelative(ctx, event),
|
|
103
|
+
"parent-tool-call-name": parent,
|
|
104
|
+
"recent-repetition-count": repBucket,
|
|
105
|
+
"time-since-last-user-message": timeSinceUserBucket(elapsed),
|
|
106
|
+
};
|
|
107
|
+
// Record current call.
|
|
108
|
+
st.history.push({ tool, ts: now });
|
|
109
|
+
if (st.history.length > HISTORY_CAP)
|
|
110
|
+
st.history.shift();
|
|
111
|
+
this.runs.set(k, st);
|
|
112
|
+
return features;
|
|
113
|
+
}
|
|
114
|
+
/** Drop a run's buffer (call on agent_end to bound memory). */
|
|
115
|
+
clearRun(ctx) {
|
|
116
|
+
this.runs.delete(runKey(ctx));
|
|
117
|
+
}
|
|
118
|
+
/** Test/inspection accessor. */
|
|
119
|
+
runCount() {
|
|
120
|
+
return this.runs.size;
|
|
121
|
+
}
|
|
122
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type DaemonClient, type SignalEnvelope, type Logger } from "./daemon-client.js";
|
|
2
|
+
import { type PriceTable } from "./cost.js";
|
|
3
|
+
import type { PluginEntry, BeforeToolCallEvent, BeforeToolCallResult, AfterToolCallEvent, LlmOutputEvent, ToolContext } from "./openclaw-types.js";
|
|
4
|
+
export declare function mapSignal(sig: SignalEnvelope | undefined, originatingEventId: string, client: DaemonClient, approvalTimeoutMs: number): BeforeToolCallResult | undefined;
|
|
5
|
+
export interface GovernorOpts {
|
|
6
|
+
hookTimeoutMs: number;
|
|
7
|
+
approvalTimeoutMs: number;
|
|
8
|
+
priceTable: PriceTable;
|
|
9
|
+
redactToolInputs: boolean;
|
|
10
|
+
log: Logger;
|
|
11
|
+
}
|
|
12
|
+
export interface Governor {
|
|
13
|
+
beforeToolCall: (event: BeforeToolCallEvent, ctx: ToolContext) => Promise<BeforeToolCallResult | undefined>;
|
|
14
|
+
afterToolCall: (event: AfterToolCallEvent, ctx: ToolContext) => Promise<void>;
|
|
15
|
+
llmOutput: (event: LlmOutputEvent, ctx: ToolContext) => Promise<void>;
|
|
16
|
+
cleanup: () => void;
|
|
17
|
+
/** Test/inspection accessor: in-flight tool_call awaiters. */
|
|
18
|
+
pendingCount: () => number;
|
|
19
|
+
}
|
|
20
|
+
export declare function createGovernor(client: DaemonClient, opts: GovernorOpts): Governor;
|
|
21
|
+
declare const plugin: PluginEntry;
|
|
22
|
+
export default plugin;
|