@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/capture.js ADDED
@@ -0,0 +1,733 @@
1
+ /**
2
+ * PRODUCTION MODE: capture the turns this agent is already serving, and -- with
3
+ * the operator's consent -- upload them.
4
+ *
5
+ * This is the production half of the package, and it is the mirror of the
6
+ * Python SDK's `attach()`. Same on-disk format (see `turns.js`), same wire
7
+ * shape, same consent rule, same cadence. A customer runs one package or the
8
+ * other, so "same" has to mean the same, not similar.
9
+ *
10
+ * ── WHAT A TURN IS BUILT FROM, AND WHAT EACH PART COSTS ───────────────────
11
+ *
12
+ * the user's message `message_received` UNGATED, void-returning. Also
13
+ * carries the UPLOAD CADENCE, for
14
+ * exactly the reason below.
15
+ * the tool calls `after_tool_call` UNGATED, void-returning. Already
16
+ * recorded; only the routing is new.
17
+ * the answer `agent_end` CONVERSATION-GATED. Dropped for a
18
+ * non-bundled plugin unless the
19
+ * operator sets
20
+ * `allowConversationAccess=true`.
21
+ *
22
+ * NOTHING PERIODIC MAY HANG OFF THE GATED HOOK. The cadence counter used to
23
+ * live on `agent_end`, which meant that in the default install -- the one this
24
+ * file spends two paragraphs describing -- it never advanced, and the only
25
+ * other trigger is shutdown, which a gateway that keeps running never reaches.
26
+ * A long-lived install uploaded nothing, ever, while the startup log told the
27
+ * operator its turns were being uploaded. Python counts in `turn()`, the
28
+ * turn-OPENING path, and this now matches it.
29
+ *
30
+ * `before_agent_finalize` carries more -- sessionKey, turnId and
31
+ * lastAssistantMessage in one event -- and is deliberately NOT used. Its result
32
+ * type accepts `action: "revise"` and a `retry` instruction, so it is a
33
+ * MUTATION seam: a handler there can send the agent round again. This package's
34
+ * first rule is that capturing evidence must never be able to change the
35
+ * behaviour being measured, and a recorder on a mutation seam breaks it whether
36
+ * or not it currently returns undefined.
37
+ *
38
+ * SO A DEFAULT INSTALL CAPTURES A TURN WITHOUT ITS ANSWER, and that is a
39
+ * designed-for state rather than a gap: the intake documents `final_text: null`
40
+ * as "an ordinary state, not an error", stores the turn, grades it advisory
41
+ * with an explicit reason, and excludes it from objectives that need a
42
+ * completion. The plugin says once, at startup, which half it is missing and
43
+ * what to set -- because a customer who sees turns arriving with no answers
44
+ * deserves the cause, not a mystery.
45
+ *
46
+ * And such a turn IS UPLOADED. "Hold back a turn with no answer, one may still
47
+ * be coming" is only true where answers come at all; where the host drops
48
+ * `agent_end` there is no answer coming for any turn, and holding them back is
49
+ * holding them back forever. See `createTurnUploader({answersExpected})`.
50
+ *
51
+ * ── WHERE `message_received` DOES AND DOES NOT FIRE ───────────────────────
52
+ *
53
+ * It is emitted from the inbound CHANNEL dispatch path
54
+ * (`dispatch-DnzGTpPs.js:1436`), so it covers an agent served over Slack,
55
+ * WhatsApp, Discord and the like -- which is the population production turn
56
+ * capture is for. It does NOT fire for a local terminal session. A turn with
57
+ * tool calls and an answer but no `input_text` is still uploaded: the intake
58
+ * accepts a null input for the same reason it accepts a null answer, and
59
+ * inventing the user's message from the model's prompt would be manufacturing
60
+ * the one field we cannot observe.
61
+ */
62
+ import { randomUUID } from "node:crypto";
63
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
64
+ import { join } from "node:path";
65
+
66
+ import { conversationAccessGranted, lastAssistantText } from "./host.js";
67
+ import { ControlPlaneClient, SDK_UA } from "./http.js";
68
+ import { percepteyeHome } from "./session.js";
69
+ import { AttachTransport } from "./transport.js";
70
+ import { CONTRACT_VERSION, productionIdentifier } from "./wire.js";
71
+ import {
72
+ listTurnDirs, readConversationId, readTaskId, recordConversationId,
73
+ recordTaskId, recordTurnText,
74
+ recordExecutionFingerprint, recordExecutionFingerprintConflict, safeTurnId,
75
+ turnDir, turnIndexOf, turnWire,
76
+ } from "./turns.js";
77
+
78
+ /** Turns between automatic upload attempts. The Python SDK's `_FLUSH_EVERY`. */
79
+ export const FLUSH_EVERY = 32;
80
+
81
+ /** Turns per request. The intake's `TURN_BATCH_MAX`. */
82
+ export const BATCH_SIZE = 64;
83
+
84
+ /**
85
+ * How long an unanswered turn must sit UNTOUCHED before the cadence will send
86
+ * it.
87
+ *
88
+ * Only consulted when no answer is coming at all (see `answersExpected`).
89
+ * `final_text` is otherwise the signal that a turn is FINISHED -- the answer is
90
+ * written after the last tool call -- so without it there is nothing left to
91
+ * say "this one is done" except quiescence. And the cadence fires from inside
92
+ * the handler for the NEWEST turn, whose tool calls have not been made yet: a
93
+ * flush with no settle window would upload that turn with `tool_calls: null`,
94
+ * mark it sent, and never send the calls it went on to make. That is a worse
95
+ * loss than the one the cadence exists to fix.
96
+ *
97
+ * Shutdown does NOT wait: nothing more is coming for anything by then.
98
+ */
99
+ export const UNANSWERED_SETTLE_MS = 5 * 60_000;
100
+
101
+ /**
102
+ * Which turns have already been sent.
103
+ *
104
+ * A FILE AT THE ROOT, never inside a turn directory. Writing a marker into a
105
+ * turn's own directory bumps that directory's MTIME -- and mtime is the
106
+ * ordering key for `turn_index`. Uploading would have silently reordered every
107
+ * conversation it touched. The Python SDK carries the same file for the same
108
+ * reason; this is not a coincidence to be tidied away.
109
+ */
110
+ export const UPLOADED_INDEX = ".uploaded";
111
+
112
+ /** Where captured production turns live when nothing else says. */
113
+ export function captureRoot(env = process.env) {
114
+ return env.PERCEPTEYE_CAPTURE_DIR || join(percepteyeHome(env), "captured");
115
+ }
116
+
117
+ /**
118
+ * May captured turns be uploaded, per the control plane's own answer?
119
+ *
120
+ * THE SERVER DECIDES; this only reads the verdict. `capture.turns` is computed
121
+ * in Mission Control from BOTH the per-agent consent flag
122
+ * (`production_capture_enabled`, opt-in, NULL is not consent) AND whether this
123
+ * flywheel key carries the `turn.report` scope. Re-deriving either half here
124
+ * would be a second answer to a settled question, and it fails in the one
125
+ * direction that costs a debugging session: the client believing it may upload
126
+ * while every batch 403s, or the reverse.
127
+ *
128
+ * ABSENCE IS NOT CONSENT. A registration that failed, or a control plane too
129
+ * old to send `capture` at all, yields `false` with a reason saying so.
130
+ */
131
+ export function captureVerdict(registration) {
132
+ const capture = registration && typeof registration === "object"
133
+ ? registration.capture : null;
134
+ // `Array.isArray` beside the `typeof` check, because `typeof [] === "object"`
135
+ // in JavaScript: without it a JSON ARRAY is accepted as a capture block,
136
+ // finds no `turns` on it, and falls through to "production capture is not
137
+ // enabled for this agent" -- naming a switch an operator can flip when what
138
+ // actually happened is that the control plane sent something that is not an
139
+ // answer. Both branches refuse, so consent was never at risk; the defect was
140
+ // that the refusal named the wrong cause. Python has always said "the
141
+ // control plane did not answer" for this body, and the two SDKs owe the same
142
+ // sentence to the same input.
143
+ if (!capture || typeof capture !== "object" || Array.isArray(capture)) {
144
+ return {
145
+ mayUpload: false,
146
+ reason:
147
+ "the control plane did not answer whether this agent may upload " +
148
+ "production turns, so nothing is uploaded. Turns are still captured " +
149
+ "locally.",
150
+ };
151
+ }
152
+ // `=== true`, not truthiness: a consent question is answered by the exact
153
+ // value. `turns: "false"` is truthy.
154
+ const mayUpload = capture.turns === true;
155
+ return {
156
+ mayUpload,
157
+ // The server's own sentence when it sent one -- it names the switch and
158
+ // who can flip it, which anything written here could only paraphrase.
159
+ reason: mayUpload ? null
160
+ : (typeof capture.reason === "string" && capture.reason.trim()
161
+ ? capture.reason
162
+ : "production capture is not enabled for this agent."),
163
+ };
164
+ }
165
+
166
+ /**
167
+ * The turn store: three hooks in, turn directories out.
168
+ *
169
+ * Exact host ids need no in-memory state. An invalid host id gets a fresh
170
+ * storage-safe id instead of being rewritten; that one exceptional mapping is
171
+ * bounded and exists only to keep the same run's observer hooks joined.
172
+ */
173
+ export function createTurnCapture({ root, logger = null }) {
174
+ let warned = false;
175
+ // Invalid host ids are never cleaned into a different caller id. They get a
176
+ // fresh safe id, retained only long enough to keep this run's hooks joined.
177
+ const generatedTurnIds = new Map();
178
+ const turnIdFor = (value) => {
179
+ const exact = safeTurnId(value);
180
+ if (exact !== null) return exact;
181
+ if (value === null || value === undefined || String(value).length === 0) {
182
+ return null;
183
+ }
184
+ // Map keys retain primitive value and object identity; stringifying an
185
+ // invalid host value would collapse every object to "[object Object]".
186
+ const key = value;
187
+ let generated = generatedTurnIds.get(key);
188
+ if (!generated) {
189
+ generated = `t_${randomUUID().replaceAll("-", "").slice(0, 16)}`;
190
+ generatedTurnIds.set(key, generated);
191
+ while (generatedTurnIds.size > 4096) {
192
+ generatedTurnIds.delete(generatedTurnIds.keys().next().value);
193
+ }
194
+ }
195
+ return generated;
196
+ };
197
+ const taskIdFrom = (event, ctx) => productionIdentifier(
198
+ event?.taskId ?? event?.task_id ?? ctx?.taskId ?? ctx?.task_id,
199
+ );
200
+ const conversationIdFrom = (event, ctx) => productionIdentifier(
201
+ event?.sessionKey ?? ctx?.sessionKey,
202
+ );
203
+ const warnOnce = (err) => {
204
+ if (warned) return;
205
+ warned = true;
206
+ logger?.warn?.(
207
+ `[agent-flywheel] could not write a captured turn; production capture is ` +
208
+ `not recording for this run: ${err?.message ?? err}`,
209
+ );
210
+ };
211
+
212
+ /** The end user's message. */
213
+ function onMessageReceived(event, ctx = {}) {
214
+ const runId = event?.runId ?? ctx?.runId;
215
+ const text = event?.content;
216
+ const turnId = turnIdFor(runId);
217
+ if (turnId === null) return false;
218
+ const conversationId = conversationIdFrom(event, ctx);
219
+ const taskId = taskIdFrom(event, ctx);
220
+ const dir = turnDir(root, turnId);
221
+ try {
222
+ const wroteConversation = conversationId
223
+ ? recordConversationId(dir, conversationId) : false;
224
+ const wroteTaskId = taskId ? recordTaskId(dir, taskId) : false;
225
+ const effectiveTaskId = readTaskId(dir);
226
+ const wroteText = recordTurnText(dir, "task", text, {
227
+ turn_id: turnId,
228
+ conversation_id: conversationId ?? "",
229
+ task_id: effectiveTaskId ?? "",
230
+ });
231
+ return wroteConversation || wroteTaskId || wroteText;
232
+ } catch (err) {
233
+ warnOnce(err);
234
+ return false;
235
+ }
236
+ }
237
+
238
+ /** The agent's answer. */
239
+ function onAgentEnd(event, ctx = {}) {
240
+ const runId = event?.runId ?? ctx?.runId;
241
+ const turnId = turnIdFor(runId);
242
+ if (turnId === null) return false;
243
+ const dir = turnDir(root, turnId);
244
+ const conversationId = conversationIdFrom(event, ctx);
245
+ const taskId = taskIdFrom(event, ctx);
246
+ const wroteConversation = conversationId
247
+ ? recordConversationId(dir, conversationId) : false;
248
+ const wroteTaskId = taskId ? recordTaskId(dir, taskId) : false;
249
+ const text = lastAssistantText(event?.messages);
250
+ if (!text) return wroteConversation || wroteTaskId;
251
+ try {
252
+ const wroteText = recordTurnText(dir, "answer", text, {
253
+ turn_id: turnId,
254
+ conversation_id: readConversationId(dir) ?? "",
255
+ task_id: readTaskId(dir) ?? "",
256
+ });
257
+ return wroteConversation || wroteTaskId || wroteText;
258
+ } catch (err) {
259
+ warnOnce(err);
260
+ return false;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Where a tool call belongs. Passed to `createWriter({resolveDir})`.
266
+ *
267
+ * `null` for a call with no run id, which DROPS the record rather than
268
+ * putting it in a shared directory. An unattributable call landing in some
269
+ * other turn's trajectory is the misattribution this package refuses
270
+ * everywhere else.
271
+ */
272
+ function resolveToolDir(callCtx) {
273
+ const runId = callCtx?.runId;
274
+ const turnId = turnIdFor(runId);
275
+ if (turnId === null) return null;
276
+ const dir = turnDir(root, turnId);
277
+ const taskId = taskIdFrom(null, callCtx);
278
+ if (taskId) recordTaskId(dir, taskId);
279
+ const conversationId = conversationIdFrom(null, callCtx);
280
+ if (conversationId) recordConversationId(dir, conversationId);
281
+ return dir;
282
+ }
283
+
284
+ /** Bind a reconciled identity to this exact host-declared turn. */
285
+ function onExecutionIdentity(event, ctx = {}, observation) {
286
+ const runId = event?.runId ?? ctx?.runId;
287
+ const turnId = turnIdFor(runId);
288
+ if (turnId === null || !observation) return false;
289
+ const dir = turnDir(root, turnId);
290
+ if (observation.conflict) return recordExecutionFingerprintConflict(dir);
291
+ if (!observation.fingerprint) return false;
292
+ return recordExecutionFingerprint(dir, observation.fingerprint);
293
+ }
294
+
295
+ return { onMessageReceived, onAgentEnd, onExecutionIdentity, resolveToolDir };
296
+ }
297
+
298
+ /**
299
+ * Uploads captured turns. Periodic while traffic arrives, and once at shutdown.
300
+ *
301
+ * TWO TRIGGERS, because neither covers the other's case -- the same pair the
302
+ * Python SDK needed, arrived at the same way. The cadence only runs while
303
+ * requests are arriving; shutdown knows nothing more is coming, but never
304
+ * fires for a gateway that keeps running.
305
+ *
306
+ * `answersExpected` IS THE HOST'S ANSWER, PASSED IN. The cadence holds back a
307
+ * turn with no `final_text` because an answer may still be coming -- but that
308
+ * is only true when this host actually delivers `agent_end` to this plugin.
309
+ * When it does not (the default install; see `host.js`), NO turn ever gets an
310
+ * answer, "may still be coming" is false for every one of them, and holding
311
+ * them back holds them back forever: a long-lived gateway would upload
312
+ * literally nothing while telling the operator its turns were being uploaded.
313
+ * The question is asked once, at registration, by the one predicate that owns
314
+ * it, and the verdict is handed here rather than re-derived.
315
+ */
316
+ export function createTurnUploader({
317
+ transport, agentId, root, logger = null, flushEvery = FLUSH_EVERY,
318
+ answersExpected = true,
319
+ }) {
320
+ let since = 0;
321
+ let inFlight = false;
322
+ let mayUpload = false;
323
+ let refusal = null;
324
+
325
+ function uploadedIds() {
326
+ try {
327
+ return new Set(
328
+ readFileSync(join(root, UPLOADED_INDEX), "utf8")
329
+ .split("\n").map((l) => l.trim()).filter(Boolean),
330
+ );
331
+ } catch {
332
+ return new Set();
333
+ }
334
+ }
335
+
336
+ function markUploaded(ids) {
337
+ if (!ids.length) return;
338
+ try {
339
+ mkdirSync(root, { recursive: true });
340
+ const all = uploadedIds();
341
+ for (const id of ids) all.add(id);
342
+ writeFileSync(join(root, UPLOADED_INDEX),
343
+ [...all].join("\n") + "\n", "utf8");
344
+ } catch {
345
+ // A marker we could not write means the turn is retried next flush. The
346
+ // intake dedupes on turn_id, so a resend is answered as a duplicate --
347
+ // which is why failing to mark is safe and failing to SEND is the thing
348
+ // this ordering protects.
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Collect the turns worth sending right now.
354
+ *
355
+ * `quietBefore` is an mtime cutoff applied ONLY to unanswered turns: a turn
356
+ * touched more recently than that may still be accruing tool calls, and
357
+ * sending it now would mark it uploaded before it finished being written.
358
+ * `null` means send them regardless, which is what shutdown wants.
359
+ */
360
+ function pending({ includeUnanswered, quietBefore = null }) {
361
+ const already = uploadedIds();
362
+ const out = [];
363
+ for (const d of listTurnDirs(root)) {
364
+ if (already.has(d.name)) continue;
365
+ let wire;
366
+ try {
367
+ const cid = readConversationId(d.path);
368
+ wire = turnWire(d.path, {
369
+ turnIndex: cid ? turnIndexOf(root, d.name, cid) : 0,
370
+ });
371
+ } catch {
372
+ // One unreadable turn costs that turn. A truncated trajectory is the
373
+ // EXPECTED case here, not an exotic one.
374
+ continue;
375
+ }
376
+ if (!wire) continue;
377
+ if (wire.final_text === null) {
378
+ if (!includeUnanswered) continue;
379
+ if (quietBefore !== null && d.mtime > quietBefore) continue;
380
+ }
381
+ out.push(wire);
382
+ }
383
+ return out;
384
+ }
385
+
386
+ /**
387
+ * Send. Never rejects: a control plane that cannot be reached must not break
388
+ * an agent that is serving users, and the turns stay on disk for next time.
389
+ */
390
+ async function flush({ includeUnanswered = false, settleMs = 0 } = {}) {
391
+ if (!mayUpload) return { sent: 0, notPermitted: true };
392
+ if (inFlight) return { sent: 0, skipped: true };
393
+ inFlight = true;
394
+ try {
395
+ const turns = pending({
396
+ includeUnanswered,
397
+ quietBefore: settleMs > 0 ? Date.now() - settleMs : null,
398
+ });
399
+ if (!turns.length) return { sent: 0 };
400
+ let sent = 0;
401
+ for (let i = 0; i < turns.length; i += BATCH_SIZE) {
402
+ const chunk = turns.slice(i, i + BATCH_SIZE);
403
+ try {
404
+ await transport.reportTurns(agentId, chunk);
405
+ } catch (err) {
406
+ // Whole chunk unacknowledged: leave every marker unwritten so the
407
+ // next flush retries it.
408
+ logger?.warn?.(
409
+ `[agent-flywheel] could not upload ${chunk.length} captured turn(s); ` +
410
+ `they stay on disk and are retried: ${err?.message ?? err}`);
411
+ continue;
412
+ }
413
+ markUploaded(chunk.map((t) => t.turn_id));
414
+ sent += chunk.length;
415
+ }
416
+ return { sent };
417
+ } finally {
418
+ inFlight = false;
419
+ }
420
+ }
421
+
422
+ return {
423
+ get mayUpload() { return mayUpload; },
424
+ get refusal() { return refusal; },
425
+ /** Whether this host delivers `agent_end`, i.e. whether answers arrive. */
426
+ get answersExpected() { return answersExpected; },
427
+ /** Record the control plane's verdict. Until this runs, nothing uploads. */
428
+ setVerdict(v) {
429
+ mayUpload = Boolean(v?.mayUpload);
430
+ refusal = v?.reason ?? null;
431
+ },
432
+ /**
433
+ * One turn happened. Kicks a flush when the cadence is due.
434
+ *
435
+ * Called from the turn's OPENING, which is the ungated half -- the mirror
436
+ * of Python's `turn()`, which increments `_turns_since_flush` there and
437
+ * not in `record_answer`. Counting on the answer instead would put the
438
+ * whole cadence behind a hook the host drops by default.
439
+ *
440
+ * Deliberately NOT awaited by its caller: the hook that calls this is on
441
+ * the path of a turn the customer's user is waiting for, and an upload
442
+ * must never be between them and their answer.
443
+ */
444
+ note() {
445
+ since += 1;
446
+ if (since < flushEvery) return null;
447
+ since = 0;
448
+ return flush({
449
+ includeUnanswered: !answersExpected,
450
+ settleMs: UNANSWERED_SETTLE_MS,
451
+ }).catch(() => ({ sent: 0 }));
452
+ },
453
+ flush,
454
+ };
455
+ }
456
+
457
+ export const MESSAGE_RECEIVED = "message_received";
458
+ export const AGENT_END = "agent_end";
459
+ export const GATEWAY_STOP = "gateway_stop";
460
+
461
+ /**
462
+ * Subscribe production turn capture. Called only in production mode.
463
+ *
464
+ * Returns `{turns, uploader, root, transport, started}`, or `null` when
465
+ * nothing was wired. `transport` is null without an API key; `started` is the
466
+ * registration chain, or null when there was nothing to register.
467
+ *
468
+ * CAPTURE RUNS WITHOUT A KEY; only UPLOADING needs one. That is the ordinary
469
+ * first install -- local recording, no account, no traffic -- which is why the
470
+ * message hook is subscribed before any control-plane call is attempted.
471
+ */
472
+ export function registerTurnCapture(api, {
473
+ config, turns, pluginId, logger = null, env = process.env,
474
+ makeTransport = defaultTransport,
475
+ }) {
476
+ if (!turns || typeof api?.on !== "function") return null;
477
+ const root = captureRoot(env);
478
+
479
+ // ASKED ONCE, HERE, and handed to everything that needs it. Whether this
480
+ // host will deliver `agent_end` decides two things at once -- whether the
481
+ // operator is told answers are missing, and whether the cadence may ever
482
+ // send a turn that has none -- and those must not be able to disagree.
483
+ const answersExpected = conversationAccessGranted(api, pluginId);
484
+
485
+ let uploader = null;
486
+ let transport = null;
487
+ // The startup chain, EXPOSED rather than fire-and-forget. `policy.js` reads
488
+ // the control plane too, and its read is sequenced behind this one for the
489
+ // same reason `announce` is sequenced behind `enrol`: three calls racing at
490
+ // plugin load is three chances to be rate-limited on a cold start, and the
491
+ // policy answer is not needed until the first turn arrives.
492
+ let started = null;
493
+
494
+ // THE TURN OPENS, and this is the UNGATED half -- the only hook of the three
495
+ // the host is guaranteed to deliver. It carries the cadence for that reason:
496
+ // `agent_end` used to, and `agent_end` is dropped for a non-bundled plugin
497
+ // unless the operator opts in, so in the default install the counter never
498
+ // advanced and a gateway that never stops uploaded nothing, ever. Python
499
+ // counts in `turn()` for the same reason. Subscribed before any
500
+ // control-plane call is attempted, because capture runs without a key.
501
+ api.on(MESSAGE_RECEIVED, (event, ctx) => {
502
+ try {
503
+ turns.onMessageReceived(event, ctx ?? {});
504
+ } catch { /* one lost observation, never a failed turn */ }
505
+ // NOT AWAITED. This hook sits on the path of a turn whose user is waiting;
506
+ // an upload must never be between them and their answer.
507
+ uploader?.note();
508
+ });
509
+
510
+ if (config.apiKey) {
511
+ try {
512
+ transport = makeTransport(config);
513
+ uploader = createTurnUploader({
514
+ transport, agentId: config.agentId, root, logger, answersExpected,
515
+ });
516
+ } catch (err) {
517
+ // `new ControlPlaneClient` refuses a missing key or base URL by design.
518
+ // A misconfigured control plane must not take local recording down with
519
+ // it, and `register` must not throw -- the host treats that as a failed
520
+ // plugin load.
521
+ logger?.warn?.(
522
+ `[${pluginId}] captured turns will not be uploaded: ` +
523
+ `${err?.message ?? err}. Local recording is unaffected.`);
524
+ }
525
+ }
526
+
527
+ // THE ANSWER, and the one part that needs the operator's opt-in. Subscribed
528
+ // regardless: if the host drops it, `api.on` reports nothing either way, and
529
+ // a turn without its answer is still stored and graded advisory.
530
+ //
531
+ // It does NOT drive the cadence. It cannot: it is the hook that may not
532
+ // arrive. And it must not count a second time when it does, or a granted
533
+ // install would flush at twice the rate this package documents and the
534
+ // Python SDK uses.
535
+ api.on(AGENT_END, (event, ctx) => {
536
+ try {
537
+ turns.onAgentEnd(event, ctx ?? {});
538
+ } catch { /* one lost observation */ }
539
+ });
540
+
541
+ if (uploader) {
542
+ api.on(GATEWAY_STOP, () => {
543
+ // LAST CHANCE: nothing more is coming for anything after this, so every
544
+ // unanswered turn goes regardless of how recently it was written.
545
+ uploader.flush({ includeUnanswered: true }).catch(() => {});
546
+ });
547
+ // REGISTER, THEN ASK -- in that order, and the order is the point. The
548
+ // verdict is computed FROM the agent row, so asking first, on a first
549
+ // start, spends the read on a question whose only possible answer is "not
550
+ // registered". Chained rather than run alongside for the same reason:
551
+ // concurrent calls race, and half the starts would still get that answer.
552
+ // Neither rejects, so a refused registration still reaches the read --
553
+ // which matters, because an operator may have consented long ago and the
554
+ // registration may be refused for something else entirely.
555
+ //
556
+ // NOT AWAITED: `register()` must return to the host synchronously, and a
557
+ // control plane that is slow or down must not delay a plugin load.
558
+ started = enrol({ transport, config, pluginId, logger })
559
+ .then(() => announce({ transport, uploader, config, pluginId, logger, env }));
560
+ } else {
561
+ logger?.info?.(
562
+ `[${pluginId}] capturing production turns to ${root}. No API key is ` +
563
+ `configured, so nothing is uploaded.`);
564
+ }
565
+
566
+ if (!answersExpected) {
567
+ logger?.info?.(
568
+ `[${pluginId}] capturing production turns WITHOUT the agent's answers: ` +
569
+ `'${AGENT_END}' is a conversation hook and this host drops it for ` +
570
+ `non-bundled plugins unless you set ` +
571
+ `plugins.entries.${pluginId}.hooks.allowConversationAccess=true. Turns ` +
572
+ `are still recorded and uploaded with their tool calls -- the cadence ` +
573
+ `sends them once they stop changing, since no answer is coming -- and ` +
574
+ `the control plane stores them with an explicit 'no answer' reason, but ` +
575
+ `nothing that needs a completion can use them.`);
576
+ }
577
+
578
+ // `transport` is handed out so the serving-policy read reuses THIS client
579
+ // rather than constructing a second one against the same base URL and key.
580
+ return { turns, uploader, root, transport, started };
581
+ }
582
+
583
+ /** The real control-plane transport. Replaced in tests, never in production. */
584
+ function defaultTransport(config) {
585
+ return new AttachTransport(
586
+ new ControlPlaneClient(config.controlPlaneUrl, config.apiKey),
587
+ { agentId: config.agentId },
588
+ );
589
+ }
590
+
591
+ /**
592
+ * Create this agent's row, so an operator has something to switch capture ON.
593
+ *
594
+ * THE OTHER HALF OF THE PERMISSION STORY, and it went missing. `POST /agents`
595
+ * is the ONLY producer of a `FlywheelAgent` row anywhere in the control plane.
596
+ * With no row the operator's consent control
597
+ * (`POST /flywheel-agents/{agent_id}/production-capture`) answers 404, and the
598
+ * verdict `announce()` reads below is permanently "this agent is not
599
+ * registered, so no operator has been able to enable production capture for
600
+ * it. Register it first." -- a remedy naming the exact call this package had
601
+ * stopped making. Uploading was structurally unreachable for every install.
602
+ *
603
+ * Replacing the permission PROBE with `GET /agents/{id}/capture` was right,
604
+ * and it stands: asking a permission question must not write. What went with
605
+ * it by accident was the REGISTRATION -- two different jobs that happened to
606
+ * share one route, and only one of them survived the split. This is the other
607
+ * one, back as its own call, which is why `announce()` still performs no write.
608
+ *
609
+ * `discovered_agent: null` -- INTROSPECTION_UNREADABLE. It is the TRUE state of
610
+ * the three rather than the convenient one, and the other two are wrong for
611
+ * different reasons:
612
+ *
613
+ * OMITTING the key records `disabled`, which means "the customer opted out
614
+ * of introspection". Nothing here offers them that choice -- this package
615
+ * has no `introspect` setting and reads no PERCEPTEYE_INTROSPECT -- so it
616
+ * would assert a decision nobody made. It is also the DESTRUCTIVE state:
617
+ * `FlywheelAgentRepository.upsert` writes `disabled` onto the row
618
+ * unconditionally, which stops that agent's description driving workflow
619
+ * generation. That is precisely the side effect `announce()` describes and
620
+ * the reason the old registration had to go, so re-creating it here would
621
+ * undo one fix while claiming another.
622
+ *
623
+ * A DESCRIPTION cannot honestly be sent. `describe.js` is the only source of
624
+ * one and it writes into PERCEPTEYE_TRAJECTORY_DIR, which production mode
625
+ * does not set -- so the describer is inert here -- and even where it is
626
+ * active `llm_input` first fires on the first model call, strictly after
627
+ * this. Waiting for one would put registration behind a CONVERSATION hook
628
+ * the host drops for a non-bundled plugin by default, i.e. this same defect
629
+ * again for the majority of installs.
630
+ *
631
+ * NULL is what is left and what is true: we looked, and we have no
632
+ * description. It is also the one state `upsert` treats as a fact about the
633
+ * control plane's framework coverage rather than a retraction by the
634
+ * customer -- a row that already holds a tool catalogue keeps BOTH the
635
+ * catalogue and its state -- so a production process re-registering on every
636
+ * restart cannot erase what a training run established.
637
+ *
638
+ * NEVER REJECTS, and never throws into its caller. A refused or unreachable
639
+ * control plane must not cost the verdict read, local capture (which needs no
640
+ * account at all), or the plugin load -- the host treats a throw from
641
+ * `register()` as a failed plugin load. The Python SDK's `attach()` splits the
642
+ * same two calls for the same reason: a registration intake dislikes must not
643
+ * also cost that process its policy read.
644
+ *
645
+ * NOT EXPORTED, unlike `announce` beside it. Its tests drive it through
646
+ * `registerTurnCapture`, which is the path a customer actually gets, and this
647
+ * package has already paid for one exported control-plane call that nothing
648
+ * called: `AttachTransport.register` itself, which is the defect above.
649
+ */
650
+ function enrol({ transport, config, pluginId, logger = null }) {
651
+ return Promise.resolve()
652
+ .then(() => transport.register({
653
+ agent_id: config.agentId,
654
+ // The WIRE value, which is not this package's mode vocabulary. The
655
+ // server accepts ("rollout", "production") only, so `config.mode` --
656
+ // whose training spelling is "training" -- would 422. A literal is safe
657
+ // because this function is reached from the production lane alone;
658
+ // `index.js` never constructs turn capture in training mode.
659
+ mode: "production",
660
+ sdk: SDK_UA,
661
+ contract_version: CONTRACT_VERSION,
662
+ // TRUE, and getting it wrong costs a grade reason
663
+ // (`flywheel_does_not_report_tool_outcomes`): `after_tool_call` is
664
+ // subscribed unconditionally and every captured call goes up in the
665
+ // turn's `tool_calls`.
666
+ reports_tool_calls: true,
667
+ // This runs INSIDE the customer's own OpenClaw process; nothing is
668
+ // spawned. The same value Python's `attach()` sends, for the same
669
+ // reason.
670
+ isolation: "inprocess",
671
+ // Production mode claims no rollouts, so this reports no capacity. One
672
+ // is what `attach()` sends.
673
+ concurrency: 1,
674
+ input_shape: "text",
675
+ entrypoint: "openclaw-plugin",
676
+ // See the docstring. `null`, never omitted and never invented.
677
+ discovered_agent: null,
678
+ }))
679
+ .then(() => true)
680
+ .catch((err) => {
681
+ logger?.info?.(
682
+ `[${pluginId}] could not register '${config.agentId}' with the ` +
683
+ `control plane (${err?.message ?? err}); turns are still captured ` +
684
+ `locally. Until a registration lands, no operator can enable upload ` +
685
+ `for this agent, because the control has no agent to act on.`);
686
+ return false;
687
+ });
688
+ }
689
+
690
+ /**
691
+ * Ask the control plane whether this agent may upload, and say so once.
692
+ *
693
+ * A READ. The verdict carries BOTH halves -- the per-agent consent an operator
694
+ * grants in Mission Control, and whether this key carries the `turn.report`
695
+ * scope -- so a client never learns its permission BY uploading, i.e. by
696
+ * sending the data whose permission is in question. Until it answers,
697
+ * `uploader.mayUpload` is false and nothing leaves the machine.
698
+ *
699
+ * IT USED TO BE A REGISTRATION, and that was a workaround this route removed.
700
+ * `POST /agents` is an upsert, and a registration omitting `discovered_agent`
701
+ * is recorded as introspection `disabled` -- which stops that agent's
702
+ * description driving workflow generation. So asking a permission question
703
+ * changed an unrelated column.
704
+ *
705
+ * THE WORKAROUND IS GONE; THE REGISTRATION IS NOT. Nothing on this path
706
+ * writes -- re-asking costs a read and nothing else, which is the whole point
707
+ * of the route -- but the row the verdict is computed from still has to be
708
+ * created by somebody, and `enrol()` above is now the one that does it. When
709
+ * that was left out, this read answered "not registered" forever. The explicit
710
+ * `discovered_agent: null` it sends is no longer a workaround for a side
711
+ * effect: it is the honest state, for the reasons `enrol()` gives.
712
+ */
713
+ export function announce({ transport, uploader, config, pluginId, logger }) {
714
+ return Promise.resolve()
715
+ .then(() => transport.captureCurrent(config.agentId))
716
+ .then((response) => {
717
+ const v = captureVerdict(response);
718
+ uploader.setVerdict(v);
719
+ logger?.info?.(v.mayUpload
720
+ ? `[${pluginId}] production capture is ENABLED for '${config.agentId}'; ` +
721
+ `turns upload every ${FLUSH_EVERY} turns and at shutdown.`
722
+ : `[${pluginId}] capturing locally; turns will NOT be uploaded: ${v.reason}`);
723
+ return v;
724
+ })
725
+ .catch((err) => {
726
+ logger?.info?.(
727
+ `[${pluginId}] could not read the production-capture verdict ` +
728
+ `(${err?.message ?? err}); nothing is uploaded and turns stay on disk.`);
729
+ return { mayUpload: false, reason: String(err?.message ?? err) };
730
+ });
731
+ }
732
+
733
+ export { conversationAccessGranted, lastAssistantText };