@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
|
@@ -0,0 +1,204 @@
|
|
|
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
|
+
|
|
19
|
+
export interface SignalEnvelope {
|
|
20
|
+
signal_type: string;
|
|
21
|
+
signal_id: string;
|
|
22
|
+
in_response_to: string;
|
|
23
|
+
effector: string;
|
|
24
|
+
parameters: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type Logger = (msg: string, err?: unknown) => void;
|
|
28
|
+
|
|
29
|
+
export interface ClientOptions {
|
|
30
|
+
baseUrl: string;
|
|
31
|
+
timeoutMs?: number;
|
|
32
|
+
initialBackoffMs?: number;
|
|
33
|
+
maxBackoffMs?: number;
|
|
34
|
+
logger?: Logger;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PostResult {
|
|
38
|
+
ok: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DaemonClient {
|
|
42
|
+
postSensor: (event: object) => Promise<PostResult>;
|
|
43
|
+
connectSignalsStream: (
|
|
44
|
+
onSignal: (sig: SignalEnvelope) => void,
|
|
45
|
+
) => SignalsConnection;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SignalsConnection {
|
|
49
|
+
close: () => void;
|
|
50
|
+
done: Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
54
|
+
const DEFAULT_INITIAL_BACKOFF_MS = 500;
|
|
55
|
+
const DEFAULT_MAX_BACKOFF_MS = 30_000;
|
|
56
|
+
|
|
57
|
+
export function createDaemonClient(opts: ClientOptions): DaemonClient {
|
|
58
|
+
const baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
59
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
60
|
+
const initialBackoff = opts.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF_MS;
|
|
61
|
+
const maxBackoff = opts.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
62
|
+
const log: Logger =
|
|
63
|
+
opts.logger ??
|
|
64
|
+
((m, e) => (e === undefined ? console.warn(m) : console.warn(m, e)));
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
postSensor: (event) => postSensor(baseUrl, event, timeoutMs, log),
|
|
68
|
+
connectSignalsStream: (onSignal) =>
|
|
69
|
+
connectSignalsStream(baseUrl, onSignal, initialBackoff, maxBackoff, log),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function postSensor(
|
|
74
|
+
baseUrl: string,
|
|
75
|
+
event: object,
|
|
76
|
+
timeoutMs: number,
|
|
77
|
+
log: Logger,
|
|
78
|
+
): Promise<PostResult> {
|
|
79
|
+
const ctrl = new AbortController();
|
|
80
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
81
|
+
try {
|
|
82
|
+
const resp = await fetch(`${baseUrl}/sensor`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "Content-Type": "application/json" },
|
|
85
|
+
body: JSON.stringify(event),
|
|
86
|
+
signal: ctrl.signal,
|
|
87
|
+
});
|
|
88
|
+
try {
|
|
89
|
+
await resp.text();
|
|
90
|
+
} catch {
|
|
91
|
+
/* noop */
|
|
92
|
+
}
|
|
93
|
+
if (!resp.ok) {
|
|
94
|
+
log(`credence-pi: daemon /sensor returned status ${resp.status}; failing open`);
|
|
95
|
+
return { ok: false };
|
|
96
|
+
}
|
|
97
|
+
return { ok: true };
|
|
98
|
+
} catch (err) {
|
|
99
|
+
log("credence-pi: daemon unreachable on /sensor; failing open", err);
|
|
100
|
+
return { ok: false };
|
|
101
|
+
} finally {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function connectSignalsStream(
|
|
107
|
+
baseUrl: string,
|
|
108
|
+
onSignal: (sig: SignalEnvelope) => void,
|
|
109
|
+
initialBackoff: number,
|
|
110
|
+
maxBackoff: number,
|
|
111
|
+
log: Logger,
|
|
112
|
+
): SignalsConnection {
|
|
113
|
+
const ctrl = new AbortController();
|
|
114
|
+
let closed = false;
|
|
115
|
+
|
|
116
|
+
const done = (async () => {
|
|
117
|
+
let backoff = initialBackoff;
|
|
118
|
+
while (!closed) {
|
|
119
|
+
try {
|
|
120
|
+
await consumeOnce(baseUrl, onSignal, ctrl.signal, log);
|
|
121
|
+
if (closed) break;
|
|
122
|
+
log("credence-pi: /signals stream ended; reconnecting");
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (closed) break;
|
|
125
|
+
log("credence-pi: /signals stream error; reconnecting", err);
|
|
126
|
+
}
|
|
127
|
+
await new Promise<void>((resolve) => {
|
|
128
|
+
const t = setTimeout(resolve, backoff);
|
|
129
|
+
ctrl.signal.addEventListener(
|
|
130
|
+
"abort",
|
|
131
|
+
() => {
|
|
132
|
+
clearTimeout(t);
|
|
133
|
+
resolve();
|
|
134
|
+
},
|
|
135
|
+
{ once: true },
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
backoff = Math.min(backoff * 2, maxBackoff);
|
|
139
|
+
}
|
|
140
|
+
})();
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
close: () => {
|
|
144
|
+
closed = true;
|
|
145
|
+
ctrl.abort();
|
|
146
|
+
},
|
|
147
|
+
done,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function consumeOnce(
|
|
152
|
+
baseUrl: string,
|
|
153
|
+
onSignal: (sig: SignalEnvelope) => void,
|
|
154
|
+
signal: AbortSignal,
|
|
155
|
+
log: Logger,
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
const resp = await fetch(`${baseUrl}/signals`, {
|
|
158
|
+
method: "GET",
|
|
159
|
+
headers: { Accept: "text/event-stream" },
|
|
160
|
+
signal,
|
|
161
|
+
});
|
|
162
|
+
if (!resp.ok || !resp.body) {
|
|
163
|
+
throw new Error(`/signals returned ${resp.status}`);
|
|
164
|
+
}
|
|
165
|
+
const reader = resp.body.getReader();
|
|
166
|
+
const decoder = new TextDecoder();
|
|
167
|
+
let buffer = "";
|
|
168
|
+
try {
|
|
169
|
+
while (true) {
|
|
170
|
+
const { done, value } = await reader.read();
|
|
171
|
+
if (done) return;
|
|
172
|
+
buffer += decoder.decode(value, { stream: true });
|
|
173
|
+
let idx: number;
|
|
174
|
+
while ((idx = buffer.indexOf("\n\n")) >= 0) {
|
|
175
|
+
const frame = buffer.slice(0, idx);
|
|
176
|
+
buffer = buffer.slice(idx + 2);
|
|
177
|
+
dispatchFrame(frame, onSignal, log);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
try {
|
|
182
|
+
reader.releaseLock();
|
|
183
|
+
} catch {
|
|
184
|
+
/* noop */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function dispatchFrame(
|
|
190
|
+
frame: string,
|
|
191
|
+
onSignal: (sig: SignalEnvelope) => void,
|
|
192
|
+
log: Logger,
|
|
193
|
+
): void {
|
|
194
|
+
for (const line of frame.split("\n")) {
|
|
195
|
+
if (line.startsWith("data: ")) {
|
|
196
|
+
const payload = line.slice(6);
|
|
197
|
+
try {
|
|
198
|
+
onSignal(JSON.parse(payload) as SignalEnvelope);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
log("credence-pi: /signals dispatch dropped malformed frame", err);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
package/src/features.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
|
|
13
|
+
import type { BeforeToolCallEvent, ToolContext } from "./openclaw-types.js";
|
|
14
|
+
|
|
15
|
+
export type Features = Record<string, string>;
|
|
16
|
+
|
|
17
|
+
const KNOWN_TOOLS = new Set([
|
|
18
|
+
"read",
|
|
19
|
+
"write",
|
|
20
|
+
"edit",
|
|
21
|
+
"bash",
|
|
22
|
+
"exec",
|
|
23
|
+
"process",
|
|
24
|
+
"apply_patch",
|
|
25
|
+
"grep",
|
|
26
|
+
"find",
|
|
27
|
+
"ls",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const HISTORY_CAP = 50;
|
|
31
|
+
const REPETITION_WINDOW = 5;
|
|
32
|
+
|
|
33
|
+
interface Entry {
|
|
34
|
+
tool: string;
|
|
35
|
+
ts: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface RunState {
|
|
39
|
+
history: Entry[];
|
|
40
|
+
lastUserTs?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function bucketTool(name: string | undefined): string {
|
|
44
|
+
if (!name) return "other";
|
|
45
|
+
const t = name.toLowerCase();
|
|
46
|
+
return KNOWN_TOOLS.has(t) ? t : "other";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function runKey(ctx: ToolContext): string {
|
|
50
|
+
return ctx.runId ?? ctx.sessionKey ?? ctx.sessionId ?? "default";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function timeSinceUserBucket(elapsedMs: number | undefined): string {
|
|
54
|
+
if (elapsedMs === undefined) return "gt-10m";
|
|
55
|
+
const s = elapsedMs / 1000;
|
|
56
|
+
if (s < 30) return "lt-30s";
|
|
57
|
+
if (s < 120) return "lt-2m";
|
|
58
|
+
if (s < 600) return "lt-10m";
|
|
59
|
+
return "gt-10m";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function workingDirRelative(
|
|
63
|
+
ctx: ToolContext,
|
|
64
|
+
event: BeforeToolCallEvent,
|
|
65
|
+
): string {
|
|
66
|
+
const root = ctx.workspaceDir;
|
|
67
|
+
const paths = event.derivedPaths ?? [];
|
|
68
|
+
if (paths.length === 0) {
|
|
69
|
+
// No path-bearing tool (e.g. a bare bash with no file args we can see).
|
|
70
|
+
return "no-path";
|
|
71
|
+
}
|
|
72
|
+
if (!root) return "no-path";
|
|
73
|
+
const norm = (p: string) => p.replace(/\/+$/, "");
|
|
74
|
+
const r = norm(root);
|
|
75
|
+
let sawOutside = false;
|
|
76
|
+
let sawRootExact = false;
|
|
77
|
+
let sawSub = false;
|
|
78
|
+
for (const raw of paths) {
|
|
79
|
+
const p = norm(raw);
|
|
80
|
+
if (p === r) {
|
|
81
|
+
sawRootExact = true;
|
|
82
|
+
} else if (p.startsWith(r + "/")) {
|
|
83
|
+
sawSub = true;
|
|
84
|
+
} else {
|
|
85
|
+
sawOutside = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (sawOutside) return "outside-project";
|
|
89
|
+
if (sawRootExact && !sawSub) return "project-root";
|
|
90
|
+
return "subdirectory";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class FeatureTracker {
|
|
94
|
+
private runs = new Map<string, RunState>();
|
|
95
|
+
|
|
96
|
+
/** Stamp the most recent user message for a run (best-effort; wired
|
|
97
|
+
* from a message hook if one is available). */
|
|
98
|
+
markUserMessage(ctx: ToolContext, ts: number): void {
|
|
99
|
+
const k = runKey(ctx);
|
|
100
|
+
const st = this.runs.get(k) ?? { history: [] };
|
|
101
|
+
st.lastUserTs = ts;
|
|
102
|
+
this.runs.set(k, st);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Compute features from the run's history BEFORE this call, then record
|
|
106
|
+
* the current call. `now` is injectable for deterministic tests. */
|
|
107
|
+
extractAndRecord(
|
|
108
|
+
event: BeforeToolCallEvent,
|
|
109
|
+
ctx: ToolContext,
|
|
110
|
+
now: number = Date.now(),
|
|
111
|
+
): Features {
|
|
112
|
+
const k = runKey(ctx);
|
|
113
|
+
const st = this.runs.get(k) ?? { history: [] };
|
|
114
|
+
const tool = bucketTool(event.toolName);
|
|
115
|
+
|
|
116
|
+
const parent =
|
|
117
|
+
st.history.length > 0 ? st.history[st.history.length - 1].tool : "none";
|
|
118
|
+
|
|
119
|
+
const recent = st.history.slice(-REPETITION_WINDOW);
|
|
120
|
+
const reps = recent.filter((e) => e.tool === tool).length;
|
|
121
|
+
const repBucket =
|
|
122
|
+
reps === 0 ? "rep-0" : reps === 1 ? "rep-1" : reps === 2 ? "rep-2" : "rep-3plus";
|
|
123
|
+
|
|
124
|
+
const elapsed =
|
|
125
|
+
st.lastUserTs === undefined ? undefined : now - st.lastUserTs;
|
|
126
|
+
|
|
127
|
+
const features: Features = {
|
|
128
|
+
"tool-name": tool,
|
|
129
|
+
"working-directory-relative": workingDirRelative(ctx, event),
|
|
130
|
+
"parent-tool-call-name": parent,
|
|
131
|
+
"recent-repetition-count": repBucket,
|
|
132
|
+
"time-since-last-user-message": timeSinceUserBucket(elapsed),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Record current call.
|
|
136
|
+
st.history.push({ tool, ts: now });
|
|
137
|
+
if (st.history.length > HISTORY_CAP) st.history.shift();
|
|
138
|
+
this.runs.set(k, st);
|
|
139
|
+
|
|
140
|
+
return features;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Drop a run's buffer (call on agent_end to bound memory). */
|
|
144
|
+
clearRun(ctx: ToolContext): void {
|
|
145
|
+
this.runs.delete(runKey(ctx));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Test/inspection accessor. */
|
|
149
|
+
runCount(): number {
|
|
150
|
+
return this.runs.size;
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// index.ts — credence-pi OpenClaw-plugin body.
|
|
2
|
+
//
|
|
3
|
+
// Governs the pi/OpenClaw agent loop by intercepting tool calls and
|
|
4
|
+
// routing the decision to the credence-pi Julia daemon (the opaque
|
|
5
|
+
// brain). Reuses credence-pi's async wire UNCHANGED: POST /sensor (a
|
|
6
|
+
// tool-proposed sensor event) then await the correlated effector signal
|
|
7
|
+
// off the SSE /signals stream; map it to OpenClaw's before_tool_call
|
|
8
|
+
// result. Also logs tool outcomes and reconstructed per-turn cost so the
|
|
9
|
+
// observation log accumulates the data the dollars-saved surface (Move 2)
|
|
10
|
+
// needs.
|
|
11
|
+
//
|
|
12
|
+
// Discipline (matches the pi-extension body):
|
|
13
|
+
// - The BRAIN decides; the body only translates. ask -> requireApproval
|
|
14
|
+
// (OpenClaw enforces the user's choice natively); the body posts
|
|
15
|
+
// user-responded via onResolution so the brain learns, but does not
|
|
16
|
+
// itself decide proceed/block on the reply.
|
|
17
|
+
// - Fail-open everywhere: daemon unreachable / slow ⇒ the tool proceeds,
|
|
18
|
+
// with one warning per outage.
|
|
19
|
+
//
|
|
20
|
+
// The orchestration lives in `createGovernor`, separated from `register`
|
|
21
|
+
// so it can be unit-tested with an injected DaemonClient (see
|
|
22
|
+
// tests/index.test.ts).
|
|
23
|
+
|
|
24
|
+
import { randomUUID } from "node:crypto";
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
createDaemonClient,
|
|
28
|
+
type DaemonClient,
|
|
29
|
+
type SignalEnvelope,
|
|
30
|
+
type Logger,
|
|
31
|
+
} from "./daemon-client.js";
|
|
32
|
+
import { FeatureTracker } from "./features.js";
|
|
33
|
+
import { buildPriceTable, computeTurnCost, type PriceTable } from "./cost.js";
|
|
34
|
+
import type {
|
|
35
|
+
PluginEntry,
|
|
36
|
+
BeforeToolCallEvent,
|
|
37
|
+
BeforeToolCallResult,
|
|
38
|
+
AfterToolCallEvent,
|
|
39
|
+
LlmOutputEvent,
|
|
40
|
+
ToolContext,
|
|
41
|
+
RequireApprovalPayload,
|
|
42
|
+
} from "./openclaw-types.js";
|
|
43
|
+
|
|
44
|
+
const DEFAULT_DAEMON_URL = "http://127.0.0.1:8787";
|
|
45
|
+
const DEFAULT_HOOK_TIMEOUT_MS = 3_000;
|
|
46
|
+
// How long OpenClaw waits for the human on an `ask` (requireApproval).
|
|
47
|
+
// Distinct from the daemon-decision timeout above.
|
|
48
|
+
const DEFAULT_APPROVAL_TIMEOUT_MS = 120_000;
|
|
49
|
+
|
|
50
|
+
function newEventId(): string {
|
|
51
|
+
return `evt_${randomUUID().slice(0, 12)}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function mapSignal(
|
|
55
|
+
sig: SignalEnvelope | undefined,
|
|
56
|
+
originatingEventId: string,
|
|
57
|
+
client: DaemonClient,
|
|
58
|
+
approvalTimeoutMs: number,
|
|
59
|
+
): BeforeToolCallResult | undefined {
|
|
60
|
+
if (!sig) return undefined; // timeout / fail-open ⇒ proceed
|
|
61
|
+
const p = sig.parameters ?? {};
|
|
62
|
+
switch (sig.effector) {
|
|
63
|
+
case "proceed":
|
|
64
|
+
return undefined;
|
|
65
|
+
case "block":
|
|
66
|
+
return {
|
|
67
|
+
block: true,
|
|
68
|
+
blockReason: `credence-pi: ${
|
|
69
|
+
typeof p.reason === "string"
|
|
70
|
+
? p.reason
|
|
71
|
+
: "tool call vetoed by expected-utility calculation"
|
|
72
|
+
}`,
|
|
73
|
+
};
|
|
74
|
+
case "ask": {
|
|
75
|
+
const description =
|
|
76
|
+
typeof p.text === "string" ? p.text : "Confirm this tool call?";
|
|
77
|
+
const requireApproval: RequireApprovalPayload = {
|
|
78
|
+
title: "credence-pi governance",
|
|
79
|
+
description,
|
|
80
|
+
severity: "warning",
|
|
81
|
+
timeoutMs: approvalTimeoutMs,
|
|
82
|
+
timeoutBehavior: "deny",
|
|
83
|
+
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
|
84
|
+
onResolution: async (decision) => {
|
|
85
|
+
const response =
|
|
86
|
+
decision === "allow-once" || decision === "allow-always"
|
|
87
|
+
? "yes"
|
|
88
|
+
: decision === "deny"
|
|
89
|
+
? "no"
|
|
90
|
+
: "timeout";
|
|
91
|
+
// Fire-and-forget: the brain conditions on the reply; OpenClaw
|
|
92
|
+
// has already enforced the decision. The daemon's follow-up
|
|
93
|
+
// proceed/block signal is unneeded here and harmlessly dropped
|
|
94
|
+
// (no awaiter).
|
|
95
|
+
await client.postSensor({
|
|
96
|
+
event_type: "user-responded",
|
|
97
|
+
event_id: newEventId(),
|
|
98
|
+
in_response_to: originatingEventId,
|
|
99
|
+
timestamp: new Date().toISOString(),
|
|
100
|
+
response,
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
return { requireApproval };
|
|
105
|
+
}
|
|
106
|
+
default:
|
|
107
|
+
return undefined; // unknown effector ⇒ fail open
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface GovernorOpts {
|
|
112
|
+
hookTimeoutMs: number;
|
|
113
|
+
approvalTimeoutMs: number;
|
|
114
|
+
priceTable: PriceTable;
|
|
115
|
+
redactToolInputs: boolean;
|
|
116
|
+
log: Logger;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface Governor {
|
|
120
|
+
beforeToolCall: (
|
|
121
|
+
event: BeforeToolCallEvent,
|
|
122
|
+
ctx: ToolContext,
|
|
123
|
+
) => Promise<BeforeToolCallResult | undefined>;
|
|
124
|
+
afterToolCall: (event: AfterToolCallEvent, ctx: ToolContext) => Promise<void>;
|
|
125
|
+
llmOutput: (event: LlmOutputEvent, ctx: ToolContext) => Promise<void>;
|
|
126
|
+
cleanup: () => void;
|
|
127
|
+
/** Test/inspection accessor: in-flight tool_call awaiters. */
|
|
128
|
+
pendingCount: () => number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The governance orchestration over an injected DaemonClient. register()
|
|
132
|
+
// wires this to the OpenClaw hook API; tests drive it with a fake client.
|
|
133
|
+
export function createGovernor(
|
|
134
|
+
client: DaemonClient,
|
|
135
|
+
opts: GovernorOpts,
|
|
136
|
+
): Governor {
|
|
137
|
+
const { hookTimeoutMs, approvalTimeoutMs, priceTable, redactToolInputs, log } =
|
|
138
|
+
opts;
|
|
139
|
+
const tracker = new FeatureTracker();
|
|
140
|
+
|
|
141
|
+
// event_id -> resolver for the awaited effector signal. The single SSE
|
|
142
|
+
// consumer dispatches signals here by in_response_to. Unmatched signals
|
|
143
|
+
// (e.g. an ask-followup after the hook already resolved) find no resolver
|
|
144
|
+
// and are dropped.
|
|
145
|
+
const awaiters = new Map<string, (sig: SignalEnvelope | undefined) => void>();
|
|
146
|
+
|
|
147
|
+
const sse = client.connectSignalsStream((sig) => {
|
|
148
|
+
const resolve = awaiters.get(sig.in_response_to);
|
|
149
|
+
if (resolve) resolve(sig);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
let warnedDown = false;
|
|
153
|
+
let announcedUp = false;
|
|
154
|
+
let down = false;
|
|
155
|
+
|
|
156
|
+
async function beforeToolCall(
|
|
157
|
+
event: BeforeToolCallEvent,
|
|
158
|
+
ctx: ToolContext,
|
|
159
|
+
): Promise<BeforeToolCallResult | undefined> {
|
|
160
|
+
const eventId = newEventId();
|
|
161
|
+
const signalPromise = new Promise<SignalEnvelope | undefined>((resolve) => {
|
|
162
|
+
const timer = setTimeout(() => {
|
|
163
|
+
awaiters.delete(eventId);
|
|
164
|
+
resolve(undefined);
|
|
165
|
+
}, hookTimeoutMs);
|
|
166
|
+
awaiters.set(eventId, (sig) => {
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
awaiters.delete(eventId);
|
|
169
|
+
resolve(sig);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const features = tracker.extractAndRecord(event, ctx);
|
|
174
|
+
const post = await client.postSensor({
|
|
175
|
+
event_type: "tool-proposed",
|
|
176
|
+
event_id: eventId,
|
|
177
|
+
session_id: ctx.sessionId ?? ctx.sessionKey ?? "",
|
|
178
|
+
timestamp: new Date().toISOString(),
|
|
179
|
+
features,
|
|
180
|
+
// Tool inputs can carry secrets (commands, tokens). Operators may
|
|
181
|
+
// redact them; the brain does not condition on input (Move 1), only
|
|
182
|
+
// the daemon's ask-text preview uses it.
|
|
183
|
+
proposed_call: {
|
|
184
|
+
tool_name: event.toolName,
|
|
185
|
+
input: redactToolInputs ? null : event.params,
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
if (!post.ok) {
|
|
190
|
+
const r = awaiters.get(eventId);
|
|
191
|
+
if (r) r(undefined); // clean up timer + awaiter
|
|
192
|
+
if (!warnedDown) {
|
|
193
|
+
log(
|
|
194
|
+
`credence-pi: daemon unreachable at the configured URL; proceeding without governance`,
|
|
195
|
+
);
|
|
196
|
+
warnedDown = true;
|
|
197
|
+
}
|
|
198
|
+
down = true;
|
|
199
|
+
announcedUp = false;
|
|
200
|
+
return undefined; // fail open
|
|
201
|
+
}
|
|
202
|
+
if (down && !announcedUp) {
|
|
203
|
+
log("credence-pi: daemon reachable again; governance resumed");
|
|
204
|
+
announcedUp = true;
|
|
205
|
+
down = false;
|
|
206
|
+
warnedDown = false; // re-arm the unreachable warning for the next outage
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const sig = await signalPromise;
|
|
210
|
+
return mapSignal(sig, eventId, client, approvalTimeoutMs);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function afterToolCall(
|
|
214
|
+
event: AfterToolCallEvent,
|
|
215
|
+
_ctx: ToolContext,
|
|
216
|
+
): Promise<void> {
|
|
217
|
+
// Correlate by the stable toolCallId (tools run in parallel).
|
|
218
|
+
await client.postSensor({
|
|
219
|
+
event_type: "tool-completed",
|
|
220
|
+
event_id: newEventId(),
|
|
221
|
+
in_response_to: event.toolCallId ?? "",
|
|
222
|
+
timestamp: new Date().toISOString(),
|
|
223
|
+
outcome: {
|
|
224
|
+
success: event.error == null,
|
|
225
|
+
duration_ms: event.durationMs ?? null,
|
|
226
|
+
result_summary: null,
|
|
227
|
+
error: event.error ?? null,
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function llmOutput(
|
|
233
|
+
event: LlmOutputEvent,
|
|
234
|
+
ctx: ToolContext,
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
const tc = computeTurnCost(event, priceTable);
|
|
237
|
+
await client.postSensor({
|
|
238
|
+
event_type: "turn-cost",
|
|
239
|
+
event_id: newEventId(),
|
|
240
|
+
session_id: ctx.sessionId ?? event.sessionId ?? "",
|
|
241
|
+
timestamp: new Date().toISOString(),
|
|
242
|
+
usd: tc.usd,
|
|
243
|
+
total_tokens: tc.total_tokens,
|
|
244
|
+
input_tokens: tc.input_tokens,
|
|
245
|
+
output_tokens: tc.output_tokens,
|
|
246
|
+
cache_read: tc.cache_read,
|
|
247
|
+
cache_write: tc.cache_write,
|
|
248
|
+
model: tc.model,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function cleanup(): void {
|
|
253
|
+
sse.close();
|
|
254
|
+
for (const resolve of awaiters.values()) resolve(undefined);
|
|
255
|
+
awaiters.clear();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
beforeToolCall,
|
|
260
|
+
afterToolCall,
|
|
261
|
+
llmOutput,
|
|
262
|
+
cleanup,
|
|
263
|
+
pendingCount: () => awaiters.size,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const plugin: PluginEntry = {
|
|
268
|
+
id: "credence-pi",
|
|
269
|
+
name: "credence-pi governance",
|
|
270
|
+
description:
|
|
271
|
+
"Bayesian in-loop governance for the pi/OpenClaw agent — intercepts tool calls (allow/block/ask) via the credence-pi brain and logs outcomes + per-turn cost.",
|
|
272
|
+
|
|
273
|
+
register(api) {
|
|
274
|
+
const cfg = api.pluginConfig ?? {};
|
|
275
|
+
const daemonUrl =
|
|
276
|
+
typeof cfg.daemonUrl === "string" ? cfg.daemonUrl : DEFAULT_DAEMON_URL;
|
|
277
|
+
const hookTimeoutMs =
|
|
278
|
+
typeof cfg.hookTimeoutMs === "number"
|
|
279
|
+
? cfg.hookTimeoutMs
|
|
280
|
+
: DEFAULT_HOOK_TIMEOUT_MS;
|
|
281
|
+
const approvalTimeoutMs =
|
|
282
|
+
typeof cfg.approvalTimeoutMs === "number"
|
|
283
|
+
? cfg.approvalTimeoutMs
|
|
284
|
+
: DEFAULT_APPROVAL_TIMEOUT_MS;
|
|
285
|
+
const silent = cfg.silent === true;
|
|
286
|
+
const redactToolInputs = cfg.redactToolInputs === true;
|
|
287
|
+
const priceTable = buildPriceTable(cfg.pricing);
|
|
288
|
+
|
|
289
|
+
const log: Logger = (m, e) => {
|
|
290
|
+
if (silent) return;
|
|
291
|
+
const msg = e === undefined ? m : `${m} ${String(e)}`;
|
|
292
|
+
api.logger?.warn?.(msg);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const client = createDaemonClient({
|
|
296
|
+
baseUrl: daemonUrl,
|
|
297
|
+
timeoutMs: hookTimeoutMs,
|
|
298
|
+
logger: log,
|
|
299
|
+
});
|
|
300
|
+
const gov = createGovernor(client, {
|
|
301
|
+
hookTimeoutMs,
|
|
302
|
+
approvalTimeoutMs,
|
|
303
|
+
priceTable,
|
|
304
|
+
redactToolInputs,
|
|
305
|
+
log,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
api.on("before_tool_call", gov.beforeToolCall, {
|
|
309
|
+
priority: 100,
|
|
310
|
+
timeoutMs: hookTimeoutMs + 1_000,
|
|
311
|
+
});
|
|
312
|
+
api.on("after_tool_call", gov.afterToolCall);
|
|
313
|
+
|
|
314
|
+
// Per-turn cost REQUIRES plugins.entries.credence-pi.hooks
|
|
315
|
+
// .allowConversationAccess: true. Wrapped so a blocked registration
|
|
316
|
+
// never breaks governance — cost is just absent.
|
|
317
|
+
try {
|
|
318
|
+
api.on("llm_output", gov.llmOutput);
|
|
319
|
+
} catch (err) {
|
|
320
|
+
log(
|
|
321
|
+
"credence-pi: llm_output hook unavailable (set hooks.allowConversationAccess:true for the cost signal)",
|
|
322
|
+
err,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Close the SSE stream + drain awaiters on reset/delete/reload so a
|
|
327
|
+
// hot-reload does not accumulate daemon connections. Optional-chained
|
|
328
|
+
// for hosts predating the lifecycle API.
|
|
329
|
+
try {
|
|
330
|
+
api.lifecycle?.registerRuntimeLifecycle?.({
|
|
331
|
+
id: "credence-pi-governor",
|
|
332
|
+
description:
|
|
333
|
+
"Close the credence-pi daemon SSE stream and drain pending tool-call awaiters.",
|
|
334
|
+
cleanup: () => gov.cleanup(),
|
|
335
|
+
});
|
|
336
|
+
} catch (err) {
|
|
337
|
+
log("credence-pi: could not register lifecycle cleanup", err);
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
export default plugin;
|