@mobileaidev/ai-app-bridge 0.2.15 → 0.3.0-rc.2

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.
Files changed (156) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +7 -0
  3. package/README.md +306 -53
  4. package/bin/ai-app-bridge.js +51 -4504
  5. package/bin/android-permissions.js +152 -0
  6. package/bin/android-uia-xml.js +74 -0
  7. package/bin/artifact-paths.js +127 -1
  8. package/bin/bridge-forward.js +56 -0
  9. package/bin/command-discovery.js +86 -0
  10. package/bin/command-errors.js +60 -0
  11. package/bin/command-registry.js +504 -0
  12. package/bin/command-request.js +14 -0
  13. package/bin/command-router.js +80 -0
  14. package/bin/connection-cache.js +119 -0
  15. package/bin/device-provider.js +2934 -0
  16. package/bin/execution-host.js +596 -0
  17. package/bin/execution-runtime.js +101 -0
  18. package/bin/fact-codec.js +321 -0
  19. package/bin/fact-recorder.js +691 -0
  20. package/bin/fact-store.js +226 -0
  21. package/bin/feedback-probe.js +285 -0
  22. package/bin/intent/install-intent.js +267 -0
  23. package/bin/intent/intent-action-executor.js +129 -0
  24. package/bin/intent/intent-autonomous-adapter.js +46 -0
  25. package/bin/intent/intent-capture-port.js +64 -0
  26. package/bin/intent/intent-entry.js +226 -0
  27. package/bin/intent/intent-errors.js +28 -0
  28. package/bin/intent/intent-evidence-store.js +121 -0
  29. package/bin/intent/intent-lifetime.js +62 -0
  30. package/bin/intent/intent-observation-target.js +33 -0
  31. package/bin/intent/intent-observer.js +194 -0
  32. package/bin/intent/intent-production-adapter.js +334 -0
  33. package/bin/intent/intent-provider.js +27 -0
  34. package/bin/intent/intent-runtime.js +63 -0
  35. package/bin/intent/intent-worker.js +432 -0
  36. package/bin/intent/ios-intent-adapter.js +90 -0
  37. package/bin/intent/permission-intent.js +239 -0
  38. package/bin/intent/web-intent-adapter.js +31 -0
  39. package/bin/ios-device-outcome.js +47 -0
  40. package/bin/ios-execution.js +108 -0
  41. package/bin/ios-provider.js +497 -583
  42. package/bin/ios-runtime-binding.js +54 -0
  43. package/bin/ios-wda-execution.js +70 -0
  44. package/bin/ios-wda-port.js +98 -0
  45. package/bin/ios-wda-project.js +79 -0
  46. package/bin/mcp-server.js +83 -1040
  47. package/bin/mmap-scan-index.js +329 -0
  48. package/bin/observation-collector.js +862 -0
  49. package/bin/runtime-client.js +154 -0
  50. package/bin/runtime-directory.js +107 -0
  51. package/bin/runtime-protocol.js +36 -0
  52. package/bin/script/bounded-script-registry.js +103 -0
  53. package/bin/script/node-runtime-adapter.js +233 -0
  54. package/bin/script/progress-projector.js +63 -0
  55. package/bin/script/python-runtime-adapter.js +111 -0
  56. package/bin/script/rolling-summary.js +134 -0
  57. package/bin/script/script-agent-port.js +40 -0
  58. package/bin/script/script-assert.js +95 -0
  59. package/bin/script/script-capture-port.js +76 -0
  60. package/bin/script/script-catalog.js +85 -0
  61. package/bin/script/script-durable-restore.js +195 -0
  62. package/bin/script/script-entry-code.js +26 -0
  63. package/bin/script/script-entry-route.js +38 -0
  64. package/bin/script/script-entry.js +3 -0
  65. package/bin/script/script-errors.js +29 -0
  66. package/bin/script/script-evidence-store.js +22 -0
  67. package/bin/script/script-format-removed.js +26 -0
  68. package/bin/script/script-host-port.js +397 -0
  69. package/bin/script/script-ledger.js +64 -0
  70. package/bin/script/script-result.js +57 -0
  71. package/bin/script/script-sdk.js +152 -0
  72. package/bin/script/script-sdk.py +153 -0
  73. package/bin/script/script-session-channel.js +127 -0
  74. package/bin/script/script-spec.js +86 -0
  75. package/bin/script/script-supervisor.js +919 -0
  76. package/bin/script/templates/checkpoint-reentry.js +13 -0
  77. package/bin/segment-index.js +481 -0
  78. package/bin/segmented-fact-store.js +1571 -0
  79. package/bin/shared-kernel/android-h5-target.js +10 -0
  80. package/bin/shared-kernel/android-install-execution.js +176 -0
  81. package/bin/shared-kernel/android-sdk-endpoint.js +42 -0
  82. package/bin/shared-kernel/android-shell-execution.js +195 -0
  83. package/bin/shared-kernel/argument-schema.js +117 -0
  84. package/bin/shared-kernel/canonical-path.js +17 -0
  85. package/bin/shared-kernel/device-acknowledgements.js +53 -0
  86. package/bin/shared-kernel/device-completion-history.js +52 -0
  87. package/bin/shared-kernel/device-mutation-lease.js +219 -0
  88. package/bin/shared-kernel/device-ownership-recovery.js +95 -0
  89. package/bin/shared-kernel/device-ownership-store.js +94 -0
  90. package/bin/shared-kernel/evidence-adapters.js +251 -0
  91. package/bin/shared-kernel/evidence-archive.js +329 -0
  92. package/bin/shared-kernel/evidence-recording.js +131 -0
  93. package/bin/shared-kernel/evidence-schema.js +194 -0
  94. package/bin/shared-kernel/evidence-store.js +190 -0
  95. package/bin/shared-kernel/execution-admission.js +22 -0
  96. package/bin/shared-kernel/execution-contracts.js +171 -0
  97. package/bin/shared-kernel/execution-io.js +106 -0
  98. package/bin/shared-kernel/execution-ledger.js +125 -0
  99. package/bin/shared-kernel/execution-scope.js +87 -0
  100. package/bin/shared-kernel/execution-target.js +115 -0
  101. package/bin/shared-kernel/flutter-execution.js +13 -0
  102. package/bin/shared-kernel/flutter-h5-port.js +60 -0
  103. package/bin/shared-kernel/flutter-h5-target.js +9 -0
  104. package/bin/shared-kernel/flutter-target.js +75 -0
  105. package/bin/shared-kernel/h5-execution.js +11 -0
  106. package/bin/shared-kernel/h5-target.js +31 -0
  107. package/bin/shared-kernel/host-fact-store.js +49 -0
  108. package/bin/shared-kernel/ios-h5-target.js +9 -0
  109. package/bin/shared-kernel/ios-native-target.js +71 -0
  110. package/bin/shared-kernel/live-capture-query.js +115 -0
  111. package/bin/shared-kernel/managed-sdk-execution.js +78 -0
  112. package/bin/shared-kernel/native-execution.js +13 -0
  113. package/bin/shared-kernel/native-target.js +156 -0
  114. package/bin/shared-kernel/provider-command-contracts.js +55 -0
  115. package/bin/shared-kernel/recorded-payload-archive.js +195 -0
  116. package/bin/shared-kernel/request-context.js +35 -0
  117. package/bin/shared-kernel/semantic-node.js +55 -0
  118. package/bin/shared-kernel/summary-transformer.js +352 -0
  119. package/bin/shared-kernel/target-lease-protocol.js +47 -0
  120. package/bin/shared-kernel/text-wait.js +111 -0
  121. package/bin/shared-kernel/uia-execution.js +96 -0
  122. package/bin/shared-kernel/uia-protocol.js +214 -0
  123. package/bin/shared-kernel/uia-runtime-port.js +377 -0
  124. package/bin/shared-kernel/uia-target.js +39 -0
  125. package/bin/shared-kernel/web-dom-target.js +44 -0
  126. package/bin/shared-kernel/xml-attributes.js +25 -0
  127. package/bin/target-execution.js +275 -0
  128. package/bin/web/command-schema.js +60 -0
  129. package/bin/web/session-store.js +157 -0
  130. package/bin/web-provider.js +334 -553
  131. package/docs/COMMAND_CONTRACT.md +1563 -0
  132. package/docs/EVIDENCE_ARCHIVE.md +214 -0
  133. package/docs/INTENT_FOREGROUND.md +71 -0
  134. package/docs/INTENT_NATIVE_EDITING.md +79 -0
  135. package/docs/RELEASE.md +59 -0
  136. package/docs/SCRIPT_AUTHORING.md +489 -0
  137. package/node_modules/@mobileaidev/segmented-fact-store-native/LICENSE +201 -0
  138. package/node_modules/@mobileaidev/segmented-fact-store-native/NOTICE +7 -0
  139. package/node_modules/@mobileaidev/segmented-fact-store-native/binding.gyp +36 -0
  140. package/node_modules/@mobileaidev/segmented-fact-store-native/bindings/node/sfs_node.c +597 -0
  141. package/node_modules/@mobileaidev/segmented-fact-store-native/include/sfs.h +178 -0
  142. package/node_modules/@mobileaidev/segmented-fact-store-native/index.js +5 -0
  143. package/node_modules/@mobileaidev/segmented-fact-store-native/package.json +24 -0
  144. package/node_modules/@mobileaidev/segmented-fact-store-native/src/sfs.c +2349 -0
  145. package/package.json +60 -5
  146. package/runtime/ios-wda/AABWDABinding.h +19 -0
  147. package/runtime/ios-wda/AABWDABinding.m +97 -0
  148. package/runtime/ios-wda/AABWDAExecution.h +26 -0
  149. package/runtime/ios-wda/AABWDAExecution.m +172 -0
  150. package/runtime/ios-wda/AABWDAIntegration.h +71 -0
  151. package/runtime/ios-wda/AABWDAManagedRoutes.h +392 -0
  152. package/runtime/ios-wda/AABWDAReceiptStore.h +10 -0
  153. package/runtime/ios-wda/AABWDAReceiptStore.m +116 -0
  154. package/runtime/uia/ai-app-bridge-uia.jar +0 -0
  155. package/runtime/uia/manifest.json +22 -0
  156. package/skills/ai-app-bridge-use/SKILL.md +23 -360
