@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.
@@ -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
+ }
@@ -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;