@trillboards/edge-sdk 0.2.140 → 0.2.150

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 (188) hide show
  1. package/README.md +2 -2
  2. package/deploy/detector/pyproject.toml +20 -2
  3. package/deploy/detector/tests/test_execution.py +124 -0
  4. package/deploy/detector/tests/test_model_identity.py +80 -0
  5. package/deploy/detector/tests/test_registry.py +18 -0
  6. package/deploy/detector/tests/test_server.py +3 -1
  7. package/deploy/detector/tests/test_version_stamp.py +4 -1
  8. package/deploy/detector/trillboards_detector/_version.py +2 -2
  9. package/deploy/detector/trillboards_detector/cli.py +3 -0
  10. package/deploy/detector/trillboards_detector/detections.py +7 -0
  11. package/deploy/detector/trillboards_detector/errors.py +4 -0
  12. package/deploy/detector/trillboards_detector/execution.py +109 -0
  13. package/deploy/detector/trillboards_detector/loader.py +6 -3
  14. package/deploy/detector/trillboards_detector/registry.py +27 -5
  15. package/deploy/detector/trillboards_detector/server.py +29 -2
  16. package/deploy/detector/trillboards_detector/session.py +15 -3
  17. package/deploy/litertlm/test_trillboards_litertlm_shim.py +81 -0
  18. package/deploy/litertlm/test_verify_model_proof.py +30 -0
  19. package/deploy/litertlm/trillboards_litertlm_shim.py +150 -1
  20. package/deploy/litertlm/verify_model_proof.py +30 -0
  21. package/deploy/macos/build-macos-pkg.sh +10 -10
  22. package/deploy/macos/scripts/setup-detector.sh +2 -2
  23. package/deploy/macos/scripts/setup-litertlm.sh +66 -30
  24. package/deploy/macos/test-setup-litertlm-reinstall.sh +57 -4
  25. package/deploy/systemd/setup-detector.sh +2 -2
  26. package/deploy/systemd/setup-litertlm.sh +51 -27
  27. package/deploy/windows/build-windows.ps1 +10 -6
  28. package/deploy/windows/bundle-windows-installer.ps1 +3 -3
  29. package/deploy/windows/e2e/test-local-sense.ps1 +6 -5
  30. package/deploy/windows/install-update.ps1 +52 -9
  31. package/deploy/windows/set-executable-icon.ps1 +24 -5
  32. package/deploy/windows/setup-detector.ps1 +142 -4
  33. package/deploy/windows/setup-litertlm.ps1 +53 -10
  34. package/dist/AudienceAnalyzeClient.d.ts +10 -0
  35. package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
  36. package/dist/AudienceAnalyzeClient.js +5 -4
  37. package/dist/AudienceAnalyzeClient.js.map +1 -1
  38. package/dist/CloudSensingManager.d.ts +38 -1
  39. package/dist/CloudSensingManager.d.ts.map +1 -1
  40. package/dist/CloudSensingManager.js +403 -25
  41. package/dist/CloudSensingManager.js.map +1 -1
  42. package/dist/DetectorFieldProducer.d.ts +5 -1
  43. package/dist/DetectorFieldProducer.d.ts.map +1 -1
  44. package/dist/DetectorFieldProducer.js +8 -1
  45. package/dist/DetectorFieldProducer.js.map +1 -1
  46. package/dist/DurableVlmCandidateSpool.d.ts +83 -2
  47. package/dist/DurableVlmCandidateSpool.d.ts.map +1 -1
  48. package/dist/DurableVlmCandidateSpool.js +596 -30
  49. package/dist/DurableVlmCandidateSpool.js.map +1 -1
  50. package/dist/EdgeAgent.js +1 -1
  51. package/dist/FrameGroupingShadow.d.ts +51 -0
  52. package/dist/FrameGroupingShadow.d.ts.map +1 -0
  53. package/dist/FrameGroupingShadow.js +158 -0
  54. package/dist/FrameGroupingShadow.js.map +1 -0
  55. package/dist/ImageClassifierFieldProducer.d.ts +36 -0
  56. package/dist/ImageClassifierFieldProducer.d.ts.map +1 -0
  57. package/dist/ImageClassifierFieldProducer.js +150 -0
  58. package/dist/ImageClassifierFieldProducer.js.map +1 -0
  59. package/dist/LiteRtVlmProvider.d.ts +5 -0
  60. package/dist/LiteRtVlmProvider.d.ts.map +1 -1
  61. package/dist/LiteRtVlmProvider.js +72 -7
  62. package/dist/LiteRtVlmProvider.js.map +1 -1
  63. package/dist/LocalDetectorClient.d.ts +6 -0
  64. package/dist/LocalDetectorClient.d.ts.map +1 -1
  65. package/dist/LocalDetectorClient.js +8 -0
  66. package/dist/LocalDetectorClient.js.map +1 -1
  67. package/dist/OrderedFrameReview.d.ts +57 -0
  68. package/dist/OrderedFrameReview.d.ts.map +1 -0
  69. package/dist/OrderedFrameReview.js +121 -0
  70. package/dist/OrderedFrameReview.js.map +1 -0
  71. package/dist/OrderedReviewClient.d.ts +38 -0
  72. package/dist/OrderedReviewClient.d.ts.map +1 -0
  73. package/dist/OrderedReviewClient.js +121 -0
  74. package/dist/OrderedReviewClient.js.map +1 -0
  75. package/dist/OrderedSequenceStore.d.ts +69 -0
  76. package/dist/OrderedSequenceStore.d.ts.map +1 -0
  77. package/dist/OrderedSequenceStore.js +257 -0
  78. package/dist/OrderedSequenceStore.js.map +1 -0
  79. package/dist/ProfileReviewWork.d.ts +68 -0
  80. package/dist/ProfileReviewWork.d.ts.map +1 -0
  81. package/dist/ProfileReviewWork.js +150 -0
  82. package/dist/ProfileReviewWork.js.map +1 -0
  83. package/dist/RetainedFieldContract.d.ts +21 -0
  84. package/dist/RetainedFieldContract.d.ts.map +1 -0
  85. package/dist/RetainedFieldContract.js +92 -0
  86. package/dist/RetainedFieldContract.js.map +1 -0
  87. package/dist/ReviewModelUse.d.ts +7 -0
  88. package/dist/ReviewModelUse.d.ts.map +1 -0
  89. package/dist/ReviewModelUse.js +30 -0
  90. package/dist/ReviewModelUse.js.map +1 -0
  91. package/dist/ReviewPriority.d.ts +12 -0
  92. package/dist/ReviewPriority.d.ts.map +1 -0
  93. package/dist/ReviewPriority.js +45 -0
  94. package/dist/ReviewPriority.js.map +1 -0
  95. package/dist/RuntimeModelSelection.d.ts +10 -0
  96. package/dist/RuntimeModelSelection.d.ts.map +1 -0
  97. package/dist/RuntimeModelSelection.js +58 -0
  98. package/dist/RuntimeModelSelection.js.map +1 -0
  99. package/dist/SceneChangeDetector.d.ts +1 -1
  100. package/dist/SceneChangeDetector.d.ts.map +1 -1
  101. package/dist/SceneChangeDetector.js +18 -6
  102. package/dist/SceneChangeDetector.js.map +1 -1
  103. package/dist/ScopedVlmReview.d.ts +76 -0
  104. package/dist/ScopedVlmReview.d.ts.map +1 -0
  105. package/dist/ScopedVlmReview.js +267 -0
  106. package/dist/ScopedVlmReview.js.map +1 -0
  107. package/dist/SensingProfileClient.d.ts +50 -0
  108. package/dist/SensingProfileClient.d.ts.map +1 -1
  109. package/dist/SensingProfileClient.js +58 -2
  110. package/dist/SensingProfileClient.js.map +1 -1
  111. package/dist/TemporalReviewClient.d.ts +43 -0
  112. package/dist/TemporalReviewClient.d.ts.map +1 -0
  113. package/dist/TemporalReviewClient.js +117 -0
  114. package/dist/TemporalReviewClient.js.map +1 -0
  115. package/dist/TemporalReviewJournal.d.ts +91 -0
  116. package/dist/TemporalReviewJournal.d.ts.map +1 -0
  117. package/dist/TemporalReviewJournal.js +337 -0
  118. package/dist/TemporalReviewJournal.js.map +1 -0
  119. package/dist/UrgentReviewTriage.d.ts +35 -0
  120. package/dist/UrgentReviewTriage.d.ts.map +1 -0
  121. package/dist/UrgentReviewTriage.js +52 -0
  122. package/dist/UrgentReviewTriage.js.map +1 -0
  123. package/dist/VideoSegmentUploader.d.ts +293 -0
  124. package/dist/VideoSegmentUploader.d.ts.map +1 -0
  125. package/dist/VideoSegmentUploader.js +1002 -0
  126. package/dist/VideoSegmentUploader.js.map +1 -0
  127. package/dist/VlmCandidateBuffer.d.ts +19 -0
  128. package/dist/VlmCandidateBuffer.d.ts.map +1 -1
  129. package/dist/VlmCandidateBuffer.js.map +1 -1
  130. package/dist/autoSense.d.ts +2 -2
  131. package/dist/autoSense.d.ts.map +1 -1
  132. package/dist/autoSense.js +16 -11
  133. package/dist/autoSense.js.map +1 -1
  134. package/dist/cli.js +1 -1
  135. package/dist/cli.js.map +1 -1
  136. package/dist/config.d.ts +2 -0
  137. package/dist/config.d.ts.map +1 -1
  138. package/dist/config.js.map +1 -1
  139. package/dist/continuousReview.d.ts +71 -0
  140. package/dist/continuousReview.d.ts.map +1 -0
  141. package/dist/continuousReview.js +53 -0
  142. package/dist/continuousReview.js.map +1 -0
  143. package/dist/detectorCountReview.d.ts +9 -0
  144. package/dist/detectorCountReview.d.ts.map +1 -0
  145. package/dist/detectorCountReview.js +79 -0
  146. package/dist/detectorCountReview.js.map +1 -0
  147. package/dist/diagnose.d.ts +1 -1
  148. package/dist/diagnose.js +1 -1
  149. package/dist/installerOnboardingMode.d.ts.map +1 -1
  150. package/dist/installerOnboardingMode.js +5 -3
  151. package/dist/installerOnboardingMode.js.map +1 -1
  152. package/dist/localInferenceSchema.d.ts +8 -0
  153. package/dist/localInferenceSchema.d.ts.map +1 -1
  154. package/dist/localInferenceSchema.js +3 -3
  155. package/dist/localInferenceSchema.js.map +1 -1
  156. package/dist/localSenseE2eHarness.d.ts +12 -0
  157. package/dist/localSenseE2eHarness.d.ts.map +1 -1
  158. package/dist/localSenseE2eHarness.js +77 -4
  159. package/dist/localSenseE2eHarness.js.map +1 -1
  160. package/dist/localSensing.d.ts +91 -18
  161. package/dist/localSensing.d.ts.map +1 -1
  162. package/dist/localSensing.js +678 -153
  163. package/dist/localSensing.js.map +1 -1
  164. package/dist/mixedScopedCounts.d.ts +17 -0
  165. package/dist/mixedScopedCounts.d.ts.map +1 -0
  166. package/dist/mixedScopedCounts.js +90 -0
  167. package/dist/mixedScopedCounts.js.map +1 -0
  168. package/dist/sensingCatchRate.d.ts +9 -5
  169. package/dist/sensingCatchRate.d.ts.map +1 -1
  170. package/dist/sensingCatchRate.js +10 -6
  171. package/dist/sensingCatchRate.js.map +1 -1
  172. package/dist/sensingRunMode.d.ts +5 -0
  173. package/dist/sensingRunMode.d.ts.map +1 -1
  174. package/dist/sensingRunMode.js +56 -0
  175. package/dist/sensingRunMode.js.map +1 -1
  176. package/package.json +7 -4
  177. package/retained/ArchiveLifecycle.cjs +79 -0
  178. package/retained/ModelStageScheduler.cjs +66 -0
  179. package/retained/ProfileAuthorityClient.cjs +88 -0
  180. package/retained/RetainedCaptureClient.cjs +164 -0
  181. package/retained/WaitRecheckCaptureClient.cjs +109 -0
  182. package/retained/__tests__/archive-lifecycle.test.cjs +117 -0
  183. package/retained/__tests__/runtime.test.cjs +384 -0
  184. package/retained/alert-rule-policy.cjs +37 -0
  185. package/retained/manager.cjs +133 -0
  186. package/retained/pilot-profile-client.cjs +82 -0
  187. package/retained/temporal-grouping.cjs +78 -0
  188. package/retained/website-api-capability.cjs +122 -0
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const assert = require('node:assert/strict');
5
+ const take = name => require('../dist/' + name);
6
+ const { checkRetainedApi } = require('./website-api-capability.cjs');
7
+
8
+ // The normal connector's website entry point. Capability, strict profile and
9
+ // artifact identity still pass before capture; this is not a second installer.
10
+ async function createWebsiteManager(input, stateDirectory, deps = {}) {
11
+ const config = JSON.parse(JSON.stringify(input));
12
+ const { buildLocalSensingLoops } = take('localSensing');
13
+ const { CloudSensingManager, resolveFeeds } = take('CloudSensingManager');
14
+ const { TemporalReviewClient } = take('TemporalReviewClient');
15
+ const { TemporalReviewJournal } = take('TemporalReviewJournal');
16
+ const { RetainedCaptureClient } = require('./RetainedCaptureClient.cjs');
17
+ const { WaitRecheckCaptureClient } = require('./WaitRecheckCaptureClient.cjs');
18
+ const { validateRule, contractFor } = require('./alert-rule-policy.cjs');
19
+ const { retainedReviewApiBase } = take('continuousReview');
20
+ const options = config.cloudSensing?.candidateBuffer?.continuousReview;
21
+ assert(options?.enabled === true && options.deliveryTarget === 'configured-api'
22
+ && options.independentUploads === true && options.reviewWindowMs === 150000, 'Explicit website pilot settings required');
23
+ const base = retainedReviewApiBase(options, config.apiBaseUrl);
24
+ assert(base && typeof config.localSensing?.model === 'string' && config.localSensing.model.trim(),
25
+ 'Use the paired API and an explicitly named local model; the artifact pin remains mandatory');
26
+ assert(config.localSensing.attachEvidenceFrame !== false,
27
+ 'Pilot requires the original evidence image');
28
+ const feeds = resolveFeeds(config);
29
+ const websiteManaged = config.pilot?.websiteManaged === true;
30
+ assert(feeds.length && (websiteManaged || Object.keys(config.pilot?.alertRules ?? {}).length === feeds.length),
31
+ 'Choose one explicit rule per active feed');
32
+ const capabilities = new Map();
33
+ // Complete every check before constructing cameras/providers. A failed gate
34
+ // does not start capture, submit historical images, or change account settings.
35
+ for (const feed of feeds) capabilities.set(feed.name, await (deps.capabilityCheck ?? checkRetainedApi)(base, feed));
36
+ fs.mkdirSync(stateDirectory, { recursive: true });
37
+ const grouping = { mode: 'shadow', separateProfileWork: true, pixelDelta: 12,
38
+ minChangedPixels: 12, quietIntervalMs: 10000, followUpMs: 1000 };
39
+ const groupingByFeed = new Map();
40
+ options.frameGrouping = grouping;
41
+ const pipeline = config.pilot.crossCameraPipelining === true;
42
+ assert(config.pilot.crossCameraPipelining === undefined || typeof config.pilot.crossCameraPipelining === 'boolean',
43
+ 'Explicit stage-pipeline choice required');
44
+ assert(!pipeline || !websiteManaged && feeds.length <= 2 && options.overlapMixedReview !== true && options.parallelModelReview !== true
45
+ && options.scopedVlmReview !== true && options.detectorCounts !== true && config.pilot.requiresDetector === true,
46
+ 'Stage pipeline is an isolated two-feed mixed-review experiment');
47
+ const stages = pipeline ? new (require('./ModelStageScheduler.cjs').ModelStageScheduler)() : null;
48
+ const loops = buildLocalSensingLoops(config, { ...deps, websiteManagedProfiles: websiteManaged,
49
+ profileClientFactory: deps.profileClientFactory ?? (feed => require('./pilot-profile-client.cjs').createPilotProfileClient(
50
+ { apiBaseUrl: config.apiBaseUrl, deviceToken: feed.deviceToken, screenId: feed.screenId },
51
+ { ...take('SensingProfileClient'), ...take('AudienceAnalyzeClient') }, deps.httpPoster)),
52
+ }), clients = [];
53
+ for (const loop of loops) {
54
+ const choice = websiteManaged ? null : config.pilot.alertRules[loop.name];
55
+ assert(websiteManaged || choice, 'Missing explicit feed rule');
56
+ // Ordinary website routes continue to own their conditions and delivery.
57
+ // No local guessed boolean/timer may suppress another saved route. The
58
+ // explicit rehearsal path retains its captured temporal rule unchanged.
59
+ const rule = websiteManaged ? { mode: 'immediate' } : validateRule(choice);
60
+ assert(['immediate', 'wait_recheck'].includes(rule.mode), 'Only immediate and wait/recheck are supported');
61
+ // Immediate rules need every changed view, not a redundant unchanged
62
+ // confirmation one second later. The existing temporal policy independently
63
+ // requires a real periodic review every five seconds. Wait/recheck keeps
64
+ // its original capture and timer semantics; do not group its observations.
65
+ const feedGrouping = { ...grouping, followUpMs: rule.mode === 'immediate' ? 5000 : grouping.followUpMs };
66
+ groupingByFeed.set(loop.name, feedGrouping);
67
+ let inner = loop.client;
68
+ await inner.prepareReview();
69
+ const context = inner.captureReviewContext();
70
+ const active = context.profileState !== 'inactive';
71
+ assert(websiteManaged || active, 'The explicitly selected pilot camera has no active profile');
72
+ assert(!active || context.modelSelection?.models?.length,'The active website profile has no pinned model selection');
73
+ for(const selected of context.modelSelection?.models ?? []){
74
+ if(selected.format==='litert-lm')assert(selected.sha256==='ab7838cdfc8f77e54d8ca45eadceb20452d9f01e4bfade03e5dce27911b27e42','Website selected a different Gemma artifact');
75
+ else assert(selected.format==='onnx'&&selected.modelId==='rfdetr_medium'
76
+ && selected.sha256==='8e8197112112b79d7cc5d0a94ecef659c05317a658aadc7c7643374c539d7615'
77
+ && config.pilot.requiresDetector===true,'The selected specialist requires an explicitly prepared runtime');
78
+ }
79
+ if (stages) {
80
+ assert(!context.detectorCounts && !context.scopedVlmReview && context.mixedModelExecution === undefined
81
+ && context.modelSelection.models.some(m => m.format === 'litert-lm')
82
+ && context.modelSelection.models.some(m => m.format === 'onnx'),
83
+ 'Stage pipeline requires a complete retained mixed profile before capture starts');
84
+ inner = stages.wrap(inner);
85
+ }
86
+ inner = new (require('./ProfileAuthorityClient.cjs').ProfileAuthorityClient)(inner);
87
+ const fields = context.retainedFieldContract?.required ?? context.scopedVlmReview?.fields ?? [];
88
+ if (!websiteManaged && (rule.mode === 'wait_recheck' || fields.some(field => field.type === 'boolean'))) {
89
+ assert(typeof choice.field === 'string' && fields.some(field => field.field === choice.field && field.type === 'boolean'),
90
+ 'Choose the exact paired boolean field, including its lasso field when applicable');
91
+ const basic = { version: 1, cameraId: loop.name, field: choice.field, mode: 'any',
92
+ minObservations: 1, durationMs: 0, maxGapMs: 2000, unknownPolicy: 'reset_confirmation_keep_alert_latch' };
93
+ // Both modes must go through the policy factory. Skipping it for an
94
+ // immediate boolean omitted groupedContinuity and queued every frame.
95
+ const contract = contractFor(basic, rule);
96
+ const journal = new TemporalReviewJournal(path.join(stateDirectory, 'journal-' + loop.name),100000,{retainAcknowledged:128});
97
+ journal.recoverAbandonedLocksUnderExclusiveHostLock();
98
+ inner = new TemporalReviewClient(inner, journal, contract, [], {
99
+ configuredApiBaseUrl: base, pairedCameraId: capabilities.get(loop.name).cameraId,
100
+ });
101
+ }
102
+ const Capture = rule.mode === 'wait_recheck' ? WaitRecheckCaptureClient : RetainedCaptureClient;
103
+ const client = new Capture(inner, path.join(stateDirectory, 'archive-' + loop.name), {
104
+ cameraId: loop.name, configuredApiBaseUrl: base, intervalMs: 500,
105
+ selectionMode: 'state-change-v1', groupingPolicy: feedGrouping,
106
+ maxCaptures: config.pilot.maxCapturesPerCamera, maxBytes: config.pilot.maxBytesPerCamera,
107
+ archiveRetentionMs: config.pilot.archiveRetentionMs ?? 600000,
108
+ offerWindow: rule.mode === 'wait_recheck' ? 1 : 16, unclearRetryMs: 2000,
109
+ });
110
+ loop.client = client; clients.push(client);
111
+ }
112
+ const manager = new CloudSensingManager({ loops, retainedApiBaseUrl: base, maxConcurrentAnalyses: pipeline ? 2 : 1, feedStaggerMs: 50,
113
+ periodicIntervalMs: 3600000, skipPostWhenNoFaces: false, motionGate: { enabled: true, scanIntervalMs: 500 },
114
+ candidateBuffer: { ...config.cloudSensing.candidateBuffer, diskSpool: {
115
+ directory: path.join(stateDirectory, 'spool'), maxFrames: 512, maxPerFeed: 128,
116
+ maxBytes: 512 * 1024 ** 2, preserveArchivedPending: true }, pumpIntervalMs: 50 } });
117
+ // The native manager constructs a shadow grouper per feed. Match it to the
118
+ // exact policy recorded in that feed's durable archive before capture starts.
119
+ const { FrameGroupingShadow } = take('FrameGroupingShadow');
120
+ assert(manager.frameGroupers instanceof Map, 'Native per-feed frame grouping is required');
121
+ for (const [name, policy] of groupingByFeed) {
122
+ assert(manager.frameGroupers.has(name), 'Missing native frame grouper');
123
+ manager.frameGroupers.set(name, new FrameGroupingShadow(policy));
124
+ }
125
+ const originalOffer = manager.candidates.offer.bind(manager.candidates);
126
+ manager.candidates.offer = candidate => {
127
+ const result = originalOffer(candidate);
128
+ loops.find(loop => loop.name === candidate.name).client.offered(candidate, result);
129
+ return result;
130
+ };
131
+ return { manager, clients, loops, ...(stages ? { stageScheduler: stages } : {}) };
132
+ }
133
+ module.exports = { createWebsiteManager };
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const { randomUUID } = require('node:crypto');
4
+ const { transportFailure } = require('./website-api-capability.cjs');
5
+ const delays = [250, 750];
6
+ async function nativeProfileRequest(poster, endpoint, request, body) {
7
+ for (let attempt = 1; ; attempt++) {
8
+ try {
9
+ const result = await poster(endpoint, { ...request, timeoutMs: Math.min(request.timeoutMs || 10000, 10000),
10
+ headers: { ...request.headers, 'X-Device-Timestamp': String(Date.now()), 'X-Device-Nonce': randomUUID() } }, body);
11
+ if (result.statusCode < 200 || result.statusCode >= 300) {
12
+ throw Object.assign(Error('Pilot API profile request failed (HTTP ' + result.statusCode
13
+ + '). Check pairing/account access or API availability.'), {
14
+ code: 'PILOT_HTTP_' + result.statusCode, retryable: [500, 502, 503, 504].includes(result.statusCode), safePilotError: true });
15
+ }
16
+ return result;
17
+ } catch (raw) {
18
+ const error = raw.safePilotError ? raw : transportFailure(raw, 'profile');
19
+ if (!error.retryable || attempt > delays.length) {
20
+ error.attempts = attempt;
21
+ error.message += ' Attempts: ' + attempt + '. No camera was started by this check.';
22
+ throw error;
23
+ }
24
+ await new Promise(resolve => setTimeout(resolve, delays[attempt - 1]));
25
+ }
26
+ }
27
+ }
28
+
29
+ // The native SDK parser still owns field, lasso and model validation. Keep its
30
+ // enterprise-aware HTTP poster: this patch does not bypass proxy or TLS trust.
31
+ function createPilotProfileClient(options, sdk, poster) {
32
+ const base = options.apiBaseUrl.replace(/\/+$/, '');
33
+ const url = new URL(base);
34
+ const loopback = url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname);
35
+ assert(!url.username && !url.password && !url.search && !url.hash
36
+ && (url.origin === 'https://api.trillboards.com' || loopback), 'Use the verified SenseAnything API');
37
+ assert(typeof options.screenId === 'string' && options.screenId, 'A paired camera is required');
38
+ const original = base + '/v2/earner/cameras/sensing-profile';
39
+ const endpoint = base + '/v2/earner/cctv-retained/sensing-profile?screen_id=' + encodeURIComponent(options.screenId);
40
+ const native = poster === undefined || poster === sdk.defaultHttpPoster;
41
+ poster ??= sdk.defaultHttpPoster;
42
+ return {
43
+ getEndpoint: () => endpoint,
44
+ async fetchProfile() {
45
+ // Per-fetch error state avoids mixing failures from concurrent callers.
46
+ let requestError = null;
47
+ let raw;
48
+ const client = new sdk.SensingProfileClient(options, async (url, request, body) => {
49
+ assert(url === original && request.method === 'GET', 'Unexpected pilot profile request');
50
+ let result;
51
+ try { result = native ? await nativeProfileRequest(poster, endpoint, request, body) : await poster(endpoint, request, body); }
52
+ catch (error) { requestError = error; throw error; }
53
+ if (result.statusCode >= 200 && result.statusCode < 300) {
54
+ try { raw = JSON.parse(result.body); } catch { throw Error('Invalid experimental profile JSON'); }
55
+ assert(raw.screenId === options.screenId && raw.retainedPilot?.version === 1
56
+ && raw.retainedPilot.reviewFieldSet === 'declared', 'The API did not return this camera\'s experimental profile');
57
+ }
58
+ return result;
59
+ });
60
+ const profile = await client.fetchProfile();
61
+ if (!profile) {
62
+ if (requestError) throw requestError;
63
+ throw Error('The paired API did not resolve this camera profile; no default model was selected');
64
+ }
65
+ if (raw.profileState === 'inactive') {
66
+ assert(raw.profileId === null && raw.profileName === null && raw.profileType === null
67
+ && Array.isArray(raw.fields) && raw.fields.length === 0 && raw.modelSelection === null
68
+ && ['vlm', 'producer', 'withheld', 'alertFocusRegions'].every(key =>
69
+ Array.isArray(raw.fieldRouting?.[key]) && raw.fieldRouting[key].length === 0),
70
+ 'Invalid inactive camera profile');
71
+ return { ...profile, profileState: 'inactive' };
72
+ }
73
+ assert(profile.modelSelection?.models?.length && !profile.modelSelection.invalid,
74
+ 'The API did not supply a valid pinned selection');
75
+ assert(profile.retainedFieldContract?.required?.length,
76
+ 'The API did not supply complete field ownership');
77
+ assert(profile.alertFocusRegionsValid !== false, 'The API supplied an invalid or missing camera area');
78
+ return { ...profile, profileState: 'active', websiteProfileType: profile.profileType, profileType: 'custom_intent' };
79
+ },
80
+ };
81
+ }
82
+ module.exports = { createPilotProfileClient };
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+ const assert=require('node:assert/strict'),fs=require('node:fs'),crypto=require('node:crypto');
3
+ const hash=x=>crypto.createHash('sha256').update(x).digest('hex');
4
+ const contextHash=c=>hash(JSON.stringify(c));
5
+
6
+ // Selection is capture-time work. It never invents a model answer for grouped images.
7
+ function selectTemporal(candidate,grouped,previous,lastSelected){
8
+ const context=candidate.reviewContext,contract=context.temporalReviewContract;
9
+ const policy=contract.groupedContinuity,at=candidate.enqueuedAtMs;
10
+ assert(policy.version===1 && policy.checkIntervalMs>=500 && policy.checkIntervalMs<=30000);
11
+ assert(policy.maxReviewGapMs>=policy.checkIntervalMs+contract.maxGapMs);
12
+ const rules=[contract,...(context.temporalComparisonContracts??[])];
13
+ assert(rules.every(r=>JSON.stringify(r.groupedContinuity)===JSON.stringify(policy)),'Temporal rules disagree on grouping');
14
+ const same=previous&&contextHash(previous.candidate.reviewContext)===contextHash(context);
15
+ const captureGap=!previous || at-previous.capturedAt>Math.min(...rules.map(r=>r.maxGapMs));
16
+ const scopes=candidate.groupingShadow?.scopes??[];
17
+ const valid=grouped.selectionReason==='group_selected'||grouped.selectionReason==='unchanged_with_saved_reference';
18
+ const comparisonUncertain=valid&&scopes.some(s=>s.reason==='uncertain');
19
+ const uncertain=!valid||comparisonUncertain;
20
+ const newSegment=!same||captureGap||uncertain||scopes.some(s=>['initial','changed'].includes(s.reason));
21
+ const segmentStartAtMs=newSegment?at:previous.temporalSchedule?.segmentStartAtMs??at;
22
+ const temporalSchedule={version:1,segmentStartAtMs};
23
+ // Missing grouping falls back to fresh reviews under the original timer rules.
24
+ // It cannot justify a coverage bridge, but definite, contiguous AI observations remain valid.
25
+ const base={temporalSchedule,temporalContinuityBroken:!same||captureGap||comparisonUncertain,temporalCoverageEligible:valid&&!comparisonUncertain};
26
+ const select=reason=>({...base,selected:true,disposition:'pending_review',selectionReason:reason});
27
+ if(!same||captureGap)return select('temporal_capture_or_context_boundary');
28
+ if(uncertain)return select('temporal_uncertain_comparison');
29
+ if(grouped.selected)return {...grouped,...base};
30
+ if(!lastSelected)return select('temporal_initial');
31
+ // Retain real model confirmations for entry and the existing clear/re-arm count.
32
+ const bootstrapCount=Math.max(...rules.map(r=>Math.max(r.minObservations,r.clearObservations??1)));
33
+ if(at<=segmentStartAtMs+(bootstrapCount-1)*500 && at-lastSelected.capturedAt>=500)
34
+ return select('temporal_confirmation');
35
+ if(rules.some(r=>r.mode==='duration'&&at>=segmentStartAtMs+r.durationMs&&lastSelected.capturedAt<segmentStartAtMs+r.durationMs))
36
+ return select('temporal_duration_checkpoint');
37
+ if(at-lastSelected.capturedAt>=policy.checkIntervalMs)return select('temporal_periodic_check');
38
+ return {...grouped,...base};
39
+ }
40
+
41
+ // Rebuild coverage from immutable capture records at review time, including after restart.
42
+ // It is local evidence, never an API-provided assertion or a reused AI answer.
43
+ function resolveTemporalCoverage(archive,current,contract){
44
+ if(!contract.groupedContinuity||!current.temporalCoverageEligible||current.temporalContinuityBroken||!current.previousSelectedId)return undefined;
45
+ const load=id=>{
46
+ assert(/^[a-f0-9]{32}$/.test(id),'Invalid coverage reference');
47
+ const r=JSON.parse(fs.readFileSync(archive.file(id),'utf8'));
48
+ assert(r.id===id&&r.cameraId===current.cameraId,'Coverage camera/identity mismatch');
49
+ assert(hash(fs.readFileSync(archive.image(r)))===r.imageSha256,'Coverage image missing or changed');
50
+ assert(contextHash(r.candidate.reviewContext)===contextHash(current.candidate.reviewContext),'Coverage context changed');
51
+ return r;
52
+ };
53
+ const from=load(current.previousSelectedId);
54
+ assert(from.selected&&from.capturedAt<current.capturedAt,'Invalid prior selected capture');
55
+ if(current.capturedAt-from.capturedAt>contract.groupedContinuity.maxReviewGapMs)return undefined;
56
+ const records=[current];let id=current.previousCaptureId;
57
+ while(id!==from.id){
58
+ assert(records.length<=128,'Coverage chain exceeds bounded window');
59
+ const r=load(id);
60
+ assert(!r.selected&&r.previousSelectedId===from.id,'Coverage crossed another selected review');
61
+ if(r.disposition!=='archived_grouped_unchanged'||r.temporalContinuityBroken)return undefined;
62
+ const verified=archive.groupingChoice(r.candidate,r.imageSha256);
63
+ assert(!verified.selected&&verified.selectionReason==='unchanged_with_saved_reference','Invalid grouped coverage');
64
+ records.push(r);id=r.previousCaptureId;
65
+ }
66
+ records.push(from);records.reverse();
67
+ let maxCaptureGapMs=0;
68
+ for(let i=1;i<records.length;i++){
69
+ const gap=records[i].capturedAt-records[i-1].capturedAt;
70
+ assert(gap>0,'Coverage capture order invalid');maxCaptureGapMs=Math.max(maxCaptureGapMs,gap);
71
+ }
72
+ if(maxCaptureGapMs>contract.maxGapMs)return undefined;
73
+ return {version:1,basis:'visually_unchanged_captures',fromEvidenceId:from.id,fromImageSha256:from.imageSha256,
74
+ fromCapturedAtMs:from.capturedAt,toCapturedAtMs:current.capturedAt,contextSha256:contextHash(current.candidate.reviewContext),
75
+ groupedCaptures:records.length-2,maxCaptureGapMs,
76
+ recordsSha256:hash(JSON.stringify(records.map(r=>({id:r.id,imageSha256:r.imageSha256,capturedAt:r.capturedAt,selected:r.selected,previousCaptureId:r.previousCaptureId,previousSelectedId:r.previousSelectedId}))))};
77
+ }
78
+ module.exports={selectTemporal,resolveTemporalCoverage};
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const { randomUUID } = require('node:crypto');
4
+ const http = require('node:http');
5
+ const https = require('node:https');
6
+
7
+ function endpoint(base) {
8
+ const url = new URL(base);
9
+ const local = ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname);
10
+ assert(!url.username && !url.password && !url.search && !url.hash
11
+ && (url.protocol === 'https:' || local && url.protocol === 'http:'), 'Invalid paired API URL');
12
+ return base.replace(/\/+$/, '') + '/v2/earner/cctv-retained/capabilities';
13
+ }
14
+ const TIMEOUT_MS = 10000;
15
+ const RETRY_DELAYS_MS = [250, 750];
16
+ const TRANSIENT_CODES = new Set(['ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED',
17
+ 'ECONNABORTED', 'EPIPE', 'EAI_AGAIN', 'ENOTFOUND', 'ENETUNREACH', 'EHOSTUNREACH']);
18
+ function failure(message, code, retryable = false) {
19
+ return Object.assign(Error(message), { code, retryable });
20
+ }
21
+ function transportFailure(error, label = 'capability') {
22
+ const nested = Array.isArray(error?.errors) ? error.errors.slice(0, 8) : [];
23
+ const codes = [...new Set([error?.code, error?.cause?.code,
24
+ ...(error?.failureClass === 'timeout' ? ['ETIMEDOUT'] : []), ...nested.map(item => item?.code)]
25
+ .filter(code => typeof code === 'string' && /^[A-Z0-9_]{1,64}$/.test(code)))];
26
+ if (!codes.length) codes.push('UNKNOWN_TRANSPORT_ERROR');
27
+ const tlsFailure = error?.failureClass?.startsWith('tls_') || codes.some(code => /TLS|CERT|SELF_SIGNED|UNABLE_TO_VERIFY|DEPTH_ZERO/.test(code));
28
+ const guidance = tlsFailure ? 'Check certificate trust and the computer clock; TLS verification was not bypassed.'
29
+ : 'Check network/DNS connectivity to api.trillboards.com.';
30
+ return failure('Pilot API ' + label + ' request failed (' + codes.join(', ') + '). ' + guidance,
31
+ codes[0], !tlsFailure && codes.some(code => TRANSIENT_CODES.has(code)));
32
+ }
33
+
34
+ // Bounded authenticated GETs before capture. Never print tokens, HTTP bodies or
35
+ // raw error messages, follow redirects, or weaken account/TLS/contract checks.
36
+ function requestCapability(url, feed) {
37
+ return new Promise((resolve, reject) => {
38
+ let settled = false, deadline;
39
+ const finish = (error, result) => {
40
+ if (settled) return;
41
+ settled = true; clearTimeout(deadline);
42
+ if (error) reject(error); else resolve(result);
43
+ };
44
+ let request;
45
+ try {
46
+ request = (url.protocol === 'https:' ? https : http).request(url, {
47
+ // Use the same enterprise proxy/trust transport as profiles/uploads,
48
+ // including SDK embedders that have not installed a global agent.
49
+ ...(url.protocol === 'https:' ? {
50
+ agent: require('../dist/enterpriseNetwork.js').getEnterpriseAgent(),
51
+ } : {}),
52
+ // Each retry gets a fresh timestamp and nonce.
53
+ method: 'GET', headers: { 'X-Device-Token': feed.deviceToken,
54
+ 'X-Trillboard-Device-Token': feed.deviceToken,
55
+ 'X-Device-Timestamp': String(Date.now()), 'X-Device-Nonce': randomUUID() },
56
+ }, response => {
57
+ let text = '', bytes = 0;
58
+ response.on('data', chunk => {
59
+ bytes += chunk.length;
60
+ if (bytes > 16384) {
61
+ finish(failure('Pilot API capability response too large', 'PILOT_RESPONSE_TOO_LARGE'));
62
+ response.destroy(); request.destroy();
63
+ } else text += chunk;
64
+ });
65
+ response.on('error', error => finish(transportFailure(error)));
66
+ response.on('aborted', () => finish(transportFailure({ code: 'ECONNRESET' })));
67
+ response.on('end', () => {
68
+ if (settled) return;
69
+ if (response.statusCode !== 200) {
70
+ const status = response.statusCode;
71
+ finish(failure('Pilot API is not enabled for this paired camera (HTTP ' + status
72
+ + '). Check pairing/account access or API availability.', 'PILOT_HTTP_' + status,
73
+ [500, 502, 503, 504].includes(status)));
74
+ return;
75
+ }
76
+ try {
77
+ let body;
78
+ try { body = JSON.parse(text); } catch { throw Error('Invalid pilot API capability JSON'); }
79
+ assert(body && body.retainedReview === true && body.protocolVersion === 1
80
+ && body.lateEvidenceAccepted === true && Array.isArray(body.timedModes)
81
+ && body.timedModes.includes('any') && body.timedModes.includes('wait_recheck')
82
+ && /^[A-Za-z0-9_-]{1,128}$/.test(body.cameraId)
83
+ && body.maxBodyBytes === 2 * 1024 * 1024, 'Unsupported pilot API capability');
84
+ assert(String(feed.screenId) === body.cameraId, 'Paired camera identity differs');
85
+ finish(null, { cameraId: body.cameraId, protocolVersion: 1 });
86
+ } catch (error) { finish(failure(error.message, 'PILOT_CAPABILITY_CONTRACT')); }
87
+ });
88
+ });
89
+ } catch (error) { finish(transportFailure(error)); return; }
90
+ const timeout = () => {
91
+ finish(transportFailure({ code: 'ETIMEDOUT' }));
92
+ request.destroy();
93
+ };
94
+ // A wall-clock deadline also covers DNS/TLS, not just socket inactivity.
95
+ deadline = setTimeout(timeout, TIMEOUT_MS);
96
+ request.setTimeout(TIMEOUT_MS, timeout);
97
+ request.on('error', error => finish(transportFailure(error)));
98
+ request.end();
99
+ });
100
+ }
101
+ function checkRetainedApi(base, feed) {
102
+ assert(typeof feed.deviceToken === 'string' && feed.deviceToken.length, 'Missing paired camera token');
103
+ assert(typeof feed.screenId === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(feed.screenId),
104
+ 'Missing or invalid paired camera identity');
105
+ const url = new URL(endpoint(base));
106
+ // Device authentication requires URL binding; token-only identity is invalid.
107
+ url.searchParams.set('screen_id', feed.screenId);
108
+ return (async () => {
109
+ for (let attempt = 1; ; attempt++) {
110
+ try { return await requestCapability(url, feed); }
111
+ catch (error) {
112
+ if (!error.retryable || attempt > RETRY_DELAYS_MS.length) {
113
+ error.attempts = attempt;
114
+ error.message += ' Attempts: ' + attempt + '. No camera was started by this check.';
115
+ throw error;
116
+ }
117
+ await new Promise(resolve => setTimeout(resolve, RETRY_DELAYS_MS[attempt - 1]));
118
+ }
119
+ }
120
+ })();
121
+ }
122
+ module.exports = { checkRetainedApi, endpoint, transportFailure };