@percepteye/agent-flywheel 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/src/http.js ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * The one HTTP client, with retry semantics that READ THE RESPONSE.
3
+ *
4
+ * A port of `agent_flywheel/_http.py`, kept deliberately
5
+ * line-for-line in behaviour because the two are one contract with one server.
6
+ * Its docstring records what it was written against: a previous SDK whose
7
+ * retry helper returned the response without reading its status -- so a 503
8
+ * was indistinguishable from success -- and whose only recovery from an
9
+ * unexpected error was a blanket sleep inside a bare `except` that also
10
+ * swallowed real bugs.
11
+ *
12
+ * Zero dependencies. `fetch` and `AbortController` are builtins in Node 22+,
13
+ * which `engines` already requires, so this adds nothing to a customer's tree.
14
+ */
15
+ import { readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+
18
+ import { CONTRACT_VERSION } from "./wire.js";
19
+ import { LeaseLost, TransportError } from "./errors.js";
20
+
21
+ /** Transient. Retry these, honouring Retry-After when present. */
22
+ const RETRY_STATUS = new Set([408, 429, 500, 502, 503, 504]);
23
+
24
+ /**
25
+ * Node's fetch rejects with a TypeError whose `cause.code` names the syscall
26
+ * failure. These are the codes where the connection was NEVER ESTABLISHED, so
27
+ * the server cannot have seen the request.
28
+ *
29
+ * A TIMEOUT IS NOT IN THIS SET, and that is the whole point of the
30
+ * distinction: a read timeout may have been fully processed by the server, so
31
+ * resending it performs the operation twice. Python names the same split with
32
+ * `_NEVER_SENT = (httpx.ConnectError, httpx.ConnectTimeout)`.
33
+ */
34
+ const NEVER_SENT_CODES = new Set([
35
+ "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ECONNRESET",
36
+ "EHOSTUNREACH", "ENETUNREACH", "EADDRNOTAVAIL",
37
+ ]);
38
+
39
+ /**
40
+ * ONE version, read from the file npm publishes it in.
41
+ *
42
+ * This was a hand-typed literal beside two other copies of the same number
43
+ * (package.json and openclaw.plugin.json). `npm version patch` rewrites
44
+ * package.json and nothing else, so the literal would go on announcing the
45
+ * previous release to the control plane and a bug reported against 0.1.1
46
+ * would be attributed server-side to 0.1.0. npm puts package.json in every
47
+ * tarball whether or not `files` names it, so this read cannot miss.
48
+ *
49
+ * The manifest's copy cannot be derived -- the host reads it as static JSON
50
+ * before any of this runs -- so it is held equal by a test instead
51
+ * (test/version.test.js).
52
+ */
53
+ export const SDK_VERSION = JSON.parse(
54
+ readFileSync(join(import.meta.dirname, "..", "package.json"), "utf8"),
55
+ ).version;
56
+
57
+ export const SDK_UA = `agent-flywheel-node/${SDK_VERSION}`;
58
+
59
+ function neverSent(err) {
60
+ // An AbortError is our own timeout firing. The request was in flight; the
61
+ // server may have completed it. Ambiguous, never "never sent".
62
+ if (err?.name === "AbortError" || err?.name === "TimeoutError") return false;
63
+ const code = err?.cause?.code ?? err?.code;
64
+ return typeof code === "string" && NEVER_SENT_CODES.has(code);
65
+ }
66
+
67
+ /**
68
+ * Exponential with FULL JITTER, capped at 8s. The jitter is load-bearing: a
69
+ * fleet of agents that all retry on the same schedule reconverges into exactly
70
+ * the spike that caused the backoff.
71
+ */
72
+ function backoffMs(attempt) {
73
+ return Math.random() * Math.min(8000, 500 * 2 ** (attempt - 1));
74
+ }
75
+
76
+ function retryAfterMs(response) {
77
+ const raw = response.headers.get("retry-after");
78
+ if (!raw) return null;
79
+ const seconds = Number(raw);
80
+ return Number.isFinite(seconds) ? Math.max(0, seconds * 1000) : null;
81
+ }
82
+
83
+ async function detail(response) {
84
+ let text = "";
85
+ try {
86
+ text = await response.text();
87
+ } catch {
88
+ return "";
89
+ }
90
+ try {
91
+ const body = JSON.parse(text);
92
+ if (body && typeof body === "object" && !Array.isArray(body)) {
93
+ for (const key of ["detail", "error", "message", "reason"]) {
94
+ if (key in body) return String(body[key]).slice(0, 300);
95
+ }
96
+ }
97
+ return String(text).slice(0, 300);
98
+ } catch {
99
+ return text.slice(0, 300);
100
+ }
101
+ }
102
+
103
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
104
+
105
+ export class ControlPlaneClient {
106
+ /**
107
+ * @param {string} baseUrl
108
+ * @param {string} apiKey
109
+ * @param {{timeoutMs?: number, maxAttempts?: number, fetchImpl?: Function}} [opts]
110
+ */
111
+ constructor(baseUrl, apiKey, opts = {}) {
112
+ if (!baseUrl) throw new TransportError("control-plane base URL is required");
113
+ if (!apiKey) {
114
+ // NO "dev-key" DEFAULT. A previous SDK defaulted a missing key to a
115
+ // literal placeholder, so a misconfigured deployment ran happily and
116
+ // reported nothing at all.
117
+ throw new TransportError(
118
+ "an API key is required; refusing to start with no credential",
119
+ );
120
+ }
121
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
122
+ this._timeoutMs = opts.timeoutMs ?? 30_000;
123
+ this._maxAttempts = Math.max(1, opts.maxAttempts ?? 4);
124
+ this._fetch = opts.fetchImpl ?? globalThis.fetch;
125
+ this._headers = {
126
+ Authorization: `Bearer ${apiKey}`,
127
+ "Contract-Version": CONTRACT_VERSION,
128
+ "X-Percepteye-Sdk": SDK_UA,
129
+ Accept: "application/json",
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Send one request, retrying transient failures.
135
+ *
136
+ * `retryIfAmbiguous: false` narrows retries to failures where the request
137
+ * PROVABLY never reached the server. Use it for any call whose server side
138
+ * has a side effect and no replay protection -- retrying a request that MAY
139
+ * have been processed performs it twice.
140
+ */
141
+ async request(method, path, opts = {}) {
142
+ const {
143
+ jsonBody, idempotencyKey, timeoutMs, retryIfAmbiguous = true,
144
+ headers: requestHeaders = null,
145
+ } = opts;
146
+ const url = `${this.baseUrl}${path}`;
147
+ const headers = { ...this._headers };
148
+
149
+ // Per-request evidence such as the current rollout lease belongs beside
150
+ // the client-owned authentication and contract headers, never in place of
151
+ // them. Header names are case-insensitive, so protect the whole set by its
152
+ // lowercase spelling rather than only the exact casing used above.
153
+ const protectedHeaders = new Set([
154
+ ...Object.keys(this._headers).map((name) => name.toLowerCase()),
155
+ "content-type",
156
+ "idempotency-key",
157
+ ]);
158
+ if (requestHeaders !== null && requestHeaders !== undefined) {
159
+ if (
160
+ typeof requestHeaders !== "object"
161
+ || Array.isArray(requestHeaders)
162
+ ) {
163
+ throw new TransportError("request headers must be an object");
164
+ }
165
+ for (const [name, value] of Object.entries(requestHeaders)) {
166
+ if (protectedHeaders.has(name.toLowerCase())) {
167
+ throw new TransportError(
168
+ `request header ${JSON.stringify(name)} is owned by the control-plane client`,
169
+ );
170
+ }
171
+ if (typeof value !== "string") {
172
+ throw new TransportError(
173
+ `request header ${JSON.stringify(name)} must be a string`,
174
+ );
175
+ }
176
+ headers[name] = value;
177
+ }
178
+ }
179
+ if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
180
+ if (jsonBody !== undefined) headers["Content-Type"] = "application/json";
181
+
182
+ let lastErr = null;
183
+
184
+ for (let attempt = 1; attempt <= this._maxAttempts; attempt += 1) {
185
+ const controller = new AbortController();
186
+ const timer = setTimeout(
187
+ () => controller.abort(),
188
+ timeoutMs ?? this._timeoutMs,
189
+ );
190
+ let response;
191
+ try {
192
+ response = await this._fetch(url, {
193
+ method,
194
+ headers,
195
+ body: jsonBody === undefined ? undefined : JSON.stringify(jsonBody),
196
+ signal: controller.signal,
197
+ });
198
+ } catch (err) {
199
+ lastErr = `${err?.name ?? "Error"}: ${err?.message ?? err}`;
200
+ if (attempt === this._maxAttempts || !(retryIfAmbiguous || neverSent(err))) break;
201
+ await sleep(backoffMs(attempt));
202
+ continue;
203
+ } finally {
204
+ clearTimeout(timer);
205
+ }
206
+
207
+ if (response.status === 410) {
208
+ throw new LeaseLost((await detail(response)) || "lease lost");
209
+ }
210
+
211
+ // 429 retries EVEN WHEN ambiguous retries are off: it is the server
212
+ // explicitly telling us it did not do the work. Python has the same
213
+ // asymmetry (`status == 429 or (status in RETRY and retry_if_ambiguous)`).
214
+ if (response.status === 429
215
+ || (RETRY_STATUS.has(response.status) && retryIfAmbiguous)) {
216
+ lastErr = `HTTP ${response.status}: ${await detail(response)}`;
217
+ if (attempt === this._maxAttempts) break;
218
+ await sleep(retryAfterMs(response) ?? backoffMs(attempt));
219
+ continue;
220
+ }
221
+
222
+ if (response.status >= 400) {
223
+ // Terminal. A 4xx that is not 408/429 will not become a 2xx by being
224
+ // sent again; retrying only delays the error.
225
+ throw new TransportError(
226
+ `${method} ${path} -> HTTP ${response.status}: ${await detail(response)}`,
227
+ );
228
+ }
229
+
230
+ const text = await response.text();
231
+ if (!text) return {};
232
+ let body;
233
+ try {
234
+ body = JSON.parse(text);
235
+ } catch (err) {
236
+ throw new TransportError(
237
+ `${method} ${path} -> HTTP ${response.status} with unparseable body: ${err.message}`,
238
+ );
239
+ }
240
+ return body && typeof body === "object" && !Array.isArray(body)
241
+ ? body
242
+ : { data: body };
243
+ }
244
+
245
+ throw new TransportError(
246
+ `${method} ${path} failed after ${this._maxAttempts} attempts: ${lastErr}`,
247
+ );
248
+ }
249
+ }
package/src/index.js ADDED
@@ -0,0 +1,380 @@
1
+ /**
2
+ * agent-flywheel — an OpenClaw plugin that records tool-call outcomes.
3
+ *
4
+ * WHY THIS HOOK. OpenClaw exposes three places to see a tool result, and only
5
+ * one of them is both complete and incapable of changing the run:
6
+ *
7
+ * registerAgentToolResultMiddleware
8
+ * A waterfall. Its return value REPLACES the result the model reads --
9
+ * that is what the bundled output-compacting plugin uses it for. A bug
10
+ * here would silently rewrite a customer's agent output. It is also
11
+ * manifest-gated and refused unless the plugin is explicitly enabled.
12
+ * Rejected: a recorder must not sit on a mutation seam.
13
+ *
14
+ * registerAgentEventSubscription (streams: ["tool"])
15
+ * A genuine observer -- the host `structuredClone`s the event and discards
16
+ * the return. But it is a UI/monitor stream: `data` is untyped, the
17
+ * arguments arrive on a separate `phase:"start"` event, and exec results
18
+ * are capped before dispatch. Correlating the two phases needs a pending
19
+ * map that either leaks or drops.
20
+ *
21
+ * api.on("after_tool_call", ...) <-- what we use
22
+ * Also void-returning, so structurally incapable of altering, blocking or
23
+ * delaying anything; the host runs it best-effort inside its own
24
+ * try/catch. And it carries the whole call in one event: `params`,
25
+ * `result`, `error`, `durationMs`, `toolCallId`, plus agent and session
26
+ * identity on the context.
27
+ *
28
+ * The rule behind the choice: capturing evidence must never be able to change
29
+ * the behaviour being measured. A collector that can corrupt the run it is
30
+ * measuring is worse than no collector.
31
+ *
32
+ * Zero dependencies.
33
+ *
34
+ * WHAT THIS WRITES, PER MODE. This used to read "inert unless
35
+ * PERCEPTEYE_TRAJECTORY_DIR is set, so one install is a no-op in production and
36
+ * a recorder during a rollout". Once production mode gained turn capture that
37
+ * was not merely stale, it was INVERTED -- production became the mode that
38
+ * writes unconditionally -- and what it understated was the recording of
39
+ * somebody's END USERS' message text. The truth:
40
+ *
41
+ * training tool outcomes go to the rollout's PERCEPTEYE_TRAJECTORY_DIR.
42
+ * With the variable unset nothing is written.
43
+ * production every served turn -- the user's message, the tool calls, and
44
+ * (with allowConversationAccess) the agent's answer -- is written
45
+ * under PERCEPTEYE_CAPTURE_DIR, default ~/.percepteye/captured,
46
+ * whether or not PERCEPTEYE_TRAJECTORY_DIR is set. Nothing is
47
+ * uploaded without a key AND the control plane's per-agent
48
+ * consent, but it is on disk either way.
49
+ *
50
+ * The off switch is `capture: false` / PERCEPTEYE_CAPTURE=0, which returns from
51
+ * `register` before any hook is subscribed, in either mode.
52
+ *
53
+ * WHAT PRODUCTION MODE ALSO DOES, and it is not observation. It applies BOTH
54
+ * halves of what the control plane has APPROVED for this agent:
55
+ *
56
+ * the system prompt `{systemPrompt}` returned from `before_prompt_build`,
57
+ * a full replacement of the prompt the host assembled.
58
+ * the model `{providerOverride, modelOverride}` returned from
59
+ * `before_model_resolve`, plus a registered provider
60
+ * whose `normalizeTransport` supplies the base URL. That
61
+ * hook is CONVERSATION-gated, so it needs the same
62
+ * `allowConversationAccess=true` opt-in that capturing
63
+ * the agent's answer needs.
64
+ *
65
+ * They are a certified PAIR, which is why they are applied together and why
66
+ * applying one alone is reported as such. That is the last mile of the loop
67
+ * this package exists to close, and the only thing here that changes what the
68
+ * agent does rather than recording it. `policy.js` holds both decisions, the
69
+ * pair check, the per-agent scope, and the two off switches --
70
+ * `applyPrompt: false` / PERCEPTEYE_APPLY_PROMPT=0 and `applyModel: false` /
71
+ * PERCEPTEYE_APPLY_MODEL=0.
72
+ */
73
+ import { createRecorder } from "./record.js";
74
+ import { createWriter, TRAJECTORY_DIR_ENV } from "./trajectory.js";
75
+ import { registerDescriber, DESCRIBE_HOOK_NAME } from "./describe.js";
76
+ import { CONFIG_SCHEMA, resolveConfig } from "./config.js";
77
+ import { ControlPlaneClient } from "./http.js";
78
+ import { AttachTransport } from "./transport.js";
79
+ import { PRODUCTION, TRAINING } from "./mode.js";
80
+ import { ConfigurationError } from "./errors.js";
81
+ import { registerRolloutDriver } from "./rollout.js";
82
+ import { PENDING_ANSWER_REASON, registerServingPolicy } from "./policy.js";
83
+ import { conversationAccessGranted } from "./host.js";
84
+ import {
85
+ captureRoot, createTurnCapture, registerTurnCapture,
86
+ } from "./capture.js";
87
+ import { createExecutionIdentityTracker } from "./execution-identity.js";
88
+
89
+ export const PLUGIN_ID = "agent-flywheel";
90
+ export const HOOK_NAME = "after_tool_call";
91
+ export { DESCRIBE_HOOK_NAME };
92
+
93
+
94
+ export function register(api, pluginConfig = {}, env = process.env) {
95
+ if (typeof api?.on !== "function") return null;
96
+
97
+ const logger = api.logger ?? api.runtime?.logger ?? null;
98
+
99
+ // THE KILL SWITCH, honoured. `openclaw.plugin.json` promises "Set
100
+ // false/0/off to disable local capture entirely", and `resolveConfig`
101
+ // computed `captureEnabled` for exactly that -- but nothing read it, and
102
+ // `trajectory.js` records on the trajectory dir alone. A customer who set
103
+ // PERCEPTEYE_CAPTURE=0 kept being recorded, which is the worst kind of
104
+ // defect: a promise about somebody's data that the code does not keep.
105
+ //
106
+ // `pluginConfig` is a SECOND parameter with a default rather than a
107
+ // required one, so this works whether or not the host passes config to
108
+ // register(). Where it does, config wins; where it does not, the
109
+ // environment still switches capture off. A fix that only worked once we
110
+ // learned the host's calling convention would leave the promise broken in
111
+ // the meantime.
112
+ // `env` is a parameter, not a read of the global, so the gate is testable
113
+ // without mutating process.env under a parallel test runner -- and it
114
+ // matches resolveConfig's own signature rather than inventing a second
115
+ // convention for the same thing.
116
+ // WHERE THE HOST ACTUALLY PUTS CONFIG: `api.pluginConfig`
117
+ // (OpenClaw 2026.7.1-2, dist/types-DaHgOqFX.d.ts:12076 declares
118
+ // `pluginConfig?: Record<string, unknown>` on the plugin api). The loader
119
+ // validates `plugins.entries.<id>.config` against the MANIFEST's
120
+ // configSchema and hands the result over on the api object -- it does NOT
121
+ // pass it as a second argument to register().
122
+ //
123
+ // Reading only an argument therefore honoured PERCEPTEYE_CAPTURE from the
124
+ // environment and IGNORED `capture: false` in the customer's OpenClaw
125
+ // config: half the promise, and the half the manifest documents first. The
126
+ // explicit parameter is kept because it is what makes this gate testable
127
+ // without constructing a whole host api.
128
+ const supplied = Object.keys(pluginConfig).length
129
+ ? pluginConfig
130
+ : (api.pluginConfig ?? {});
131
+ let config;
132
+ try {
133
+ config = resolveConfig(supplied, env);
134
+ } catch (err) {
135
+ if (!(err instanceof ConfigurationError)) throw err;
136
+ // AN UNREADABLE MODE DEGRADES TO PRODUCTION, LOUDLY. It does not throw
137
+ // and it does not fall back to `training`, and both of those matter.
138
+ //
139
+ // Throwing is what the Python SDK does, and it is right THERE: `serve()`
140
+ // is a process entry point, so refusing to start is the whole remedy. Here
141
+ // the same throw would fail the plugin load and take LOCAL CAPTURE down
142
+ // with it -- the one feature that works with no account, no key and no
143
+ // control plane -- over a typo in a setting it does not depend on.
144
+ //
145
+ // Falling back to `training` would be worse than either: someone typing
146
+ // `PERCEPTEYE_AGENT_MODE=prodction` is trying to STOP contributing, and a
147
+ // default that resumes claiming rollouts on their credentials is the exact
148
+ // failure the closed alias set exists to prevent. Production is the safe
149
+ // side of a mode we could not read: the worst case is that this install
150
+ // does less than intended, and says so.
151
+ config = resolveConfig({ ...supplied, mode: PRODUCTION }, env);
152
+ logger?.warn?.(
153
+ `[${PLUGIN_ID}] ${err.message} Running in ${PRODUCTION} mode until ` +
154
+ `this is corrected; no rollouts will be claimed. Note that production ` +
155
+ `mode is not inert: it captures served turns and applies the approved ` +
156
+ `system prompt (PERCEPTEYE_CAPTURE=0 / PERCEPTEYE_APPLY_PROMPT=0).`,
157
+ );
158
+ }
159
+ if (!config.captureEnabled) {
160
+ logger?.info?.(
161
+ `[${PLUGIN_ID}] capture is disabled by configuration; recording nothing. ` +
162
+ `Unset PERCEPTEYE_CAPTURE (or plugins.entries.${PLUGIN_ID}.config.capture) ` +
163
+ `to re-enable.`,
164
+ );
165
+ return null;
166
+ }
167
+ const executionIdentity = createExecutionIdentityTracker({
168
+ executionSnapshot: config.executionSnapshot,
169
+ mode: config.mode,
170
+ agentId: config.agentId,
171
+ });
172
+ if (config.executionSnapshot !== null && !executionIdentity.enabled) {
173
+ logger?.warn?.(
174
+ `[${PLUGIN_ID}] exact execution identity is disabled: ` +
175
+ `${executionIdentity.refusal}. Capture continues without an affirmative ` +
176
+ `execution fingerprint.`,
177
+ );
178
+ }
179
+ // ── WHERE TOOL CALLS LAND, decided by mode ────────────────────────────
180
+ //
181
+ // In TRAINING mode a trajectory belongs to one ROLLOUT, and the rollout
182
+ // driver names the directory through PERCEPTEYE_TRAJECTORY_DIR. Unchanged.
183
+ //
184
+ // In PRODUCTION mode there is no rollout: a trajectory belongs to one TURN,
185
+ // so each call is routed to its own turn directory by `runId`. The env var
186
+ // is not consulted there, because a single shared directory would merge
187
+ // every end user's calls into one trajectory -- and `PERCEPTEYE_AGENT_MODE`
188
+ // is the authority on which of the two this process is.
189
+ const turns = config.mode === PRODUCTION
190
+ ? createTurnCapture({ root: captureRoot(env), logger })
191
+ : null;
192
+ const writer = createWriter({
193
+ resolveDir: turns ? turns.resolveToolDir : null,
194
+ onError: (err) => logger?.warn?.(
195
+ `[${PLUGIN_ID}] trajectory write failed; tool outcomes are not being ` +
196
+ `recorded for this run: ${err?.message ?? err}`,
197
+ ),
198
+ });
199
+ const recorder = createRecorder({ writer });
200
+
201
+ api.on(HOOK_NAME, (event, ctx) => {
202
+ // The host already isolates hook failures, but it logs a warning per
203
+ // failure. Swallowing here keeps a malformed event from filling a
204
+ // customer's logs with our name -- one lost record, never a failed run,
205
+ // never a noisy one.
206
+ try {
207
+ recorder.handle(event, ctx ?? {});
208
+ } catch {
209
+ /* one lost record is not worth an interrupted agent */
210
+ }
211
+ });
212
+
213
+ // ── THE MODE SPLIT, STRUCTURALLY ──────────────────────────────────────
214
+ //
215
+ // In production mode the rollout driver is NOT CONSTRUCTED and its three
216
+ // hooks are NOT SUBSCRIBED. That is what makes "claims no work, starts no
217
+ // turn" a property of this file rather than a promise each handler keeps:
218
+ // there is no code path from a production install to a claim, an injection
219
+ // or a report, because the module that could do those things is never
220
+ // reached. A `if (mode === "training")` inside each handler would be the
221
+ // same behaviour with a worse failure mode -- one missed call site is a
222
+ // silent breach instead of a hook that simply is not there.
223
+ //
224
+ // Local capture above is UNAFFECTED by mode. It never leaves the machine,
225
+ // and it is the plugin's day-one value with no account at all.
226
+ let rollouts = null;
227
+ let capture = null;
228
+ let serving = null;
229
+ if (config.mode === TRAINING) {
230
+ // WRAPPED, because the host requires `register` to be SYNCHRONOUS and
231
+ // treats a throw here as a failed plugin load (loader-D8d2EvVh.js:762).
232
+ // `new ControlPlaneClient` refuses a missing key or base URL by design, so
233
+ // a misconfigured control plane would otherwise take the RECORDER down
234
+ // with it -- losing the local capture that works without any of this.
235
+ try {
236
+ rollouts = registerRolloutDriver(api, {
237
+ config,
238
+ pluginId: PLUGIN_ID,
239
+ logger,
240
+ transport: config.apiKey
241
+ ? new AttachTransport(
242
+ new ControlPlaneClient(config.controlPlaneUrl, config.apiKey),
243
+ { agentId: config.agentId },
244
+ )
245
+ : null,
246
+ executionIdentity,
247
+ });
248
+ } catch (err) {
249
+ logger?.warn?.(
250
+ `[${PLUGIN_ID}] rollouts are disabled for this run: ` +
251
+ `${err?.message ?? err}. Local capture is unaffected.`,
252
+ );
253
+ }
254
+ } else {
255
+ // WHAT PRODUCTION MODE PROMISES, said exactly, and CONDITIONAL ON WHAT
256
+ // WILL ACTUALLY HAPPEN. This used to end "and no prompt is injected --
257
+ // those hooks are not registered at all", which stopped being true the
258
+ // moment `registerServingPolicy` below started subscribing
259
+ // `before_prompt_build`. Then it promised the opposite unconditionally --
260
+ // "It DOES ... apply the system prompt your control plane has approved" --
261
+ // and on a keyless install, which is the ordinary first install, that was
262
+ // false in the other direction: with no key there is no transport,
263
+ // `registerServingPolicy` subscribes nothing, and the operator was told
264
+ // otherwise by the line immediately before the silence.
265
+ //
266
+ // So the claim is READ OFF THE SOURCE, after it has wired itself, rather
267
+ // than re-derived here. It used to be computed from three facts -- a key,
268
+ // the kill switch, the conversation opt-in -- and there are more than
269
+ // three: a host that has no provider surface, or that refuses the provider
270
+ // id, rules the model half out too, and only `registerServingPolicy` knows
271
+ // that. A predicate assembled here from a SUBSET of the source's own
272
+ // inputs is a second answer that drifts the moment a cause is added.
273
+ const conversationAccess = conversationAccessGranted(api, PLUGIN_ID);
274
+ capture = registerTurnCapture(api, {
275
+ config, turns, pluginId: PLUGIN_ID, logger, env,
276
+ });
277
+ // THE LAST MILE. `capture` owns the one control-plane client, so this
278
+ // reuses it rather than building a second against the same key -- and
279
+ // waits on the registration chain rather than racing it.
280
+ serving = registerServingPolicy(api, {
281
+ config,
282
+ pluginId: PLUGIN_ID,
283
+ logger,
284
+ transport: capture?.transport ?? null,
285
+ after: capture?.started ?? null,
286
+ // The host's own config, for the agent scope, and the host's own answer
287
+ // about whether it will deliver `before_model_resolve` at all.
288
+ hostConfig: api?.config,
289
+ conversationAccess,
290
+ });
291
+ // PENDING is the only value either half can hold here that is not a
292
+ // settled refusal: the read has not happened yet, so anything else is a
293
+ // cause that has already ruled that half out.
294
+ const applying = [
295
+ serving.refusal === PENDING_ANSWER_REASON ? "the system prompt" : null,
296
+ serving.modelRefusal === PENDING_ANSWER_REASON ? "the model" : null,
297
+ ].filter(Boolean);
298
+ logger?.info?.(
299
+ `[${PLUGIN_ID}] mode=${config.mode} (from ${config.modeSource}): this ` +
300
+ `install claims no rollout and starts no turn -- those hooks are not ` +
301
+ `registered at all. It DOES capture the turns you serve. ` +
302
+ (applying.length
303
+ ? `It also applies ${applying.join(" and ")} your control plane has ` +
304
+ "approved."
305
+ : "It applies NOTHING your control plane has approved; the lines " +
306
+ "above say why.") +
307
+ ` Tool outcomes are still recorded locally.`,
308
+ );
309
+ }
310
+
311
+ // The describer answers a different question from the recorder: not "what
312
+ // did this call do" but "what IS this agent". See describe.js -- it is the
313
+ // only way a CLI agent can reach `introspection_state = described`, because
314
+ // Python reflection cannot cross a process and a language boundary.
315
+ const describer = registerDescriber(api, {
316
+ logger,
317
+ // Identity rides the same observer hook but is independent of description
318
+ // persistence: it must work in production, where the trajectory env var
319
+ // is normally absent and the describer writes nothing.
320
+ onObservation: (event, ctx) => {
321
+ const observation = executionIdentity.observe(event, ctx ?? {});
322
+ if (turns && observation) {
323
+ turns.onExecutionIdentity(event, ctx ?? {}, observation);
324
+ }
325
+ },
326
+ });
327
+
328
+ // `llm_input` is a CONVERSATION hook, and the host REFUSES to register it
329
+ // for a non-bundled plugin unless the operator sets
330
+ // `plugins.entries.<id>.hooks.allowConversationAccess=true`. The refusal is
331
+ // a warn-level diagnostic on the host's side, so without this line a
332
+ // missing opt-in is indistinguishable from a working install: the plugin
333
+ // loads, the recorder works, and the agent registers as `unreadable`
334
+ // forever with nothing anywhere saying why. Said once at startup, and only
335
+ // when a trajectory dir makes it relevant.
336
+ if (describer?.active) {
337
+ logger?.info?.(
338
+ `[${PLUGIN_ID}] describing this agent via ${DESCRIBE_HOOK_NAME}. That is ` +
339
+ `a conversation hook: if you do not see ${"discovered_agent.json"} in ` +
340
+ `${TRAJECTORY_DIR_ENV} after the first model call, set ` +
341
+ `plugins.entries.${PLUGIN_ID}.hooks.allowConversationAccess=true in ` +
342
+ `your OpenClaw config -- the host blocks the hook without it.`,
343
+ );
344
+ }
345
+
346
+ return {
347
+ recorder, writer, describer, rollouts, capture, serving, executionIdentity,
348
+ mode: config.mode,
349
+ };
350
+ }
351
+
352
+ export default {
353
+ id: PLUGIN_ID,
354
+ name: "PerceptEye Agent Flywheel",
355
+ // THE STRING `openclaw plugins list` PRINTS, so it has to be true of the
356
+ // install the reader is looking at. It said "Inert unless
357
+ // PERCEPTEYE_TRAJECTORY_DIR is set" while production mode captured every
358
+ // served turn to disk with that variable unset -- an operator who left the
359
+ // plugin installed "because it is inert" was accumulating their end users'
360
+ // messages.
361
+ description:
362
+ "Records tool-call outcomes (ok/failed/unknown). In training mode they " +
363
+ `go to the active rollout's trajectory under ${TRAJECTORY_DIR_ENV}. In ` +
364
+ "production mode every served turn -- the user's message, the tool " +
365
+ "calls and the agent's answer -- is captured under " +
366
+ "PERCEPTEYE_CAPTURE_DIR (default ~/.percepteye/captured) even when " +
367
+ `${TRAJECTORY_DIR_ENV} is unset, and uploaded only with a key and the ` +
368
+ "control plane's per-agent consent; production mode also APPLIES what " +
369
+ "your control plane has approved for this agent -- the system prompt, and " +
370
+ "the model it was certified with -- which changes what the agent does. " +
371
+ "Set capture=false (or PERCEPTEYE_CAPTURE=0) to record nothing, " +
372
+ "applyPrompt=false (or PERCEPTEYE_APPLY_PROMPT=0) to leave the prompt " +
373
+ "alone, applyModel=false (or PERCEPTEYE_APPLY_MODEL=0) to leave the model " +
374
+ "alone.",
375
+ // THE SAME OBJECT the manifest declares, not a second one. This used to be
376
+ // `{additionalProperties: false, properties: {}}` -- a schema that rejects
377
+ // every setting openclaw.plugin.json tells a customer to write.
378
+ configSchema: CONFIG_SCHEMA,
379
+ register,
380
+ };