@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/dist/index.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
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
|
+
import { randomUUID } from "node:crypto";
|
|
24
|
+
import { createDaemonClient, } from "./daemon-client.js";
|
|
25
|
+
import { FeatureTracker } from "./features.js";
|
|
26
|
+
import { buildPriceTable, computeTurnCost } from "./cost.js";
|
|
27
|
+
const DEFAULT_DAEMON_URL = "http://127.0.0.1:8787";
|
|
28
|
+
const DEFAULT_HOOK_TIMEOUT_MS = 3_000;
|
|
29
|
+
// How long OpenClaw waits for the human on an `ask` (requireApproval).
|
|
30
|
+
// Distinct from the daemon-decision timeout above.
|
|
31
|
+
const DEFAULT_APPROVAL_TIMEOUT_MS = 120_000;
|
|
32
|
+
function newEventId() {
|
|
33
|
+
return `evt_${randomUUID().slice(0, 12)}`;
|
|
34
|
+
}
|
|
35
|
+
export function mapSignal(sig, originatingEventId, client, approvalTimeoutMs) {
|
|
36
|
+
if (!sig)
|
|
37
|
+
return undefined; // timeout / fail-open ⇒ proceed
|
|
38
|
+
const p = sig.parameters ?? {};
|
|
39
|
+
switch (sig.effector) {
|
|
40
|
+
case "proceed":
|
|
41
|
+
return undefined;
|
|
42
|
+
case "block":
|
|
43
|
+
return {
|
|
44
|
+
block: true,
|
|
45
|
+
blockReason: `credence-pi: ${typeof p.reason === "string"
|
|
46
|
+
? p.reason
|
|
47
|
+
: "tool call vetoed by expected-utility calculation"}`,
|
|
48
|
+
};
|
|
49
|
+
case "ask": {
|
|
50
|
+
const description = typeof p.text === "string" ? p.text : "Confirm this tool call?";
|
|
51
|
+
const requireApproval = {
|
|
52
|
+
title: "credence-pi governance",
|
|
53
|
+
description,
|
|
54
|
+
severity: "warning",
|
|
55
|
+
timeoutMs: approvalTimeoutMs,
|
|
56
|
+
timeoutBehavior: "deny",
|
|
57
|
+
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
|
58
|
+
onResolution: async (decision) => {
|
|
59
|
+
const response = decision === "allow-once" || decision === "allow-always"
|
|
60
|
+
? "yes"
|
|
61
|
+
: decision === "deny"
|
|
62
|
+
? "no"
|
|
63
|
+
: "timeout";
|
|
64
|
+
// Fire-and-forget: the brain conditions on the reply; OpenClaw
|
|
65
|
+
// has already enforced the decision. The daemon's follow-up
|
|
66
|
+
// proceed/block signal is unneeded here and harmlessly dropped
|
|
67
|
+
// (no awaiter).
|
|
68
|
+
await client.postSensor({
|
|
69
|
+
event_type: "user-responded",
|
|
70
|
+
event_id: newEventId(),
|
|
71
|
+
in_response_to: originatingEventId,
|
|
72
|
+
timestamp: new Date().toISOString(),
|
|
73
|
+
response,
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
return { requireApproval };
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
return undefined; // unknown effector ⇒ fail open
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// The governance orchestration over an injected DaemonClient. register()
|
|
84
|
+
// wires this to the OpenClaw hook API; tests drive it with a fake client.
|
|
85
|
+
export function createGovernor(client, opts) {
|
|
86
|
+
const { hookTimeoutMs, approvalTimeoutMs, priceTable, redactToolInputs, log } = opts;
|
|
87
|
+
const tracker = new FeatureTracker();
|
|
88
|
+
// event_id -> resolver for the awaited effector signal. The single SSE
|
|
89
|
+
// consumer dispatches signals here by in_response_to. Unmatched signals
|
|
90
|
+
// (e.g. an ask-followup after the hook already resolved) find no resolver
|
|
91
|
+
// and are dropped.
|
|
92
|
+
const awaiters = new Map();
|
|
93
|
+
const sse = client.connectSignalsStream((sig) => {
|
|
94
|
+
const resolve = awaiters.get(sig.in_response_to);
|
|
95
|
+
if (resolve)
|
|
96
|
+
resolve(sig);
|
|
97
|
+
});
|
|
98
|
+
let warnedDown = false;
|
|
99
|
+
let announcedUp = false;
|
|
100
|
+
let down = false;
|
|
101
|
+
async function beforeToolCall(event, ctx) {
|
|
102
|
+
const eventId = newEventId();
|
|
103
|
+
const signalPromise = new Promise((resolve) => {
|
|
104
|
+
const timer = setTimeout(() => {
|
|
105
|
+
awaiters.delete(eventId);
|
|
106
|
+
resolve(undefined);
|
|
107
|
+
}, hookTimeoutMs);
|
|
108
|
+
awaiters.set(eventId, (sig) => {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
awaiters.delete(eventId);
|
|
111
|
+
resolve(sig);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
const features = tracker.extractAndRecord(event, ctx);
|
|
115
|
+
const post = await client.postSensor({
|
|
116
|
+
event_type: "tool-proposed",
|
|
117
|
+
event_id: eventId,
|
|
118
|
+
session_id: ctx.sessionId ?? ctx.sessionKey ?? "",
|
|
119
|
+
timestamp: new Date().toISOString(),
|
|
120
|
+
features,
|
|
121
|
+
// Tool inputs can carry secrets (commands, tokens). Operators may
|
|
122
|
+
// redact them; the brain does not condition on input (Move 1), only
|
|
123
|
+
// the daemon's ask-text preview uses it.
|
|
124
|
+
proposed_call: {
|
|
125
|
+
tool_name: event.toolName,
|
|
126
|
+
input: redactToolInputs ? null : event.params,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
if (!post.ok) {
|
|
130
|
+
const r = awaiters.get(eventId);
|
|
131
|
+
if (r)
|
|
132
|
+
r(undefined); // clean up timer + awaiter
|
|
133
|
+
if (!warnedDown) {
|
|
134
|
+
log(`credence-pi: daemon unreachable at the configured URL; proceeding without governance`);
|
|
135
|
+
warnedDown = true;
|
|
136
|
+
}
|
|
137
|
+
down = true;
|
|
138
|
+
announcedUp = false;
|
|
139
|
+
return undefined; // fail open
|
|
140
|
+
}
|
|
141
|
+
if (down && !announcedUp) {
|
|
142
|
+
log("credence-pi: daemon reachable again; governance resumed");
|
|
143
|
+
announcedUp = true;
|
|
144
|
+
down = false;
|
|
145
|
+
warnedDown = false; // re-arm the unreachable warning for the next outage
|
|
146
|
+
}
|
|
147
|
+
const sig = await signalPromise;
|
|
148
|
+
return mapSignal(sig, eventId, client, approvalTimeoutMs);
|
|
149
|
+
}
|
|
150
|
+
async function afterToolCall(event, _ctx) {
|
|
151
|
+
// Correlate by the stable toolCallId (tools run in parallel).
|
|
152
|
+
await client.postSensor({
|
|
153
|
+
event_type: "tool-completed",
|
|
154
|
+
event_id: newEventId(),
|
|
155
|
+
in_response_to: event.toolCallId ?? "",
|
|
156
|
+
timestamp: new Date().toISOString(),
|
|
157
|
+
outcome: {
|
|
158
|
+
success: event.error == null,
|
|
159
|
+
duration_ms: event.durationMs ?? null,
|
|
160
|
+
result_summary: null,
|
|
161
|
+
error: event.error ?? null,
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
async function llmOutput(event, ctx) {
|
|
166
|
+
const tc = computeTurnCost(event, priceTable);
|
|
167
|
+
await client.postSensor({
|
|
168
|
+
event_type: "turn-cost",
|
|
169
|
+
event_id: newEventId(),
|
|
170
|
+
session_id: ctx.sessionId ?? event.sessionId ?? "",
|
|
171
|
+
timestamp: new Date().toISOString(),
|
|
172
|
+
usd: tc.usd,
|
|
173
|
+
total_tokens: tc.total_tokens,
|
|
174
|
+
input_tokens: tc.input_tokens,
|
|
175
|
+
output_tokens: tc.output_tokens,
|
|
176
|
+
cache_read: tc.cache_read,
|
|
177
|
+
cache_write: tc.cache_write,
|
|
178
|
+
model: tc.model,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
function cleanup() {
|
|
182
|
+
sse.close();
|
|
183
|
+
for (const resolve of awaiters.values())
|
|
184
|
+
resolve(undefined);
|
|
185
|
+
awaiters.clear();
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
beforeToolCall,
|
|
189
|
+
afterToolCall,
|
|
190
|
+
llmOutput,
|
|
191
|
+
cleanup,
|
|
192
|
+
pendingCount: () => awaiters.size,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const plugin = {
|
|
196
|
+
id: "credence-pi",
|
|
197
|
+
name: "credence-pi governance",
|
|
198
|
+
description: "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.",
|
|
199
|
+
register(api) {
|
|
200
|
+
const cfg = api.pluginConfig ?? {};
|
|
201
|
+
const daemonUrl = typeof cfg.daemonUrl === "string" ? cfg.daemonUrl : DEFAULT_DAEMON_URL;
|
|
202
|
+
const hookTimeoutMs = typeof cfg.hookTimeoutMs === "number"
|
|
203
|
+
? cfg.hookTimeoutMs
|
|
204
|
+
: DEFAULT_HOOK_TIMEOUT_MS;
|
|
205
|
+
const approvalTimeoutMs = typeof cfg.approvalTimeoutMs === "number"
|
|
206
|
+
? cfg.approvalTimeoutMs
|
|
207
|
+
: DEFAULT_APPROVAL_TIMEOUT_MS;
|
|
208
|
+
const silent = cfg.silent === true;
|
|
209
|
+
const redactToolInputs = cfg.redactToolInputs === true;
|
|
210
|
+
const priceTable = buildPriceTable(cfg.pricing);
|
|
211
|
+
const log = (m, e) => {
|
|
212
|
+
if (silent)
|
|
213
|
+
return;
|
|
214
|
+
const msg = e === undefined ? m : `${m} ${String(e)}`;
|
|
215
|
+
api.logger?.warn?.(msg);
|
|
216
|
+
};
|
|
217
|
+
const client = createDaemonClient({
|
|
218
|
+
baseUrl: daemonUrl,
|
|
219
|
+
timeoutMs: hookTimeoutMs,
|
|
220
|
+
logger: log,
|
|
221
|
+
});
|
|
222
|
+
const gov = createGovernor(client, {
|
|
223
|
+
hookTimeoutMs,
|
|
224
|
+
approvalTimeoutMs,
|
|
225
|
+
priceTable,
|
|
226
|
+
redactToolInputs,
|
|
227
|
+
log,
|
|
228
|
+
});
|
|
229
|
+
api.on("before_tool_call", gov.beforeToolCall, {
|
|
230
|
+
priority: 100,
|
|
231
|
+
timeoutMs: hookTimeoutMs + 1_000,
|
|
232
|
+
});
|
|
233
|
+
api.on("after_tool_call", gov.afterToolCall);
|
|
234
|
+
// Per-turn cost REQUIRES plugins.entries.credence-pi.hooks
|
|
235
|
+
// .allowConversationAccess: true. Wrapped so a blocked registration
|
|
236
|
+
// never breaks governance — cost is just absent.
|
|
237
|
+
try {
|
|
238
|
+
api.on("llm_output", gov.llmOutput);
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
log("credence-pi: llm_output hook unavailable (set hooks.allowConversationAccess:true for the cost signal)", err);
|
|
242
|
+
}
|
|
243
|
+
// Close the SSE stream + drain awaiters on reset/delete/reload so a
|
|
244
|
+
// hot-reload does not accumulate daemon connections. Optional-chained
|
|
245
|
+
// for hosts predating the lifecycle API.
|
|
246
|
+
try {
|
|
247
|
+
api.lifecycle?.registerRuntimeLifecycle?.({
|
|
248
|
+
id: "credence-pi-governor",
|
|
249
|
+
description: "Close the credence-pi daemon SSE stream and drain pending tool-call awaiters.",
|
|
250
|
+
cleanup: () => gov.cleanup(),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
log("credence-pi: could not register lifecycle cleanup", err);
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
export default plugin;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export type PluginApprovalResolution = "allow-once" | "allow-always" | "deny" | "timeout" | "cancelled";
|
|
2
|
+
export interface RequireApprovalPayload {
|
|
3
|
+
title: string;
|
|
4
|
+
description: string;
|
|
5
|
+
severity?: "info" | "warning" | "critical";
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
timeoutBehavior?: "allow" | "deny";
|
|
8
|
+
allowedDecisions?: Array<"allow-once" | "allow-always" | "deny">;
|
|
9
|
+
pluginId?: string;
|
|
10
|
+
onResolution?: (decision: PluginApprovalResolution) => Promise<void> | void;
|
|
11
|
+
}
|
|
12
|
+
export interface BeforeToolCallEvent {
|
|
13
|
+
toolName: string;
|
|
14
|
+
params: Record<string, unknown>;
|
|
15
|
+
toolKind?: string;
|
|
16
|
+
toolInputKind?: string;
|
|
17
|
+
runId?: string;
|
|
18
|
+
toolCallId?: string;
|
|
19
|
+
derivedPaths?: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
export interface BeforeToolCallResult {
|
|
22
|
+
params?: Record<string, unknown>;
|
|
23
|
+
block?: boolean;
|
|
24
|
+
blockReason?: string;
|
|
25
|
+
requireApproval?: RequireApprovalPayload;
|
|
26
|
+
}
|
|
27
|
+
export interface AfterToolCallEvent {
|
|
28
|
+
toolName: string;
|
|
29
|
+
params: Record<string, unknown>;
|
|
30
|
+
runId?: string;
|
|
31
|
+
toolCallId?: string;
|
|
32
|
+
result?: unknown;
|
|
33
|
+
error?: string;
|
|
34
|
+
durationMs?: number;
|
|
35
|
+
}
|
|
36
|
+
export interface LlmUsage {
|
|
37
|
+
input?: number;
|
|
38
|
+
output?: number;
|
|
39
|
+
cacheRead?: number;
|
|
40
|
+
cacheWrite?: number;
|
|
41
|
+
total?: number;
|
|
42
|
+
}
|
|
43
|
+
export interface LlmOutputEvent {
|
|
44
|
+
runId?: string;
|
|
45
|
+
sessionId?: string;
|
|
46
|
+
provider?: string;
|
|
47
|
+
model?: string;
|
|
48
|
+
resolvedRef?: string;
|
|
49
|
+
usage?: LlmUsage;
|
|
50
|
+
}
|
|
51
|
+
export interface ToolContext {
|
|
52
|
+
agentId?: string;
|
|
53
|
+
sessionKey?: string;
|
|
54
|
+
sessionId?: string;
|
|
55
|
+
runId?: string;
|
|
56
|
+
toolName?: string;
|
|
57
|
+
toolCallId?: string;
|
|
58
|
+
channelId?: string;
|
|
59
|
+
workspaceDir?: string;
|
|
60
|
+
}
|
|
61
|
+
export interface PluginLogger {
|
|
62
|
+
info?: (msg: string) => void;
|
|
63
|
+
warn?: (msg: string) => void;
|
|
64
|
+
error?: (msg: string) => void;
|
|
65
|
+
}
|
|
66
|
+
export type HookHandler<E, R = void> = (event: E, ctx: ToolContext) => Promise<R | void> | R | void;
|
|
67
|
+
export interface HookOpts {
|
|
68
|
+
priority?: number;
|
|
69
|
+
timeoutMs?: number;
|
|
70
|
+
}
|
|
71
|
+
export interface OpenClawPluginApi {
|
|
72
|
+
id?: string;
|
|
73
|
+
logger?: PluginLogger;
|
|
74
|
+
pluginConfig?: Record<string, unknown>;
|
|
75
|
+
lifecycle?: {
|
|
76
|
+
registerRuntimeLifecycle: (registration: {
|
|
77
|
+
id: string;
|
|
78
|
+
description?: string;
|
|
79
|
+
cleanup?: (ctx: {
|
|
80
|
+
reason?: string;
|
|
81
|
+
sessionKey?: string;
|
|
82
|
+
runId?: string;
|
|
83
|
+
}) => void | Promise<void>;
|
|
84
|
+
}) => void;
|
|
85
|
+
};
|
|
86
|
+
on(hook: "before_tool_call", handler: HookHandler<BeforeToolCallEvent, BeforeToolCallResult>, opts?: HookOpts): void;
|
|
87
|
+
on(hook: "after_tool_call", handler: HookHandler<AfterToolCallEvent, void>, opts?: HookOpts): void;
|
|
88
|
+
on(hook: "llm_output", handler: HookHandler<LlmOutputEvent, void>, opts?: HookOpts): void;
|
|
89
|
+
on(hook: string, handler: (...args: unknown[]) => unknown, opts?: HookOpts): void;
|
|
90
|
+
}
|
|
91
|
+
export interface PluginEntry {
|
|
92
|
+
id: string;
|
|
93
|
+
name: string;
|
|
94
|
+
description?: string;
|
|
95
|
+
register: (api: OpenClawPluginApi) => void | Promise<void>;
|
|
96
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// openclaw-types.ts — minimal local declarations of the OpenClaw plugin
|
|
2
|
+
// API surface this body consumes.
|
|
3
|
+
//
|
|
4
|
+
// Sourced from OpenClaw (HEAD 36a596aa9f, 2026-06-02):
|
|
5
|
+
// - src/plugins/types.ts (OpenClawPluginApi, api.on)
|
|
6
|
+
// - src/plugins/hook-types.ts (before_tool_call / after_tool_call /
|
|
7
|
+
// llm_output events + results)
|
|
8
|
+
//
|
|
9
|
+
// We declare ONLY what we consume so the plugin builds standalone with no
|
|
10
|
+
// @openclaw/openclaw dependency — mirroring the dependency-free pattern of
|
|
11
|
+
// apps/openclaw-plugin/ (which used `api: any`). At runtime OpenClaw calls
|
|
12
|
+
// register(api) with the real, fully-typed api; these declarations are our
|
|
13
|
+
// compile-time view. If the host surface drifts, the runtime still works;
|
|
14
|
+
// only this file needs occasional resync. Keep it narrow.
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "credence-pi",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"name": "credence-pi governance",
|
|
5
|
+
"description": "Bayesian in-loop governance for the pi/OpenClaw agent — intercepts tool calls and halts/asks on low expected-value spend; logs outcomes and per-turn cost for the dollars-saved surface.",
|
|
6
|
+
"activation": {
|
|
7
|
+
"onStartup": true
|
|
8
|
+
},
|
|
9
|
+
"configSchema": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"additionalProperties": false,
|
|
12
|
+
"properties": {
|
|
13
|
+
"daemonUrl": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"description": "Base URL of the credence-pi Julia daemon (POST /sensor, GET /signals).",
|
|
16
|
+
"default": "http://127.0.0.1:8787"
|
|
17
|
+
},
|
|
18
|
+
"hookTimeoutMs": {
|
|
19
|
+
"type": "number",
|
|
20
|
+
"description": "Max time to await the daemon's decision before failing open (proceeding without governance).",
|
|
21
|
+
"default": 3000
|
|
22
|
+
},
|
|
23
|
+
"approvalTimeoutMs": {
|
|
24
|
+
"type": "number",
|
|
25
|
+
"description": "How long OpenClaw waits for the user on an `ask` (requireApproval) before applying timeoutBehavior=deny.",
|
|
26
|
+
"default": 120000
|
|
27
|
+
},
|
|
28
|
+
"redactToolInputs": {
|
|
29
|
+
"type": "boolean",
|
|
30
|
+
"description": "Omit tool-call inputs from sensor events sent to the daemon (they can carry secrets/commands). Governance still works; the ask-dialog preview becomes generic.",
|
|
31
|
+
"default": false
|
|
32
|
+
},
|
|
33
|
+
"silent": {
|
|
34
|
+
"type": "boolean",
|
|
35
|
+
"description": "Suppress info/warn logging.",
|
|
36
|
+
"default": false
|
|
37
|
+
},
|
|
38
|
+
"pricing": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"description": "Optional per-model USD price overrides in $/million tokens: { \"<model-id>\": { \"input\": <num>, \"output\": <num>, \"cacheRead\": <num>, \"cacheWrite\": <num> } }. Merged over the built-in table.",
|
|
41
|
+
"additionalProperties": true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gfrmin/credence-pi-openclaw",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "credence-pi body: OpenClaw plugin that governs tool calls via the credence-pi Bayesian brain (before_tool_call -> allow/block/ask) and logs outcomes + per-turn cost.",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"license": "AGPL-3.0-or-later",
|
|
9
|
+
"author": "Guy Freeman",
|
|
10
|
+
"homepage": "https://github.com/gfrmin/credence/tree/master/apps/credence-pi",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/gfrmin/credence.git",
|
|
14
|
+
"directory": "apps/credence-pi/openclaw-plugin"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"openclaw",
|
|
18
|
+
"openclaw-plugin",
|
|
19
|
+
"ai-agent",
|
|
20
|
+
"governance",
|
|
21
|
+
"bayesian",
|
|
22
|
+
"tool-call",
|
|
23
|
+
"llm-cost",
|
|
24
|
+
"expected-utility"
|
|
25
|
+
],
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"src",
|
|
29
|
+
"openclaw.plugin.json",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"openclaw": {
|
|
33
|
+
"extensions": ["./src/index.ts"],
|
|
34
|
+
"runtimeExtensions": ["./dist/index.js"],
|
|
35
|
+
"compat": {
|
|
36
|
+
"pluginApi": ">=2026.3.24-beta.2",
|
|
37
|
+
"minGatewayVersion": "2026.3.24-beta.2"
|
|
38
|
+
},
|
|
39
|
+
"build": {
|
|
40
|
+
"openclawVersion": "2026.6.2",
|
|
41
|
+
"pluginSdkVersion": "2026.3.24-beta.2"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsc",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"test": "node --import tsx --test tests/*.test.ts"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"typescript": "^5.5.0",
|
|
52
|
+
"@types/node": "^22.0.0",
|
|
53
|
+
"tsx": "^4.19.0"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=22"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/cost.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
|
|
13
|
+
import type { LlmOutputEvent } from "./openclaw-types.js";
|
|
14
|
+
|
|
15
|
+
export interface ModelPrice {
|
|
16
|
+
input: number; // USD / million input tokens
|
|
17
|
+
output: number; // USD / million output tokens
|
|
18
|
+
cacheRead?: number; // USD / million cache-read tokens
|
|
19
|
+
cacheWrite?: number; // USD / million cache-write tokens
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type PriceTable = Record<string, ModelPrice>;
|
|
23
|
+
|
|
24
|
+
// Built-in defaults, keyed by a lowercase family substring matched against
|
|
25
|
+
// the model id. Approximate; override via config `pricing`. Ordered by
|
|
26
|
+
// specificity is not required — we pick the longest matching key.
|
|
27
|
+
export const DEFAULT_PRICES: PriceTable = {
|
|
28
|
+
"claude-opus": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
|
|
29
|
+
"claude-sonnet": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
30
|
+
"claude-haiku": { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
|
|
31
|
+
"gpt-4o-mini": { input: 0.15, output: 0.6 },
|
|
32
|
+
"gpt-4o": { input: 2.5, output: 10 },
|
|
33
|
+
"gpt-4.1": { input: 2, output: 8 },
|
|
34
|
+
"o3": { input: 2, output: 8 },
|
|
35
|
+
"gemini-2.5-pro": { input: 1.25, output: 10 },
|
|
36
|
+
"gemini-2.5-flash": { input: 0.3, output: 2.5 },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface TurnCost {
|
|
40
|
+
usd: number | null;
|
|
41
|
+
total_tokens: number | null;
|
|
42
|
+
input_tokens: number | null;
|
|
43
|
+
output_tokens: number | null;
|
|
44
|
+
cache_read: number | null;
|
|
45
|
+
cache_write: number | null;
|
|
46
|
+
model: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function escapeRegExp(s: string): string {
|
|
50
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolvePrice(
|
|
54
|
+
model: string | undefined,
|
|
55
|
+
table: PriceTable,
|
|
56
|
+
): ModelPrice | undefined {
|
|
57
|
+
if (!model) return undefined;
|
|
58
|
+
const m = model.toLowerCase();
|
|
59
|
+
// Exact match first.
|
|
60
|
+
if (table[m]) return table[m];
|
|
61
|
+
// Longest key that appears as a SEGMENT of the id — anchored at the
|
|
62
|
+
// start or after a non-alphanumeric separator. So "o3" matches
|
|
63
|
+
// "o3-mini" and "openai/o3" but NOT "foo3"; longest match wins.
|
|
64
|
+
let best: { key: string; price: ModelPrice } | undefined;
|
|
65
|
+
for (const [key, price] of Object.entries(table)) {
|
|
66
|
+
const re = new RegExp(`(^|[^a-z0-9])${escapeRegExp(key)}`);
|
|
67
|
+
if (re.test(m) && (best === undefined || key.length > best.key.length)) {
|
|
68
|
+
best = { key, price };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return best?.price;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Merge operator `pricing` config over the built-in table. */
|
|
75
|
+
export function buildPriceTable(overrides: unknown): PriceTable {
|
|
76
|
+
const table: PriceTable = { ...DEFAULT_PRICES };
|
|
77
|
+
if (overrides && typeof overrides === "object") {
|
|
78
|
+
for (const [k, v] of Object.entries(overrides as Record<string, unknown>)) {
|
|
79
|
+
if (v && typeof v === "object") {
|
|
80
|
+
const o = v as Record<string, unknown>;
|
|
81
|
+
const input = typeof o.input === "number" ? o.input : undefined;
|
|
82
|
+
const output = typeof o.output === "number" ? o.output : undefined;
|
|
83
|
+
if (input !== undefined && output !== undefined) {
|
|
84
|
+
table[k.toLowerCase()] = {
|
|
85
|
+
input,
|
|
86
|
+
output,
|
|
87
|
+
cacheRead: typeof o.cacheRead === "number" ? o.cacheRead : undefined,
|
|
88
|
+
cacheWrite:
|
|
89
|
+
typeof o.cacheWrite === "number" ? o.cacheWrite : undefined,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return table;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function computeTurnCost(
|
|
99
|
+
event: LlmOutputEvent,
|
|
100
|
+
table: PriceTable,
|
|
101
|
+
): TurnCost {
|
|
102
|
+
const u = event.usage ?? {};
|
|
103
|
+
const input = u.input ?? 0;
|
|
104
|
+
const output = u.output ?? 0;
|
|
105
|
+
const cacheRead = u.cacheRead ?? 0;
|
|
106
|
+
const cacheWrite = u.cacheWrite ?? 0;
|
|
107
|
+
const total = u.total ?? input + output + cacheRead + cacheWrite;
|
|
108
|
+
const model = event.model ?? null;
|
|
109
|
+
|
|
110
|
+
const price = resolvePrice(event.model, table);
|
|
111
|
+
let usd: number | null = null;
|
|
112
|
+
if (price) {
|
|
113
|
+
usd =
|
|
114
|
+
(input * price.input +
|
|
115
|
+
output * price.output +
|
|
116
|
+
cacheRead * (price.cacheRead ?? price.input) +
|
|
117
|
+
cacheWrite * (price.cacheWrite ?? price.input)) /
|
|
118
|
+
1_000_000;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
usd,
|
|
123
|
+
total_tokens: total,
|
|
124
|
+
input_tokens: input,
|
|
125
|
+
output_tokens: output,
|
|
126
|
+
cache_read: cacheRead,
|
|
127
|
+
cache_write: cacheWrite,
|
|
128
|
+
model,
|
|
129
|
+
};
|
|
130
|
+
}
|