@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.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Identity evidence: what a write produced, extracted and the body discarded.
3
+ *
4
+ * A tool outcome says whether a call worked. It cannot say whether the change
5
+ * SURVIVED -- a create that returns 201 and then silently fails to persist
6
+ * looks identical, from inside the process, to one that landed. Settling that
7
+ * needs a read-back, and a read-back needs one thing: which entity to look for.
8
+ *
9
+ * That is all this produces. Not the response body -- the identity within it:
10
+ *
11
+ * - the body is customer production data, and persisting it in a trajectory
12
+ * turns a capture plugin into a data processor;
13
+ * - the identity is a handful of opaque strings, and is nearly all a
14
+ * read-back needs.
15
+ *
16
+ * So identity is resolved here, at capture, and the body is dropped. This is
17
+ * the mirror of `evidence.py` on the Python side and MUST agree with it --
18
+ * same fields, same refusals, same ambiguity rule.
19
+ */
20
+
21
+ /** High-entropy fields that uniquely identify the entity a write produced. */
22
+ export const IDENTITY_FIELDS = new Set(["id", "key", "uuid", "sid", "gid"]);
23
+
24
+ /**
25
+ * Low-entropy fields a DIFFERENT entity can share by coincidence. Never
26
+ * identity: matching on them confirms the wrong resource, which is a false
27
+ * positive dressed as a verified success -- strictly worse than no evidence.
28
+ */
29
+ export const WEAK_ID_FIELDS = new Set([
30
+ "name", "number", "title", "label", "summary", "description",
31
+ ]);
32
+
33
+ const MAX_DEPTH = 3;
34
+ const MAX_NODES = 2000;
35
+ const AMBIGUOUS = Symbol("ambiguous");
36
+
37
+ function scalar(value) {
38
+ // `typeof true === "boolean"`, so booleans are excluded by omission rather
39
+ // than by a check -- but say so, because `{"id": true}` is not an identity.
40
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
41
+ if (typeof value === "string") {
42
+ const s = value.trim();
43
+ return s === "" ? null : s;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * The identity a write produced, filed under the field it came from:
50
+ * `{ id: "10001", key: "PROJ-42" }`.
51
+ *
52
+ * The per-field filing is not cosmetic. It is what lets a read-back compare a
53
+ * candidate's `key` to the write's `key` and never to the write's `id` --
54
+ * collapsing them into one flat set is how an entity whose literal `id` equals
55
+ * another's `key` string gets falsely confirmed.
56
+ *
57
+ * Shallowest occurrence wins; a field seen twice at the same depth with
58
+ * different values is dropped as ambiguous, because an ambiguous identity is
59
+ * not an identity. Never throws: an unreadable body yields `{}`, which means
60
+ * "no evidence" and therefore an abstention, never a failure.
61
+ */
62
+ export function extractEntityIds(output) {
63
+ try {
64
+ let root = output;
65
+ if (typeof root === "string") {
66
+ try {
67
+ root = JSON.parse(root);
68
+ } catch {
69
+ return {}; // prose; an id parsed out of it would be a guess
70
+ }
71
+ }
72
+ const found = new Map(); // field -> { depth, value }
73
+ let budget = MAX_NODES;
74
+
75
+ const walk = (node, depth) => {
76
+ if (depth > MAX_DEPTH || budget <= 0 || node === null) return;
77
+ budget -= 1;
78
+ if (Array.isArray(node)) {
79
+ for (const item of node) walk(item, depth + 1);
80
+ return;
81
+ }
82
+ if (typeof node !== "object") return;
83
+ for (const [rawKey, value] of Object.entries(node)) {
84
+ const field = String(rawKey).trim().toLowerCase();
85
+ if (IDENTITY_FIELDS.has(field)) {
86
+ const text = scalar(value);
87
+ if (text !== null) {
88
+ const prev = found.get(field);
89
+ if (!prev || depth < prev.depth) {
90
+ found.set(field, { depth, value: text });
91
+ } else if (depth === prev.depth && prev.value !== text) {
92
+ found.set(field, { depth, value: AMBIGUOUS });
93
+ }
94
+ }
95
+ }
96
+ walk(value, depth + 1);
97
+ }
98
+ };
99
+
100
+ walk(root, 0);
101
+ const out = {};
102
+ for (const [field, { value }] of found) {
103
+ if (value !== AMBIGUOUS) out[field] = value;
104
+ }
105
+ return out;
106
+ } catch {
107
+ return {};
108
+ }
109
+ }
@@ -0,0 +1,444 @@
1
+ /**
2
+ * Exact, framework-neutral identity for one observed agent execution.
3
+ *
4
+ * The host adapter supplies opaque SHA-256 components for implementation and
5
+ * mutable state that no model-call hook can see. Core combines those with the
6
+ * complete prompt/tool/model observation made at `llm_input`; it never knows
7
+ * what a component means and never accepts a caller-authored final digest. A
8
+ * host adapter may also supply that same exact observation at startup, before
9
+ * a rollout is claimed, so registration can bind the worker to the execution
10
+ * identity its later reports must carry.
11
+ *
12
+ * No component, incomplete discovery, or two different observations in one
13
+ * run means no affirmative identity. That is an abstention, not an error and
14
+ * never a reason to interrupt the customer's agent.
15
+ */
16
+ import { createHash } from "node:crypto";
17
+
18
+ import { buildRecord, normalizeTools } from "./describe.js";
19
+ import { SDK_UA } from "./http.js";
20
+ import { CONTRACT_VERSION } from "./wire.js";
21
+
22
+ const OBSERVATION_DOMAIN = "percepteye-agent-observation-v1\0";
23
+ const EXECUTION_DOMAIN = "percepteye-agent-execution-v1\0";
24
+ const LOWER_SHA256 = /^[0-9a-f]{64}$/;
25
+ const MAX_PENDING_IDENTITIES = 4096;
26
+ const STARTUP_OBSERVATION_KEYS = Object.freeze([
27
+ "systemPrompt", "tools", "provider", "model",
28
+ ]);
29
+ const STARTUP_OBSERVATION_KEY_SET = new Set(STARTUP_OBSERVATION_KEYS);
30
+
31
+ /** Recursively freeze an SDK-owned JSON tree before exposing it to callers. */
32
+ function freezeJsonTree(value, seen = new Set()) {
33
+ if (
34
+ !value || typeof value !== "object" || Object.isFrozen(value)
35
+ || seen.has(value)
36
+ ) return value;
37
+ seen.add(value);
38
+ for (const child of Object.values(value)) freezeJsonTree(child, seen);
39
+ return Object.freeze(value);
40
+ }
41
+
42
+ /** Unicode-code-point ordering, matching Python rather than UTF-16 sort. */
43
+ function compareStrings(left, right) {
44
+ const a = [...left].map((c) => c.codePointAt(0));
45
+ const b = [...right].map((c) => c.codePointAt(0));
46
+ for (let i = 0; i < Math.min(a.length, b.length); i += 1) {
47
+ if (a[i] !== b[i]) return a[i] - b[i];
48
+ }
49
+ return a.length - b.length;
50
+ }
51
+
52
+ /** UTF-8 cannot encode an unpaired UTF-16 surrogate without replacing it. */
53
+ function hasLoneSurrogate(value) {
54
+ for (let i = 0; i < value.length; i += 1) {
55
+ const unit = value.charCodeAt(i);
56
+ if (unit >= 0xd800 && unit <= 0xdbff) {
57
+ const next = value.charCodeAt(i + 1);
58
+ if (!(next >= 0xdc00 && next <= 0xdfff)) return true;
59
+ i += 1;
60
+ } else if (unit >= 0xdc00 && unit <= 0xdfff) {
61
+ return true;
62
+ }
63
+ }
64
+ return false;
65
+ }
66
+
67
+ /** Canonical IEEE-754 spelling shared with the Python SDK. */
68
+ function f64Hex(value, path) {
69
+ if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
70
+ throw new Error("execution identity integer is outside the exact JSON range at " + path);
71
+ }
72
+ if (!Number.isFinite(value)) {
73
+ throw new Error("execution identity contains a non-finite number at " + path);
74
+ }
75
+ const normalized = Object.is(value, -0) ? 0 : value;
76
+ const bytes = new ArrayBuffer(8);
77
+ new DataView(bytes).setFloat64(0, normalized, false);
78
+ return [...new Uint8Array(bytes)]
79
+ .map((byte) => byte.toString(16).padStart(2, "0"))
80
+ .join("");
81
+ }
82
+
83
+ /** Typed canonical encoding for the deliberately JSON-only vocabulary. */
84
+ function canonicalJson(value, path = "$", parents = new Set()) {
85
+ if (typeof value === "string") {
86
+ if (hasLoneSurrogate(value)) {
87
+ throw new Error("execution identity contains invalid Unicode at " + path);
88
+ }
89
+ return "[\"string\"," + JSON.stringify(value) + "]";
90
+ }
91
+ if (value === null) return "[\"null\"]";
92
+ if (typeof value === "boolean") {
93
+ return "[\"boolean\"," + JSON.stringify(value) + "]";
94
+ }
95
+ if (typeof value === "number") {
96
+ return "[\"number_f64\"," + JSON.stringify(f64Hex(value, path)) + "]";
97
+ }
98
+ if (typeof value !== "object") {
99
+ throw new Error(
100
+ "execution identity contains unsupported " + typeof value + " at " + path,
101
+ );
102
+ }
103
+ if (parents.has(value)) {
104
+ throw new Error("execution identity contains a cycle at " + path);
105
+ }
106
+ parents.add(value);
107
+ try {
108
+ if (Array.isArray(value)) {
109
+ return "[\"array\",[" + value.map(
110
+ (item, index) => canonicalJson(item, path + "[" + index + "]", parents),
111
+ ).join(",") + "]]";
112
+ }
113
+ const proto = Object.getPrototypeOf(value);
114
+ if (proto !== Object.prototype && proto !== null) {
115
+ throw new Error("execution identity contains a non-JSON object at " + path);
116
+ }
117
+ const keys = Object.keys(value).sort(compareStrings);
118
+ if (keys.some(hasLoneSurrogate)) {
119
+ throw new Error("execution identity contains an invalid Unicode object key at " + path);
120
+ }
121
+ return "[\"object\",[" + keys.map((key) => (
122
+ "[" + JSON.stringify(key) + "," +
123
+ canonicalJson(value[key], path + "." + key, parents) + "]"
124
+ )).join(",") + "]]";
125
+ } finally {
126
+ parents.delete(value);
127
+ }
128
+ }
129
+
130
+ function digest(domain, value) {
131
+ return createHash("sha256")
132
+ .update(domain, "utf8")
133
+ .update(canonicalJson(value), "utf8")
134
+ .digest("hex");
135
+ }
136
+
137
+ export const canonicalObservationSha256 = (value) =>
138
+ digest(OBSERVATION_DOMAIN, value);
139
+
140
+ export const executionDescriptorSha256 = (value) =>
141
+ digest(EXECUTION_DOMAIN, value);
142
+
143
+ export const isLowerSha256 = (value) =>
144
+ typeof value === "string" && LOWER_SHA256.test(value);
145
+
146
+ /**
147
+ * A startup observation is a closed, adapter-authored copy of the four host
148
+ * facts runtime observation reads. Invalid or lossy evidence abstains from a
149
+ * registration fingerprint without disabling exact runtime observations.
150
+ */
151
+ function normalizeStartupObservation(raw) {
152
+ try {
153
+ if (raw === undefined || raw === null) return null;
154
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
155
+ const proto = Object.getPrototypeOf(raw);
156
+ if (proto !== Object.prototype && proto !== null) return null;
157
+ const keys = Object.keys(raw);
158
+ if (
159
+ keys.length !== STARTUP_OBSERVATION_KEYS.length
160
+ || keys.some((key) => !STARTUP_OBSERVATION_KEY_SET.has(key))
161
+ ) return null;
162
+ if (typeof raw.systemPrompt !== "string" || !raw.systemPrompt) return null;
163
+ if (!Array.isArray(raw.tools)) return null;
164
+ if (typeof raw.provider !== "string" || !raw.provider.trim()) return null;
165
+ if (typeof raw.model !== "string" || !raw.model.trim()) return null;
166
+ const copied = structuredClone({
167
+ systemPrompt: raw.systemPrompt,
168
+ tools: raw.tools,
169
+ provider: raw.provider,
170
+ model: raw.model,
171
+ });
172
+ return freezeJsonTree(copied);
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
178
+ /** Validate adapter-owned evidence without assigning meaning to its names. */
179
+ export function normalizeExecutionSnapshot(raw) {
180
+ if (raw === undefined || raw === null) {
181
+ return {
182
+ snapshot: null,
183
+ reason: "no execution snapshot was supplied by the host adapter",
184
+ };
185
+ }
186
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
187
+ return { snapshot: null, reason: "executionSnapshot must be an object" };
188
+ }
189
+ const adapterName = typeof raw.adapterName === "string"
190
+ ? raw.adapterName.trim()
191
+ : "";
192
+ if (!adapterName) {
193
+ return { snapshot: null, reason: "executionSnapshot.adapterName is required" };
194
+ }
195
+ if (hasLoneSurrogate(adapterName)) {
196
+ return {
197
+ snapshot: null,
198
+ reason: "executionSnapshot.adapterName contains invalid Unicode",
199
+ };
200
+ }
201
+ if (raw.executionIdentityComplete !== true) {
202
+ return {
203
+ snapshot: null,
204
+ reason: "the host adapter did not attest a complete execution snapshot",
205
+ };
206
+ }
207
+ const supplied = raw.executionComponents;
208
+ if (!supplied || typeof supplied !== "object" || Array.isArray(supplied)) {
209
+ return {
210
+ snapshot: null,
211
+ reason: "executionSnapshot.executionComponents must be an object",
212
+ };
213
+ }
214
+ const components = {};
215
+ for (const name of Object.keys(supplied).sort(compareStrings)) {
216
+ if (!name.trim() || hasLoneSurrogate(name)) {
217
+ return { snapshot: null, reason: "execution component names must be non-empty" };
218
+ }
219
+ if (name === "execution_sha256") {
220
+ return {
221
+ snapshot: null,
222
+ reason: "execution components must not author execution_sha256",
223
+ };
224
+ }
225
+ if (!isLowerSha256(supplied[name])) {
226
+ return {
227
+ snapshot: null,
228
+ reason: `execution component ${JSON.stringify(name)} is not lowercase SHA-256`,
229
+ };
230
+ }
231
+ components[name] = supplied[name];
232
+ }
233
+ if (Object.keys(components).length === 0) {
234
+ return {
235
+ snapshot: null,
236
+ reason: "a complete execution snapshot needs at least one component digest",
237
+ };
238
+ }
239
+ const startupObservation = normalizeStartupObservation(raw.startupObservation);
240
+ return {
241
+ snapshot: Object.freeze({
242
+ adapterName,
243
+ executionComponents: Object.freeze(components),
244
+ executionIdentityComplete: true,
245
+ startupObservation,
246
+ }),
247
+ reason: null,
248
+ };
249
+ }
250
+
251
+ /** Whether one host observation can support an exact identity. */
252
+ function exactObservation(event, ctx) {
253
+ const record = buildRecord(event, ctx);
254
+ const rawTools = event?.tools;
255
+ if (!Array.isArray(rawTools)) return null;
256
+ const tools = normalizeTools(rawTools);
257
+ // Dropping one malformed tool from identity would make two different host
258
+ // catalogues collapse to the same digest while the public description still
259
+ // looked healthy. Discovery may remain advisory; identity must abstain.
260
+ if (tools.length !== rawTools.length) return null;
261
+ if (typeof event?.systemPrompt !== "string" || !event.systemPrompt) return null;
262
+ if (typeof event?.provider !== "string" || !event.provider.trim()) return null;
263
+ if (typeof event?.model !== "string" || !event.model.trim()) return null;
264
+ const incomplete = record.incomplete ?? [];
265
+ if (incomplete.some((item) => item !== "tool_annotations")) return null;
266
+ return {
267
+ discovered: record,
268
+ // Hash before the public/JSONB projection scrubs strings and normalises
269
+ // host tool envelopes. The projection remains useful downstream, but its
270
+ // deliberate loss must never make two observed executions share an exact
271
+ // identity. Unsupported/cyclic host values abstain in canonicalJson.
272
+ full: {
273
+ system_prompt: event.systemPrompt,
274
+ tools: rawTools,
275
+ provider: event.provider,
276
+ model: event.model,
277
+ },
278
+ };
279
+ }
280
+
281
+ function runKey(event, ctx) {
282
+ const runId = event?.runId ?? ctx?.runId;
283
+ return typeof runId === "string" && runId.length > 0
284
+ ? `run:${runId}`
285
+ : null;
286
+ }
287
+
288
+ /**
289
+ * Reconcile observations and retain the answer only for the run that made it.
290
+ *
291
+ * A single run seeing two descriptors is permanently conflicted in memory.
292
+ * This is intentional: last-write-wins would retroactively label the earlier
293
+ * calls with whichever prompt/model happened to be observed last.
294
+ */
295
+ export function createExecutionIdentityTracker({
296
+ executionSnapshot = null,
297
+ mode,
298
+ agentId,
299
+ } = {}) {
300
+ const normalized = normalizeExecutionSnapshot(executionSnapshot);
301
+ const byContext = new Map();
302
+ const conflicted = new Set();
303
+
304
+ /**
305
+ * One canonical descriptor path for startup registration and runtime hooks.
306
+ */
307
+ function evidenceFromObservation(event, ctx = {}) {
308
+ if (normalized.snapshot === null) return null;
309
+ let observed;
310
+ try {
311
+ observed = exactObservation(event, ctx);
312
+ } catch {
313
+ // Host and adapter observations are untrusted. Cyclic or exotic tool
314
+ // schemas make exact identity unknowable; they remain an abstention.
315
+ return null;
316
+ }
317
+ if (observed === null) return null;
318
+ try {
319
+ const observation = canonicalObservationSha256(observed.full);
320
+ const execution = executionDescriptorSha256({
321
+ schema: "percepteye.agent_execution/v1",
322
+ sdk: SDK_UA,
323
+ contract_version: CONTRACT_VERSION,
324
+ entrypoint: {
325
+ kind: "host_plugin",
326
+ framework: "openclaw",
327
+ agent_id: agentId,
328
+ adapter: normalized.snapshot.adapterName,
329
+ },
330
+ configuration: {
331
+ mode,
332
+ reports_tool_calls: true,
333
+ },
334
+ discovered_agent: observed.discovered,
335
+ discovery_observation_sha256: observation,
336
+ attested_components: normalized.snapshot.executionComponents,
337
+ });
338
+ return Object.freeze({
339
+ fingerprint: Object.freeze({ execution_sha256: execution }),
340
+ description: freezeJsonTree(observed.discovered),
341
+ });
342
+ } catch {
343
+ return null;
344
+ }
345
+ }
346
+
347
+ // Computed ONCE. A later config-object mutation cannot move the worker
348
+ // identity after registration, and callers receive only a frozen value.
349
+ const registrationContext = typeof agentId === "string" && agentId.trim()
350
+ ? { agentId }
351
+ : {};
352
+ const registrationEvidence = normalized.snapshot === null
353
+ ? null
354
+ : evidenceFromObservation(
355
+ normalized.snapshot.startupObservation, registrationContext,
356
+ );
357
+
358
+ function bind(key, digestValue) {
359
+ if (conflicted.has(key)) return;
360
+ const prior = byContext.get(key);
361
+ if (prior === undefined || prior === digestValue) {
362
+ byContext.set(key, digestValue);
363
+ while (byContext.size > MAX_PENDING_IDENTITIES) {
364
+ byContext.delete(byContext.keys().next().value);
365
+ }
366
+ return;
367
+ }
368
+ byContext.delete(key);
369
+ conflicted.add(key);
370
+ while (conflicted.size > MAX_PENDING_IDENTITIES) {
371
+ conflicted.delete(conflicted.values().next().value);
372
+ }
373
+ }
374
+
375
+ function fingerprintFor(event, ctx = {}) {
376
+ const key = runKey(event, ctx);
377
+ if (key === null || conflicted.has(key)) return null;
378
+ const value = byContext.get(key);
379
+ return value === undefined ? null : { execution_sha256: value };
380
+ }
381
+
382
+ function consumeFingerprintFor(event, ctx = {}) {
383
+ const key = runKey(event, ctx);
384
+ if (key === null) return null;
385
+ const fingerprint = fingerprintFor(event, ctx);
386
+ byContext.delete(key);
387
+ conflicted.delete(key);
388
+ return fingerprint;
389
+ }
390
+
391
+ function observe(event, ctx = {}) {
392
+ const key = runKey(event, ctx);
393
+ if (key === null) return null;
394
+ // Once a stable registration exists, every later observation must use its
395
+ // configured agent name rather than a run/session-shaped fallback. If
396
+ // startup evidence abstained, retain the legacy runtime descriptor path.
397
+ const observationContext = registrationEvidence !== null
398
+ && typeof agentId === "string" && agentId.trim()
399
+ ? { ...ctx, agentId }
400
+ : ctx;
401
+ // A startup-capable adapter owns the exact fields its host cannot expose
402
+ // per run. Reconcile, do not overwrite: every field the runtime does
403
+ // expose wins and therefore changes/conflicts the digest; only an absent
404
+ // field is supplied from the registered snapshot. Requiring at least one
405
+ // runtime field prevents a bare run id from inheriting a startup identity.
406
+ let observedEvent = event;
407
+ if (registrationEvidence !== null) {
408
+ const startup = normalized.snapshot.startupObservation;
409
+ const observedAny = STARTUP_OBSERVATION_KEYS.some(
410
+ (name) => event?.[name] !== undefined,
411
+ );
412
+ if (!observedAny) return null;
413
+ observedEvent = { ...event };
414
+ for (const name of STARTUP_OBSERVATION_KEYS) {
415
+ if (observedEvent[name] === undefined) observedEvent[name] = startup[name];
416
+ }
417
+ }
418
+ const candidate = evidenceFromObservation(observedEvent, observationContext);
419
+ if (candidate === null) return null;
420
+ bind(key, candidate.fingerprint.execution_sha256);
421
+ const fingerprint = fingerprintFor(event, ctx);
422
+ return {
423
+ fingerprint,
424
+ // The caller persists a monotonic conflict marker. Returning only null
425
+ // here would leave an earlier sidecar in place and falsely label the
426
+ // mixed turn with the first descriptor.
427
+ conflict: fingerprint === null && conflicted.has(key),
428
+ };
429
+ }
430
+
431
+ return {
432
+ observe,
433
+ fingerprintFor,
434
+ consumeFingerprintFor,
435
+ get registrationFingerprint() {
436
+ return registrationEvidence?.fingerprint ?? null;
437
+ },
438
+ get registrationDescription() {
439
+ return registrationEvidence?.description ?? null;
440
+ },
441
+ get enabled() { return normalized.snapshot !== null; },
442
+ get refusal() { return normalized.reason; },
443
+ };
444
+ }
package/src/host.js ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * What this host permits, and what its events mean.
3
+ *
4
+ * ONE ANSWER PER QUESTION, and this module exists because there were briefly
5
+ * two. Both lanes -- the training rollout driver and production turn capture --
6
+ * need the agent's answer off `agent_end`, and both must know whether this host
7
+ * will deliver that hook at all. Each grew its own copy: `lastAssistantText`
8
+ * and `conversationAccessGranted` were written twice, in `rollout.js` and
9
+ * `capture.js`, with the same intent and slightly different code. That is the
10
+ * shape this codebase keeps finding, and it fails the same way every time --
11
+ * one copy gets fixed.
12
+ *
13
+ * Neither lane may import the other (production must not reach into the
14
+ * training driver, and vice versa), so the shared answers live here.
15
+ */
16
+
17
+ /**
18
+ * Does the host permit this plugin the CONVERSATION hooks?
19
+ *
20
+ * THE HOST'S OWN PREDICATE, not a paraphrase. `registerTypedHook` drops a
21
+ * conversation hook -- `agent_end` among them -- for any plugin whose origin is
22
+ * not `bundled` unless `allowConversationAccess === true`, and the refusal is a
23
+ * bare `return`: `api.on()` reports nothing either way, so this cannot be
24
+ * detected after the fact. This package is non-bundled by construction (it is
25
+ * installed from npm), so the origin half is always true and only the explicit
26
+ * opt-in is in question.
27
+ *
28
+ * Read from `api.config`, which is the same config object the host's registry
29
+ * consults.
30
+ */
31
+ export function conversationAccessGranted(api, pluginId) {
32
+ return api?.config?.plugins?.entries?.[pluginId]
33
+ ?.hooks?.allowConversationAccess === true;
34
+ }
35
+
36
+ /**
37
+ * The last assistant message's text, or `""` when there is none.
38
+ *
39
+ * `""` rather than a guess. A turn whose answer we never saw is a real state
40
+ * that both lanes handle explicitly -- the rollout driver gives the work back,
41
+ * and capture stores the turn with a null `final_text` for the control plane to
42
+ * grade advisory. Inventing an answer here would remove that distinction from
43
+ * both at once.
44
+ *
45
+ * Handles both content shapes because the host uses both: a plain string, and
46
+ * an array of parts each carrying `text`.
47
+ */
48
+ export function lastAssistantText(messages) {
49
+ if (!Array.isArray(messages)) return "";
50
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
51
+ const m = messages[i];
52
+ if (!m || typeof m !== "object" || m.role !== "assistant") continue;
53
+ const { content } = m;
54
+ if (typeof content === "string" && content.trim()) return content;
55
+ if (Array.isArray(content)) {
56
+ const text = content
57
+ .filter((p) => p && typeof p === "object" && typeof p.text === "string")
58
+ .map((p) => p.text).join("");
59
+ if (text.trim()) return text;
60
+ }
61
+ }
62
+ return "";
63
+ }