@@ -0,0 +1,862 @@
1
+ 'use strict';
2
+
3
+ const { spawn: spawnProcess } = require('node:child_process');
4
+
5
+ const defaultDeviceLogBuffers = ['main', 'system', 'crash'];
6
+ const sensitiveDeviceLogBuffers = new Set(['radio', 'security', 'kernel']);
7
+ const allowedDeviceLogBuffers = new Set([...defaultDeviceLogBuffers, ...sensitiveDeviceLogBuffers]);
8
+
9
+ class ObservationCollector {
10
+ constructor({
11
+ rawRunner,
12
+ recordEvidence,
13
+ recordDeviceLog,
14
+ spawn = spawnProcess,
15
+ clock = Date.now,
16
+ timers = globalThis,
17
+ pollIntervalMs = 1_000,
18
+ statusIntervalMs = 5_000,
19
+ initialBackoffMs = 1_000,
20
+ maxBackoffMs = 30_000,
21
+ deviceLogFlushMs = 250,
22
+ deviceLogBatchLines = 100,
23
+ deviceLogBatchBytes = 64 * 1024,
24
+ maxDeviceLogQueueLines = 2_000,
25
+ maxDeviceLogQueueBytes = 1024 * 1024,
26
+ maxDeviceLogPendingBatches = 8,
27
+ deviceLogRestartMs = 1_000,
28
+ maxDeviceLogRestartMs = 30_000,
29
+ actionTimelineLimit = 64,
30
+ actionTimelineTtlMs = 30_000,
31
+ maxTargets = 32,
32
+ inactiveTargetTtlMs = 30 * 60 * 1_000,
33
+ } = {}) {
34
+ if (typeof rawRunner !== 'function') throw new TypeError('rawRunner must be a function');
35
+ if (typeof recordEvidence !== 'function') throw new TypeError('recordEvidence must be a function');
36
+ if (typeof recordDeviceLog !== 'function') throw new TypeError('recordDeviceLog must be a function');
37
+ if (typeof spawn !== 'function') throw new TypeError('spawn must be a function');
38
+ if (typeof clock !== 'function') throw new TypeError('clock must be a function');
39
+ if (!timers || typeof timers.setTimeout !== 'function' || typeof timers.clearTimeout !== 'function') {
40
+ throw new TypeError('timers must provide setTimeout and clearTimeout');
41
+ }
42
+
43
+ this.rawRunner = rawRunner;
44
+ this.recordEvidence = recordEvidence;
45
+ this.recordDeviceLog = recordDeviceLog;
46
+ this.spawn = spawn;
47
+ this.clock = clock;
48
+ this.timers = timers;
49
+ this.pollIntervalMs = positiveNumber(pollIntervalMs, 'pollIntervalMs');
50
+ this.statusIntervalMs = positiveNumber(statusIntervalMs, 'statusIntervalMs');
51
+ this.initialBackoffMs = positiveNumber(initialBackoffMs, 'initialBackoffMs');
52
+ this.maxBackoffMs = positiveNumber(maxBackoffMs, 'maxBackoffMs');
53
+ this.deviceLogFlushMs = positiveNumber(deviceLogFlushMs, 'deviceLogFlushMs');
54
+ this.deviceLogBatchLines = positiveInteger(deviceLogBatchLines, 'deviceLogBatchLines');
55
+ this.deviceLogBatchBytes = positiveInteger(deviceLogBatchBytes, 'deviceLogBatchBytes');
56
+ this.maxDeviceLogQueueLines = positiveInteger(maxDeviceLogQueueLines, 'maxDeviceLogQueueLines');
57
+ this.maxDeviceLogQueueBytes = positiveInteger(maxDeviceLogQueueBytes, 'maxDeviceLogQueueBytes');
58
+ this.maxDeviceLogPendingBatches = positiveInteger(maxDeviceLogPendingBatches, 'maxDeviceLogPendingBatches');
59
+ this.deviceLogRestartMs = positiveNumber(deviceLogRestartMs, 'deviceLogRestartMs');
60
+ this.maxDeviceLogRestartMs = positiveNumber(maxDeviceLogRestartMs, 'maxDeviceLogRestartMs');
61
+ this.actionTimelineLimit = positiveInteger(actionTimelineLimit, 'actionTimelineLimit');
62
+ this.actionTimelineTtlMs = positiveNumber(actionTimelineTtlMs, 'actionTimelineTtlMs');
63
+ this.maxTargets = positiveInteger(maxTargets, 'maxTargets');
64
+ this.inactiveTargetTtlMs = positiveNumber(inactiveTargetTtlMs, 'inactiveTargetTtlMs');
65
+ this.running = false;
66
+ this.targets = new Map();
67
+ this.deviceLogs = new Map();
68
+ this.retiredDeviceLogDrains = new Set();
69
+ this.targetEvictions = 0;
70
+ this.targetExpirations = 0;
71
+ }
72
+
73
+ register(command, args = {}) {
74
+ const target = observationTargetFor(command, args);
75
+ if (!target) {
76
+ return { registered: false, ignored: true, reason: 'observable_target_required' };
77
+ }
78
+ const now = this.clock();
79
+ this.pruneInactiveTargets(now);
80
+ const existing = this.targets.get(target.key);
81
+ if (existing) {
82
+ existing.args = { ...existing.args, ...target.args };
83
+ if (target.deviceLogScope === 'device') {
84
+ existing.deviceLogScope = 'device';
85
+ existing.deviceLogBuffers = orderedDeviceLogBuffers([
86
+ ...existing.deviceLogBuffers,
87
+ ...target.deviceLogBuffers,
88
+ ]);
89
+ }
90
+ existing.lastRegisteredAtMs = now;
91
+ existing.updatedAtMs = now;
92
+ this.registerDeviceLogTarget(existing);
93
+ return { registered: false, target: publicTarget(existing) };
94
+ }
95
+
96
+ while (this.targets.size >= this.maxTargets) {
97
+ const leastRecentlyUsed = [...this.targets.values()].sort(compareTargetActivity)[0];
98
+ if (!leastRecentlyUsed || !this.removeTarget(leastRecentlyUsed, 'evicted')) break;
99
+ }
100
+ const state = {
101
+ ...target,
102
+ registeredAtMs: now,
103
+ lastRegisteredAtMs: now,
104
+ updatedAtMs: now,
105
+ lastStatusAtMs: null,
106
+ lastPollAtMs: null,
107
+ lastSuccessAtMs: null,
108
+ nextPollAtMs: null,
109
+ runtimeEpoch: null,
110
+ generationChanges: 0,
111
+ failureCount: 0,
112
+ lastError: null,
113
+ lastActionId: null,
114
+ lastActionAtMs: null,
115
+ actionTimeline: [],
116
+ pollTimer: null,
117
+ expiryTimer: null,
118
+ polling: false,
119
+ };
120
+ this.targets.set(state.key, state);
121
+ this.registerDeviceLogTarget(state);
122
+ if (this.running) this.startTarget(state);
123
+ return { registered: true, target: publicTarget(state) };
124
+ }
125
+
126
+ noteAction(target, actionId, timings = {}) {
127
+ const key = targetKeyValue(target);
128
+ const state = key ? this.targets.get(key) : null;
129
+ if (!state) return false;
130
+ const now = this.clock();
131
+ const normalizedActionId = actionId === undefined || actionId === null ? null : String(actionId);
132
+ state.lastActionId = normalizedActionId;
133
+ state.lastActionAtMs = now;
134
+ state.lastRegisteredAtMs = now;
135
+ state.updatedAtMs = now;
136
+ if (normalizedActionId === null) return true;
137
+
138
+ let action = state.actionTimeline.find((candidate) => candidate.actionId === normalizedActionId);
139
+ if (!action) {
140
+ action = {
141
+ actionId: normalizedActionId,
142
+ requestedAtMs: null,
143
+ startedAtMs: null,
144
+ completedAtMs: null,
145
+ touchedAtMs: now,
146
+ };
147
+ state.actionTimeline.push(action);
148
+ }
149
+ for (const field of ['requestedAtMs', 'startedAtMs', 'completedAtMs']) {
150
+ const value = optionalTimestamp(timings?.[field]);
151
+ if (value !== null) action[field] = value;
152
+ }
153
+ if (
154
+ action.requestedAtMs === null
155
+ && action.startedAtMs === null
156
+ && action.completedAtMs === null
157
+ ) {
158
+ action.requestedAtMs = now;
159
+ }
160
+ action.touchedAtMs = now;
161
+ this.pruneActionTimeline(state, now);
162
+ return true;
163
+ }
164
+
165
+ start() {
166
+ if (this.running) return this.status();
167
+ this.running = true;
168
+ for (const target of this.targets.values()) {
169
+ this.startTarget(target);
170
+ }
171
+ return this.status();
172
+ }
173
+
174
+ async stop() {
175
+ this.running = false;
176
+ for (const target of this.targets.values()) {
177
+ if (target.pollTimer !== null) this.timers.clearTimeout(target.pollTimer);
178
+ if (target.expiryTimer !== null) this.timers.clearTimeout(target.expiryTimer);
179
+ target.pollTimer = null;
180
+ target.expiryTimer = null;
181
+ target.nextPollAtMs = null;
182
+ }
183
+ for (const deviceLog of this.deviceLogs.values()) {
184
+ if (deviceLog.flushTimer !== null) this.timers.clearTimeout(deviceLog.flushTimer);
185
+ if (deviceLog.restartTimer !== null) this.timers.clearTimeout(deviceLog.restartTimer);
186
+ deviceLog.flushTimer = null;
187
+ deviceLog.restartTimer = null;
188
+ if (deviceLog.partialLine) {
189
+ this.enqueueDeviceLogLine(deviceLog, deviceLog.partialLine);
190
+ deviceLog.partialLine = '';
191
+ }
192
+ while (deviceLog.lines.length > 0) this.flushDeviceLog(deviceLog, false);
193
+ if (deviceLog.child && typeof deviceLog.child.kill === 'function') deviceLog.child.kill();
194
+ deviceLog.child = null;
195
+ }
196
+ await Promise.allSettled([
197
+ ...[...this.deviceLogs.values()].map((deviceLog) => deviceLog.drainPromise).filter(Boolean),
198
+ ...this.retiredDeviceLogDrains,
199
+ ]);
200
+ return this.status();
201
+ }
202
+
203
+ status() {
204
+ return {
205
+ running: this.running,
206
+ targetCount: this.targets.size,
207
+ maxTargets: this.maxTargets,
208
+ inactiveTargetTtlMs: this.inactiveTargetTtlMs,
209
+ targetEvictions: this.targetEvictions,
210
+ targetExpirations: this.targetExpirations,
211
+ targets: [...this.targets.values()].map((target) => {
212
+ const actionTimeline = this.actionTimelineSnapshot(target);
213
+ return {
214
+ ...publicTarget(target),
215
+ ...(target.kind === 'android' ? { deviceLog: this.targetDeviceLogStatus(target) } : {}),
216
+ runtimeEpoch: target.runtimeEpoch,
217
+ failureCount: target.failureCount,
218
+ lastError: target.lastError,
219
+ lastActionId: target.lastActionId,
220
+ actionTimeline,
221
+ backgroundPolling: target.kind !== 'android',
222
+ expiresAtMs: target.lastRegisteredAtMs + this.inactiveTargetTtlMs,
223
+ lastPollAtMs: target.lastPollAtMs,
224
+ lastSuccessAtMs: target.lastSuccessAtMs,
225
+ nextPollAtMs: target.nextPollAtMs,
226
+ generationChanges: target.generationChanges,
227
+ };
228
+ }),
229
+ deviceLogs: [...this.deviceLogs.values()].map((deviceLog) => ({
230
+ serial: deviceLog.serial,
231
+ buffers: [...deviceLog.buffers],
232
+ running: Boolean(deviceLog.child),
233
+ targetKeys: [...deviceLog.targetKeys],
234
+ queuedLines: deviceLog.lines.length,
235
+ queuedBytes: deviceLog.queuedBytes,
236
+ partialBytes: Buffer.byteLength(deviceLog.partialLine, 'utf8'),
237
+ pendingBatches: deviceLog.pendingBatches.length,
238
+ droppedLines: deviceLog.droppedLines,
239
+ droppedBytes: deviceLog.droppedBytes,
240
+ droppedBatches: deviceLog.droppedBatches,
241
+ restartFailures: deviceLog.restartFailures,
242
+ nextRestartAtMs: deviceLog.nextRestartAtMs,
243
+ lastError: deviceLog.lastError,
244
+ })),
245
+ dropped: {
246
+ deviceLogLines: sumDeviceLogMetric(this.deviceLogs, 'droppedLines'),
247
+ deviceLogBytes: sumDeviceLogMetric(this.deviceLogs, 'droppedBytes'),
248
+ deviceLogBatches: sumDeviceLogMetric(this.deviceLogs, 'droppedBatches'),
249
+ },
250
+ };
251
+ }
252
+
253
+ targetDeviceLogStatus(target) {
254
+ if (target.deviceLogScope !== 'device') {
255
+ return { enabled: false, reason: 'device_scope_opt_in_required' };
256
+ }
257
+ if (!target.serial) return { enabled: false, reason: 'serial_required' };
258
+ const deviceLog = this.deviceLogs.get(target.serial);
259
+ if (!deviceLog) return { enabled: false, reason: 'not_registered' };
260
+ return {
261
+ enabled: true,
262
+ running: Boolean(deviceLog.child),
263
+ serial: target.serial,
264
+ buffers: [...deviceLog.buffers],
265
+ };
266
+ }
267
+
268
+ startTarget(target) {
269
+ if (target.kind === 'android') {
270
+ // Android evidence is read explicitly. An unsolicited SDK connection can
271
+ // block ADB while a background app is being launched; TTL needs no I/O.
272
+ this.scheduleTargetExpiry(target);
273
+ } else {
274
+ this.schedulePoll(target, 0);
275
+ }
276
+ this.startDeviceLogForTarget(target);
277
+ }
278
+
279
+ scheduleTargetExpiry(target) {
280
+ if (!this.running || target.expiryTimer !== null || this.targets.get(target.key) !== target) return;
281
+ const delayMs = Math.max(0, target.lastRegisteredAtMs + this.inactiveTargetTtlMs - this.clock());
282
+ target.expiryTimer = this.timers.setTimeout(() => {
283
+ target.expiryTimer = null;
284
+ if (!this.running || this.targets.get(target.key) !== target) return;
285
+ if (this.targetExpired(target)) this.removeTarget(target, 'expired');
286
+ else this.scheduleTargetExpiry(target);
287
+ }, delayMs);
288
+ }
289
+
290
+ schedulePoll(target, delayMs) {
291
+ if (
292
+ !this.running
293
+ || target.pollTimer !== null
294
+ || this.targets.get(target.key) !== target
295
+ ) return;
296
+ target.nextPollAtMs = this.clock() + delayMs;
297
+ target.pollTimer = this.timers.setTimeout(() => {
298
+ target.pollTimer = null;
299
+ target.nextPollAtMs = null;
300
+ void this.pollTarget(target);
301
+ }, delayMs);
302
+ }
303
+
304
+ async pollTarget(target) {
305
+ if (!this.running || target.polling || this.targets.get(target.key) !== target) return;
306
+ if (this.targetExpired(target, this.clock())) {
307
+ this.removeTarget(target, 'expired');
308
+ return;
309
+ }
310
+ target.polling = true;
311
+ target.lastPollAtMs = this.clock();
312
+ try {
313
+ if (target.lastStatusAtMs === null || this.clock() - target.lastStatusAtMs >= this.statusIntervalMs) {
314
+ await this.pullStatus(target);
315
+ }
316
+ target.failureCount = 0;
317
+ target.lastError = null;
318
+ target.lastSuccessAtMs = this.clock();
319
+ target.updatedAtMs = this.clock();
320
+ this.schedulePoll(target, this.pollIntervalMs);
321
+ } catch (error) {
322
+ target.failureCount += 1;
323
+ target.lastError = firstErrorLine(error);
324
+ target.updatedAtMs = this.clock();
325
+ const delayMs = Math.min(
326
+ this.maxBackoffMs,
327
+ this.initialBackoffMs * (2 ** Math.max(0, target.failureCount - 1)),
328
+ );
329
+ this.schedulePoll(target, delayMs);
330
+ } finally {
331
+ target.polling = false;
332
+ }
333
+ }
334
+
335
+ async pullStatus(target) {
336
+ const command = commandFor(target.kind, 'status');
337
+ const result = await this.rawRunner(command, { ...target.args });
338
+ assertSuccessfulResult(command, result);
339
+ const runtimeEpoch = runtimeEpochFor(target.kind, result);
340
+ const changed = target.runtimeEpoch !== null
341
+ && runtimeEpoch !== null
342
+ && target.runtimeEpoch !== runtimeEpoch;
343
+ if (changed) {
344
+ target.generationChanges += 1;
345
+ }
346
+ if (runtimeEpoch !== null) target.runtimeEpoch = runtimeEpoch;
347
+ target.lastStatusAtMs = this.clock();
348
+ await this.recordEvidence(command, { ...target.args }, result, this.evidenceContext(target, {
349
+ generationChanged: changed,
350
+ sinceId: null,
351
+ }));
352
+ }
353
+
354
+ evidenceContext(target, extra = {}) {
355
+ const observedAtMs = this.clock();
356
+ return {
357
+ collector: 'observation',
358
+ target: publicTarget(target),
359
+ targetKey: target.key,
360
+ runtimeEpoch: target.runtimeEpoch,
361
+ actionId: target.lastActionId,
362
+ actionTimeline: this.actionTimelineSnapshot(target, observedAtMs),
363
+ observedAtMs,
364
+ ingestedAtMs: observedAtMs,
365
+ ...extra,
366
+ };
367
+ }
368
+
369
+ pruneActionTimeline(target, now = this.clock()) {
370
+ const oldestAllowedAtMs = now - this.actionTimelineTtlMs;
371
+ target.actionTimeline = target.actionTimeline.filter((action) => action.touchedAtMs >= oldestAllowedAtMs);
372
+ if (target.actionTimeline.length > this.actionTimelineLimit) {
373
+ target.actionTimeline.splice(0, target.actionTimeline.length - this.actionTimelineLimit);
374
+ }
375
+ if (
376
+ target.lastActionId !== null
377
+ && !target.actionTimeline.some((action) => action.actionId === target.lastActionId)
378
+ ) {
379
+ target.lastActionId = null;
380
+ target.lastActionAtMs = null;
381
+ }
382
+ }
383
+
384
+ actionTimelineSnapshot(target, now = this.clock()) {
385
+ this.pruneActionTimeline(target, now);
386
+ return target.actionTimeline.map(publicActionTiming);
387
+ }
388
+
389
+ actionTimelineForTargets(targetKeys, now = this.clock()) {
390
+ return targetKeys.flatMap((key) => {
391
+ const target = this.targets.get(key);
392
+ return target ? this.actionTimelineSnapshot(target, now) : [];
393
+ });
394
+ }
395
+
396
+ targetExpired(target, now = this.clock()) {
397
+ return now - target.lastRegisteredAtMs >= this.inactiveTargetTtlMs;
398
+ }
399
+
400
+ pruneInactiveTargets(now = this.clock()) {
401
+ for (const target of [...this.targets.values()]) {
402
+ if (this.targetExpired(target, now)) this.removeTarget(target, 'expired');
403
+ }
404
+ }
405
+
406
+ removeTarget(targetOrKey, reason) {
407
+ const target = typeof targetOrKey === 'string'
408
+ ? this.targets.get(targetOrKey)
409
+ : targetOrKey;
410
+ if (!target || this.targets.get(target.key) !== target) return false;
411
+ if (target.pollTimer !== null) this.timers.clearTimeout(target.pollTimer);
412
+ if (target.expiryTimer !== null) this.timers.clearTimeout(target.expiryTimer);
413
+ target.pollTimer = null;
414
+ target.expiryTimer = null;
415
+ target.nextPollAtMs = null;
416
+ this.targets.delete(target.key);
417
+ if (reason === 'evicted') this.targetEvictions += 1;
418
+ if (reason === 'expired') this.targetExpirations += 1;
419
+ if (target.kind === 'android' && target.serial) {
420
+ const deviceLog = this.deviceLogs.get(target.serial);
421
+ if (deviceLog) {
422
+ deviceLog.targetKeys.delete(target.key);
423
+ if (deviceLog.targetKeys.size === 0) this.retireDeviceLog(deviceLog);
424
+ }
425
+ }
426
+ return true;
427
+ }
428
+
429
+ retireDeviceLog(deviceLog) {
430
+ if (deviceLog.flushTimer !== null) this.timers.clearTimeout(deviceLog.flushTimer);
431
+ if (deviceLog.restartTimer !== null) this.timers.clearTimeout(deviceLog.restartTimer);
432
+ deviceLog.flushTimer = null;
433
+ deviceLog.restartTimer = null;
434
+ deviceLog.nextRestartAtMs = null;
435
+ if (deviceLog.partialLine) {
436
+ this.enqueueDeviceLogLine(deviceLog, deviceLog.partialLine);
437
+ deviceLog.partialLine = '';
438
+ }
439
+ while (deviceLog.lines.length > 0) this.flushDeviceLog(deviceLog, false);
440
+ const drainPromise = deviceLog.drainPromise;
441
+ if (drainPromise) {
442
+ this.retiredDeviceLogDrains.add(drainPromise);
443
+ void drainPromise.then(
444
+ () => this.retiredDeviceLogDrains.delete(drainPromise),
445
+ () => this.retiredDeviceLogDrains.delete(drainPromise),
446
+ );
447
+ }
448
+ const child = deviceLog.child;
449
+ deviceLog.child = null;
450
+ if (child && typeof child.kill === 'function') child.kill();
451
+ this.deviceLogs.delete(deviceLog.serial);
452
+ }
453
+
454
+ registerDeviceLogTarget(target) {
455
+ if (target.kind !== 'android' || target.deviceLogScope !== 'device' || !target.serial) return;
456
+ let deviceLog = this.deviceLogs.get(target.serial);
457
+ if (!deviceLog) {
458
+ deviceLog = {
459
+ serial: target.serial,
460
+ adbPath: target.args.adbPath || target.args.adb || 'adb',
461
+ buffers: [...defaultDeviceLogBuffers],
462
+ targetKeys: new Set(),
463
+ child: null,
464
+ partialLine: '',
465
+ lines: [],
466
+ queuedBytes: 0,
467
+ pendingBatches: [],
468
+ drainPromise: null,
469
+ flushTimer: null,
470
+ restartTimer: null,
471
+ restartFailures: 0,
472
+ nextRestartAtMs: null,
473
+ droppedLines: 0,
474
+ droppedBytes: 0,
475
+ droppedBatches: 0,
476
+ lastError: null,
477
+ };
478
+ this.deviceLogs.set(target.serial, deviceLog);
479
+ }
480
+ const previousBuffers = deviceLog.buffers.join(',');
481
+ deviceLog.targetKeys.add(target.key);
482
+ deviceLog.buffers = orderedDeviceLogBuffers([
483
+ ...deviceLog.buffers,
484
+ ...target.deviceLogBuffers,
485
+ ]);
486
+ if (
487
+ this.running
488
+ && deviceLog.child
489
+ && previousBuffers !== deviceLog.buffers.join(',')
490
+ ) {
491
+ const previousChild = deviceLog.child;
492
+ deviceLog.child = null;
493
+ if (typeof previousChild.kill === 'function') previousChild.kill();
494
+ deviceLog.restartFailures = 0;
495
+ this.startDeviceLogForTarget(target);
496
+ }
497
+ }
498
+
499
+ startDeviceLogForTarget(target) {
500
+ if (target.kind !== 'android' || target.deviceLogScope !== 'device' || !target.serial) return;
501
+ const deviceLog = this.deviceLogs.get(target.serial);
502
+ if (!deviceLog || deviceLog.child) return;
503
+ const args = [
504
+ '-s', deviceLog.serial,
505
+ 'logcat', '-v', 'epoch',
506
+ // Follow from the current tail instead of replaying the device's entire
507
+ // historical ring buffer into the persistent fact cache.
508
+ '-T', '1',
509
+ ...deviceLog.buffers.flatMap((buffer) => ['-b', buffer]),
510
+ ];
511
+ let child;
512
+ try {
513
+ child = this.spawn(deviceLog.adbPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
514
+ } catch (error) {
515
+ deviceLog.lastError = firstErrorLine(error);
516
+ this.scheduleDeviceLogRestart(deviceLog);
517
+ return;
518
+ }
519
+ if (!child || !child.stdout || typeof child.stdout.on !== 'function') {
520
+ deviceLog.lastError = 'logcat spawn returned no readable stdout';
521
+ this.scheduleDeviceLogRestart(deviceLog);
522
+ return;
523
+ }
524
+ deviceLog.child = child;
525
+ deviceLog.nextRestartAtMs = null;
526
+ deviceLog.lastError = null;
527
+ if (typeof child.stdout.setEncoding === 'function') child.stdout.setEncoding('utf8');
528
+ if (child.stderr && typeof child.stderr.setEncoding === 'function') child.stderr.setEncoding('utf8');
529
+ child.stdout.on('data', (chunk) => this.acceptDeviceLogChunk(deviceLog, chunk));
530
+ child.stderr?.on?.('data', (chunk) => {
531
+ const line = String(chunk || '').trim();
532
+ if (line) deviceLog.lastError = line.slice(0, 500);
533
+ });
534
+ child.on?.('error', (error) => {
535
+ deviceLog.lastError = firstErrorLine(error);
536
+ });
537
+ child.on?.('exit', (code, signal) => {
538
+ if (deviceLog.child !== child) return;
539
+ deviceLog.child = null;
540
+ if (!this.running) return;
541
+ deviceLog.lastError = `logcat exited (${code ?? 'null'}, ${signal || 'no signal'})`;
542
+ this.scheduleDeviceLogRestart(deviceLog);
543
+ });
544
+ }
545
+
546
+ scheduleDeviceLogRestart(deviceLog) {
547
+ if (!this.running || deviceLog.restartTimer !== null) return;
548
+ deviceLog.restartFailures += 1;
549
+ const delayMs = Math.min(
550
+ this.maxDeviceLogRestartMs,
551
+ this.deviceLogRestartMs * (2 ** Math.max(0, deviceLog.restartFailures - 1)),
552
+ );
553
+ deviceLog.nextRestartAtMs = this.clock() + delayMs;
554
+ deviceLog.restartTimer = this.timers.setTimeout(() => {
555
+ deviceLog.restartTimer = null;
556
+ deviceLog.nextRestartAtMs = null;
557
+ const target = [...deviceLog.targetKeys]
558
+ .map((key) => this.targets.get(key))
559
+ .find(Boolean);
560
+ if (target) this.startDeviceLogForTarget(target);
561
+ }, delayMs);
562
+ }
563
+
564
+ acceptDeviceLogChunk(deviceLog, chunk) {
565
+ if (!this.running) return;
566
+ deviceLog.restartFailures = 0;
567
+ const text = deviceLog.partialLine + String(chunk || '');
568
+ const parts = text.split(/\r?\n/);
569
+ deviceLog.partialLine = parts.pop() || '';
570
+ const partialBytes = Buffer.byteLength(deviceLog.partialLine, 'utf8');
571
+ if (partialBytes > this.deviceLogBatchBytes) {
572
+ const bounded = utf8Tail(deviceLog.partialLine, this.deviceLogBatchBytes);
573
+ deviceLog.droppedBytes += partialBytes - Buffer.byteLength(bounded, 'utf8');
574
+ deviceLog.partialLine = bounded;
575
+ }
576
+ for (const line of parts) {
577
+ if (line) this.enqueueDeviceLogLine(deviceLog, line);
578
+ }
579
+ if (deviceLog.lines.length >= this.deviceLogBatchLines || deviceLog.queuedBytes >= this.deviceLogBatchBytes) {
580
+ this.flushDeviceLog(deviceLog);
581
+ } else if (deviceLog.lines.length > 0 && deviceLog.flushTimer === null) {
582
+ deviceLog.flushTimer = this.timers.setTimeout(() => {
583
+ deviceLog.flushTimer = null;
584
+ this.flushDeviceLog(deviceLog);
585
+ }, this.deviceLogFlushMs);
586
+ }
587
+ }
588
+
589
+ enqueueDeviceLogLine(deviceLog, line) {
590
+ const entry = { text: line, bytes: Buffer.byteLength(line, 'utf8') + 1 };
591
+ deviceLog.lines.push(entry);
592
+ deviceLog.queuedBytes += entry.bytes;
593
+ while (
594
+ deviceLog.lines.length > this.maxDeviceLogQueueLines
595
+ || deviceLog.queuedBytes > this.maxDeviceLogQueueBytes
596
+ ) {
597
+ const dropped = deviceLog.lines.shift();
598
+ if (!dropped) break;
599
+ deviceLog.queuedBytes -= dropped.bytes;
600
+ deviceLog.droppedLines += 1;
601
+ deviceLog.droppedBytes += dropped.bytes;
602
+ }
603
+ }
604
+
605
+ flushDeviceLog(deviceLog, scheduleRemaining = true) {
606
+ if (deviceLog.flushTimer !== null) {
607
+ this.timers.clearTimeout(deviceLog.flushTimer);
608
+ deviceLog.flushTimer = null;
609
+ }
610
+ if (deviceLog.lines.length === 0) return;
611
+ const lines = [];
612
+ let bytes = 0;
613
+ while (deviceLog.lines.length > 0 && lines.length < this.deviceLogBatchLines) {
614
+ const next = deviceLog.lines[0];
615
+ if (lines.length > 0 && bytes + next.bytes > this.deviceLogBatchBytes) break;
616
+ deviceLog.lines.shift();
617
+ deviceLog.queuedBytes -= next.bytes;
618
+ lines.push(next.text);
619
+ bytes += next.bytes;
620
+ }
621
+ if (deviceLog.pendingBatches.length >= this.maxDeviceLogPendingBatches) {
622
+ deviceLog.droppedBatches += 1;
623
+ deviceLog.droppedLines += lines.length;
624
+ deviceLog.droppedBytes += bytes;
625
+ } else {
626
+ deviceLog.pendingBatches.push({ lines, bytes });
627
+ this.drainDeviceLog(deviceLog);
628
+ }
629
+ if (scheduleRemaining && deviceLog.lines.length > 0 && deviceLog.flushTimer === null) {
630
+ deviceLog.flushTimer = this.timers.setTimeout(() => {
631
+ deviceLog.flushTimer = null;
632
+ this.flushDeviceLog(deviceLog);
633
+ }, 0);
634
+ }
635
+ }
636
+
637
+ drainDeviceLog(deviceLog) {
638
+ if (deviceLog.drainPromise) return;
639
+ deviceLog.drainPromise = (async () => {
640
+ while (deviceLog.pendingBatches.length > 0) {
641
+ const batch = deviceLog.pendingBatches.shift();
642
+ const targetKeys = [...deviceLog.targetKeys];
643
+ const observedAtMs = this.clock();
644
+ const actionTimeline = this.actionTimelineForTargets(targetKeys, observedAtMs);
645
+ const actionId = latestActionIdFromTimeline(actionTimeline);
646
+ const buffers = [...deviceLog.buffers];
647
+ try {
648
+ await this.recordDeviceLog({
649
+ serial: deviceLog.serial,
650
+ deviceLogScope: 'device',
651
+ buffers,
652
+ }, {
653
+ lines: batch.lines,
654
+ count: batch.lines.length,
655
+ byteCount: batch.bytes,
656
+ observedAtMs,
657
+ buffers,
658
+ dropped: deviceLog.droppedLines,
659
+ }, {
660
+ collector: 'observation',
661
+ provider: 'android',
662
+ serial: deviceLog.serial,
663
+ targetKeys,
664
+ actionId,
665
+ actionTimeline,
666
+ runtimeEpoch: latestRuntimeEpoch(targetKeys, this.targets),
667
+ lineCount: batch.lines.length,
668
+ byteCount: batch.bytes,
669
+ observedAtMs,
670
+ ingestedAtMs: this.clock(),
671
+ droppedLines: deviceLog.droppedLines,
672
+ droppedBytes: deviceLog.droppedBytes,
673
+ droppedBatches: deviceLog.droppedBatches,
674
+ });
675
+ } catch (error) {
676
+ deviceLog.lastError = firstErrorLine(error);
677
+ }
678
+ }
679
+ })().finally(() => {
680
+ deviceLog.drainPromise = null;
681
+ if (deviceLog.pendingBatches.length > 0) this.drainDeviceLog(deviceLog);
682
+ });
683
+ }
684
+ }
685
+
686
+ function observationTargetFor(command, args = {}) {
687
+ const name = String(command || '');
688
+ if (name.startsWith('ios-')) {
689
+ const deviceId = stringPart(args.deviceId);
690
+ const bundleId = stringPart(args.bundleId);
691
+ // A device id alone identifies hardware, not an observable app runtime.
692
+ if (!bundleId && !args.runtimeUrl) return null;
693
+ return {
694
+ kind: 'ios',
695
+ key: identityKey('ios', deviceId, bundleId),
696
+ args: pickDefined(args, ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl']),
697
+ deviceId,
698
+ bundleId,
699
+ };
700
+ }
701
+ if (name.startsWith('web-')) {
702
+ // Web ingress commits authoritative facts before its SDK acknowledgement.
703
+ // Pulling those facts into a second observer would duplicate evidence and
704
+ // infer action ownership from whichever command happened to run last.
705
+ return null;
706
+ }
707
+ const packageName = stringPart(args.packageName);
708
+ if (!packageName) return null;
709
+ const serial = stringPart(args.serial);
710
+ return {
711
+ kind: 'android',
712
+ key: identityKey('android', serial, packageName),
713
+ args: pickDefined(args, ['serial', 'packageName', 'port', 'adb', 'adbPath']),
714
+ serial,
715
+ packageName,
716
+ deviceLogScope: normalizeDeviceLogScope(args.deviceLogScope),
717
+ deviceLogBuffers: normalizeDeviceLogBuffers(args.deviceLogBuffers),
718
+ };
719
+ }
720
+
721
+ function publicTarget(target) {
722
+ const base = { kind: target.kind, key: target.key };
723
+ if (target.kind === 'android') return { ...base, serial: target.serial, packageName: target.packageName };
724
+ return { ...base, deviceId: target.deviceId, bundleId: target.bundleId };
725
+ }
726
+
727
+ function targetKeyValue(target) {
728
+ if (typeof target === 'string') return target;
729
+ return target && typeof target === 'object' ? target.key : null;
730
+ }
731
+
732
+ function commandFor(kind, stream) {
733
+ if (kind === 'android') return stream;
734
+ return `${kind}-${stream}`;
735
+ }
736
+
737
+ function runtimeEpochFor(kind, result) {
738
+ const value = result?.debugBridge?.runtimeEpoch;
739
+ return value === undefined || value === null || value === '' ? null : String(value);
740
+ }
741
+
742
+ function assertSuccessfulResult(command, result) {
743
+ if (result && typeof result === 'object' && result.ok === false) {
744
+ throw new Error(`${command}: ${result.error || result.message || 'failed'}`);
745
+ }
746
+ }
747
+
748
+ function identityKey(kind, first, second) {
749
+ return `${kind}:${JSON.stringify([first, second])}`;
750
+ }
751
+
752
+ function stringPart(value) {
753
+ return value === undefined || value === null ? '' : String(value);
754
+ }
755
+
756
+ function pickDefined(source, keys) {
757
+ const result = {};
758
+ for (const key of keys) {
759
+ if (source[key] !== undefined && source[key] !== null && source[key] !== '') result[key] = source[key];
760
+ }
761
+ return result;
762
+ }
763
+
764
+ function positiveNumber(value, name) {
765
+ const number = Number(value);
766
+ if (!Number.isFinite(number) || number <= 0) throw new TypeError(`${name} must be a positive number`);
767
+ return number;
768
+ }
769
+
770
+ function positiveInteger(value, name) {
771
+ const number = Number(value);
772
+ if (!Number.isInteger(number) || number <= 0) throw new TypeError(`${name} must be a positive integer`);
773
+ return number;
774
+ }
775
+
776
+ function optionalTimestamp(value) {
777
+ if (value === undefined || value === null || value === '') return null;
778
+ const number = Number(value);
779
+ return Number.isSafeInteger(number) ? number : null;
780
+ }
781
+
782
+ function publicActionTiming(action) {
783
+ return {
784
+ actionId: action.actionId,
785
+ requestedAtMs: action.requestedAtMs,
786
+ startedAtMs: action.startedAtMs,
787
+ completedAtMs: action.completedAtMs,
788
+ };
789
+ }
790
+
791
+ function compareTargetActivity(left, right) {
792
+ return left.lastRegisteredAtMs - right.lastRegisteredAtMs
793
+ || left.registeredAtMs - right.registeredAtMs
794
+ || left.key.localeCompare(right.key);
795
+ }
796
+
797
+ function firstErrorLine(error) {
798
+ return String(error?.message || error || 'unknown error').split(/\r?\n/, 1)[0];
799
+ }
800
+
801
+ function normalizeDeviceLogBuffers(value) {
802
+ const requested = Array.isArray(value)
803
+ ? value
804
+ : String(value || '').split(',');
805
+ return orderedDeviceLogBuffers([
806
+ ...defaultDeviceLogBuffers,
807
+ ...requested.map((entry) => String(entry).trim()).filter(Boolean),
808
+ ]);
809
+ }
810
+
811
+ function normalizeDeviceLogScope(value) {
812
+ return String(value || '').trim().toLowerCase() === 'device' ? 'device' : null;
813
+ }
814
+
815
+ function orderedDeviceLogBuffers(values) {
816
+ const unique = new Set(values.filter((value) => allowedDeviceLogBuffers.has(value)));
817
+ return [...defaultDeviceLogBuffers, ...['radio', 'security', 'kernel']]
818
+ .filter((value) => unique.has(value));
819
+ }
820
+
821
+ function latestActionIdFromTimeline(actionTimeline) {
822
+ let selected = null;
823
+ let selectedAtMs = Number.NEGATIVE_INFINITY;
824
+ for (const action of actionTimeline) {
825
+ const atMs = action.completedAtMs ?? action.startedAtMs ?? action.requestedAtMs;
826
+ if (atMs === null || atMs < selectedAtMs) continue;
827
+ selected = action.actionId;
828
+ selectedAtMs = atMs;
829
+ }
830
+ return selected;
831
+ }
832
+
833
+ function latestRuntimeEpoch(targetKeys, targets) {
834
+ let selected = null;
835
+ let selectedAtMs = Number.NEGATIVE_INFINITY;
836
+ for (const key of targetKeys) {
837
+ const target = targets.get(key);
838
+ if (!target || target.runtimeEpoch === null || target.lastSuccessAtMs < selectedAtMs) continue;
839
+ selected = target.runtimeEpoch;
840
+ selectedAtMs = target.lastSuccessAtMs;
841
+ }
842
+ return selected;
843
+ }
844
+
845
+ function sumDeviceLogMetric(deviceLogs, name) {
846
+ let total = 0;
847
+ for (const deviceLog of deviceLogs.values()) total += deviceLog[name];
848
+ return total;
849
+ }
850
+
851
+ function utf8Tail(value, maxBytes) {
852
+ const bytes = Buffer.from(value, 'utf8');
853
+ if (bytes.length <= maxBytes) return value;
854
+ let start = bytes.length - maxBytes;
855
+ while (start < bytes.length && (bytes[start] & 0xC0) === 0x80) start += 1;
856
+ return bytes.subarray(start).toString('utf8');
857
+ }
858
+
859
+ module.exports = {
860
+ ObservationCollector,
861
+ observationTargetFor,
862
+ };