@trillboards/edge-sdk 0.2.104 → 0.2.106

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.
@@ -133,6 +133,39 @@ states the box is in. The service does not exit when nothing was injected: all
133
133
  three supervisors restart on a non-zero exit, and this file's own rule for a
134
134
  required input that has not arrived is *start, and report the reason*.
135
135
 
136
+ #### Who actually supplies it — added 2026-08-26, because until then NOBODY did
137
+
138
+ This section described a parameter that existed on both spellings and was passed
139
+ on neither. `TRILLBOARDS_DETECTOR_RAM_BUDGET_MB` appeared in this file, in
140
+ `cli.py`, in `modelpath.py` and in the tests, and in **zero** of the six
141
+ supervisor definitions — so on every fielded box the "not injected" row of the
142
+ table above was not one of two states, it was the only one, and every delivered
143
+ specialist was permanently `idle`.
144
+
145
+ `ram-budget.js`, in this directory, is the supplier. It is run by the three
146
+ installers, not by this service — the ceiling must stay something this process is
147
+ TOLD — and it derives the number from the box rather than picking one, because
148
+ `docs/runbooks/sensing-model-library.md` §2 is arithmetic about a SET on a
149
+ specific machine and the two measured field boxes are 9.8 GB and 31.4 GB. It
150
+ subtracts the on-device VLM's measured working set, the connector process and the
151
+ capture lane from physical memory, floors the result at the pinned model's own
152
+ footprint (below that `ModelRegistry.__init__` raises and the supervisor
153
+ crash-loops, which is strictly worse than being constrained), and prints the whole
154
+ subtraction with `--explain` so an installer log says why.
155
+
156
+ Three transports, one number, because the three supervisors differ in what they
157
+ can carry:
158
+
159
+ | platform | how | why not the other one |
160
+ | --- | --- | --- |
161
+ | Linux | `TRILLBOARDS_DETECTOR_RAM_BUDGET_MB` in `/etc/trillboards/env` | the unit is a static file `install.sh` copies verbatim; there is nothing to template, and both units already declare `EnvironmentFile=-` |
162
+ | macOS | `--ram-budget-mb` in the plist's `ProgramArguments` | the plist IS generated by `setup-detector.sh`, from a template |
163
+ | Windows | `--ram-budget-mb` in the Scheduled Task's argument string | a Scheduled Task action has no environment block, and a User-scope variable would be global machine state |
164
+
165
+ On every platform, **failing to derive injects nothing** rather than a fallback —
166
+ the "not injected" row stays reachable, and it is the honest thing to report about
167
+ a box we could not measure.
168
+
136
169
  **Eviction is a last-resort safety valve, not the allocator.** It logs a WARNING
137
170
  naming the box as oversubscribed against what it was allocated, and `/health`
138
171
  carries a top-level `evictions` count — because a service quietly thrashing
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The detector's RAM ceiling, DERIVED FROM THIS BOX — run by the installer, for
4
+ * the supervisor to write down.
5
+ *
6
+ * ── Why this file exists ────────────────────────────────────────────────────
7
+ *
8
+ * `registry.py::_admit` refuses to make any non-pinned model resident unless a
9
+ * ceiling was injected, and says so out loud:
10
+ *
11
+ * cannot admit ['gh_forklift_combine'] — no RAM ceiling was injected, so this
12
+ * service has no basis for a portfolio decision and will not invent one.
13
+ *
14
+ * That refusal is correct. What was missing is the other half of it:
15
+ * `TRILLBOARDS_DETECTOR_RAM_BUDGET_MB` appeared in the detector's source, its
16
+ * README, its tests and its CHANGELOG, and in ZERO of the six supervisor
17
+ * definitions — three plists, three units, three Windows scripts. So on every
18
+ * fielded box the ceiling was absent, and every delivered specialist stayed
19
+ * permanently `idle` while `/detect` answered 503. Measured on a live GCE
20
+ * connector on 2026-08-26.
21
+ *
22
+ * ── Why the number is derived and not a constant ────────────────────────────
23
+ *
24
+ * `docs/runbooks/sensing-model-library.md` §2 is the arithmetic: the binding
25
+ * field box is a Ryzen 5 7640HS with a measured 9.8 GB carrying 6 of the 9
26
+ * spec-reporting screens, and Gemma's working set alone is 3.31 GB. **The SUM
27
+ * has to fit, not each member** — a selector that approves each model
28
+ * independently approves a set that OOM-kills the box, and the failure then
29
+ * looks like a camera problem. A hardcoded 4096 is wrong on that box and wrong
30
+ * again on the 31.4 GB Core Ultra 9 285H beside it in the same table.
31
+ *
32
+ * ── Why HERE and not in the service ─────────────────────────────────────────
33
+ *
34
+ * `registry.py`'s header settles it: a ceiling chosen by the detector would be a
35
+ * SECOND allocation authority that cannot see the first. So the service still
36
+ * has no default and still refuses; this runs at INSTALL time, in the
37
+ * installer, and the supervisor records the answer in its own definition where
38
+ * an operator can read it and override it.
39
+ *
40
+ * It also could not honestly live in the Python package: every quantity it
41
+ * subtracts is a CONNECTOR cost — Gemma's working set, the connector process,
42
+ * one ffmpeg per feed — and the detector knows none of them. It sits in
43
+ * `deploy/detector/` because that is the one directory all three installers
44
+ * already resolve a path to (`find_detector_source` / `Resolve-DetectorSource`),
45
+ * so no packaging step had to change on any platform. It is NOT part of the
46
+ * Python distribution: `pyproject.toml` declares
47
+ * `packages = ["trillboards_detector"]`, so the wheel never sees this file.
48
+ *
49
+ * ── Node, and only Node built-ins ───────────────────────────────────────────
50
+ *
51
+ * ONE implementation for three supervisors. A bash twin and a PowerShell twin
52
+ * would be two more places for the arithmetic to drift, and `os.totalmem()` is
53
+ * the same probe on all three. Node is present wherever this runs: `install.sh`
54
+ * requires >= 18 before anything else, the macOS pkg bundles `$ROOT/bin/node`
55
+ * and the Windows payload bundles `bin\node.exe`.
56
+ *
57
+ * ── Contract ────────────────────────────────────────────────────────────────
58
+ *
59
+ * stdout the ceiling, as a bare integer number of MiB. Nothing else, so a
60
+ * caller can `$(node ram-budget.js)` it.
61
+ * stderr with --explain, the whole subtraction, so an operator reading a
62
+ * setup log can see WHY the number is what it is.
63
+ * exit 0 derived. exit 1: could not read this box's memory — the caller
64
+ * then injects NOTHING, which leaves the service in its existing,
65
+ * honest refusing state rather than acting on a guess.
66
+ *
67
+ * `--assume-total-mb` / `--assume-platform` exist for the fixture matrix in
68
+ * `src/__tests__/detectorRamBudget.test.ts` and for reproducing a field box's
69
+ * number on a laptop. An INSTALLER must never pass them: a number an installer
70
+ * supplied is not a number derived from the box.
71
+ */
72
+
73
+ 'use strict';
74
+
75
+ const fs = require('fs');
76
+ const os = require('os');
77
+ const path = require('path');
78
+
79
+ const CONTRACT_PATH = path.join(__dirname, 'ram-budget.json');
80
+ const BYTES_PER_MIB = 1024 * 1024;
81
+
82
+ /** Read the committed inputs. Keys prefixed `_` are prose and are ignored. */
83
+ function loadContract(contractPath) {
84
+ const raw = fs.readFileSync(contractPath || CONTRACT_PATH, 'utf8');
85
+ const parsed = JSON.parse(raw);
86
+ for (const key of ['pinnedModelMb', 'localVlmWorkingSetMb', 'connectorProcessMb', 'captureMb']) {
87
+ if (!Number.isFinite(parsed[key]) || parsed[key] <= 0) {
88
+ throw new Error(`ram-budget.json: ${key} must be a positive number, got ${parsed[key]}`);
89
+ }
90
+ }
91
+ if (!parsed.operatingSystemMb || !Number.isFinite(parsed.operatingSystemMb.default)) {
92
+ throw new Error('ram-budget.json: operatingSystemMb.default must be a positive number');
93
+ }
94
+ return parsed;
95
+ }
96
+
97
+ /**
98
+ * The ceiling, in MiB, and the subtraction that produced it.
99
+ *
100
+ * FLOORED AT THE PINNED MODEL, never below. `ModelRegistry.__init__` RAISES on a
101
+ * budget it cannot hold the pinned model in — "the service would be permanently
102
+ * unavailable rather than merely constrained" — and every supervisor restarts a
103
+ * non-zero exit, so a ceiling of 200 MiB on a tiny box would be a crash-loop
104
+ * rather than a constraint. At the floor the service still starts, still holds
105
+ * the model it was told to hold, and refuses each specialist with a CAPACITY
106
+ * reason naming what did not fit, which is the truthful thing to say about a box
107
+ * that has no room.
108
+ */
109
+ function deriveRamBudgetMb(opts) {
110
+ const contract = opts.contract;
111
+ const totalMb = opts.totalMb;
112
+ const platform = opts.platform;
113
+
114
+ const osMb = Number.isFinite(contract.operatingSystemMb[platform])
115
+ ? contract.operatingSystemMb[platform]
116
+ : contract.operatingSystemMb.default;
117
+
118
+ const reserved = osMb
119
+ + contract.connectorProcessMb
120
+ + contract.localVlmWorkingSetMb
121
+ + contract.captureMb;
122
+
123
+ const available = totalMb - reserved;
124
+ const floored = available < contract.pinnedModelMb;
125
+ const budgetMb = floored ? contract.pinnedModelMb : Math.floor(available);
126
+
127
+ return {
128
+ budgetMb,
129
+ totalMb,
130
+ platform,
131
+ osMb,
132
+ osKeyed: Number.isFinite(contract.operatingSystemMb[platform]),
133
+ connectorProcessMb: contract.connectorProcessMb,
134
+ localVlmWorkingSetMb: contract.localVlmWorkingSetMb,
135
+ captureMb: contract.captureMb,
136
+ pinnedModelMb: contract.pinnedModelMb,
137
+ reserved,
138
+ available,
139
+ floored,
140
+ };
141
+ }
142
+
143
+ /** `os.totalmem()` in whole MiB, or null when it reported nothing usable. */
144
+ function readTotalMemoryMb(totalmem) {
145
+ let bytes;
146
+ try {
147
+ bytes = (totalmem || os.totalmem)();
148
+ } catch {
149
+ return null;
150
+ }
151
+ if (!Number.isFinite(bytes) || bytes <= 0) return null;
152
+ return Math.floor(bytes / BYTES_PER_MIB);
153
+ }
154
+
155
+ function explain(d) {
156
+ const floorNote = d.floored
157
+ ? `\n FLOORED at the pinned model (${d.pinnedModelMb} MiB): this box has `
158
+ + `${d.available} MiB left after the reservations, which cannot hold a second model. `
159
+ + 'The detector still starts and still holds its pinned model; every specialist is '
160
+ + 'refused with a capacity reason.'
161
+ : '';
162
+ const osNote = d.osKeyed ? d.platform : `${d.platform} -> default (unrecognised platform)`;
163
+ return `detector RAM ceiling ${d.budgetMb} MiB`
164
+ + `\n ${d.totalMb} MiB total on this box`
165
+ + `\n - ${d.osMb} MiB operating system (${osNote})`
166
+ + `\n - ${d.connectorProcessMb} MiB connector process`
167
+ + `\n - ${d.localVlmWorkingSetMb} MiB on-device VLM working set`
168
+ + `\n - ${d.captureMb} MiB capture (one persistent ffmpeg per feed, at the 16-session ceiling)`
169
+ + `\n = ${d.available} MiB for every resident detector session TOGETHER`
170
+ + floorNote;
171
+ }
172
+
173
+ function parseArgs(argv) {
174
+ const opts = { explain: false, totalMb: null, platform: null };
175
+ for (const arg of argv) {
176
+ if (arg === '--explain') opts.explain = true;
177
+ else if (arg.startsWith('--assume-total-mb=')) opts.totalMb = Number(arg.split('=')[1]);
178
+ else if (arg.startsWith('--assume-platform=')) opts.platform = arg.split('=')[1];
179
+ else throw new Error(`unknown argument ${arg}`);
180
+ }
181
+ return opts;
182
+ }
183
+
184
+ function main(argv) {
185
+ let opts;
186
+ try {
187
+ opts = parseArgs(argv);
188
+ } catch (err) {
189
+ process.stderr.write(`${err.message}\n`);
190
+ return 1;
191
+ }
192
+
193
+ const totalMb = opts.totalMb !== null ? Math.floor(opts.totalMb) : readTotalMemoryMb();
194
+ if (!Number.isFinite(totalMb) || totalMb <= 0) {
195
+ // NOTHING IS INJECTED, and that is the designed outcome rather than a
196
+ // fallback. An absent ceiling is a STATE the service already handles
197
+ // correctly and loudly; a made-up one is a portfolio decision nobody made.
198
+ process.stderr.write(
199
+ 'could not read this box\'s total memory, so no RAM ceiling is derived. '
200
+ + 'The detector will start, hold its pinned model, and refuse to make a second '
201
+ + 'model resident — naming why.\n',
202
+ );
203
+ return 1;
204
+ }
205
+
206
+ let derived;
207
+ try {
208
+ derived = deriveRamBudgetMb({
209
+ contract: loadContract(),
210
+ totalMb,
211
+ platform: opts.platform || process.platform,
212
+ });
213
+ } catch (err) {
214
+ process.stderr.write(`${err.message}\n`);
215
+ return 1;
216
+ }
217
+
218
+ if (opts.explain) process.stderr.write(`${explain(derived)}\n`);
219
+ process.stdout.write(`${derived.budgetMb}\n`);
220
+ return 0;
221
+ }
222
+
223
+ module.exports = { loadContract, deriveRamBudgetMb, readTotalMemoryMb, explain, main, CONTRACT_PATH };
224
+
225
+ if (require.main === module) process.exit(main(process.argv.slice(2)));
@@ -0,0 +1,76 @@
1
+ {
2
+ "_comment": [
3
+ "Inputs to the detector's RAM ceiling. ONE file, read by the one derivation",
4
+ "(ram-budget.js) that all three installers run, so a number can never mean two",
5
+ "things on two platforms.",
6
+ "",
7
+ "Every entry carries its own basis, and the basis says whether it is a",
8
+ "MEASUREMENT or a POLICY. That distinction is the whole point of writing them",
9
+ "down separately: a measurement is re-derived when the thing is re-measured, a",
10
+ "policy is argued about. Collapsing them into one 'reserve' number is how a",
11
+ "guess acquires the authority of a measurement."
12
+ ],
13
+
14
+ "pinnedModelMb": 632,
15
+ "_pinnedModelMb": [
16
+ "MEASURED. RF-DETR Medium's peak RSS — docs/runbooks/cctv-detector-tier.md §6,",
17
+ "and the same figure `labels.py` commits as RFDETR_MEDIUM.approx_ram_bytes.",
18
+ "It is the FLOOR of the derived ceiling, not a reservation: ModelRegistry",
19
+ "REFUSES to construct with a budget below the pinned model's footprint",
20
+ "(`the service would be permanently unavailable rather than merely",
21
+ "constrained`), so a ceiling under this would crash-loop the service on a",
22
+ "small box instead of merely denying it specialists. tests/test_ram_budget.py",
23
+ "pins this to labels.py so the two cannot drift."
24
+ ],
25
+
26
+ "localVlmWorkingSetMb": 3390,
27
+ "_localVlmWorkingSetMb": [
28
+ "MEASURED. Gemma's working set on the SHIPPED GPU-vision path —",
29
+ "docs/runbooks/cctv-local-inference-hardware-floor.md §3, re-measured",
30
+ "2026-08-19 at 3.31 GB (2.7 GB on the retired stock config; the GPU vision",
31
+ "encoder costs ~0.6 GB). The 4.3 GB .litertlm bundle is memory-MAPPED and is",
32
+ "deliberately not reserved here — what binds is the working set.",
33
+ "3.31 read as GiB, which is the larger of the two readings of that unit and",
34
+ "therefore the safe one: over-reserving costs a specialist slot, under-",
35
+ "reserving OOM-kills a box and looks like a camera fault."
36
+ ],
37
+
38
+ "connectorProcessMb": 2048,
39
+ "_connectorProcessMb": [
40
+ "DECLARED, not measured — and it is our own declaration, which is why it is",
41
+ "usable: trillboards-edge.service sets MemoryMax=2G, so on Linux this is not",
42
+ "an estimate of the connector, it is the cgroup ceiling the kernel enforces on",
43
+ "it. macOS and Windows run the same program with no such cap, so the same",
44
+ "number carries as the best statement we have about it."
45
+ ],
46
+
47
+ "captureMb": 384,
48
+ "_captureMb": [
49
+ "MEASURED. RTSPStreamCameraProvider holds one long-lived ffmpeg per feed into",
50
+ "a bounded ring; docs/runbooks/sensing-catch-rate.md §2b measured that at",
51
+ "+366 MB RSS at 16 feeds. 16 is the learned per-recorder session ceiling, so",
52
+ "this reserves the whole capture lane at its widest rather than per feed —",
53
+ "the feed count is not known at install time, and a per-feed reservation",
54
+ "would have to be revised every time an operator adds a camera."
55
+ ],
56
+
57
+ "operatingSystemMb": {
58
+ "linux": 1024,
59
+ "darwin": 3072,
60
+ "win32": 2048,
61
+ "default": 3072
62
+ },
63
+ "_operatingSystemMb": [
64
+ "POLICY, NOT MEASUREMENT, and it must not be read as one. There is no fleet",
65
+ "measurement of idle OS footprint anywhere in this tree; these are floors",
66
+ "chosen to be comfortably above what each platform idles at, because the",
67
+ "consequence of under-reserving here is the OOM killer choosing which of our",
68
+ "processes dies. `default` takes the largest value, so an unrecognised",
69
+ "platform fails conservative rather than optimistic.",
70
+ "linux 1024: the connector's Linux SKU is a headless mini-PC; a Debian/Ubuntu",
71
+ " server idles well under 512 MB, and this is roughly twice that.",
72
+ "win32 2048: Windows 11 idles at roughly 2-3 GB of working set.",
73
+ "darwin 3072: macOS holds a large (compressed) working set even idle; on the",
74
+ " 16 GB Mac mini M4 that is the recommended SKU this is ~19% of the box."
75
+ ]
76
+ }
@@ -0,0 +1,124 @@
1
+ """The supervisor's ceiling and this service's floor are ONE number.
2
+
3
+ `ram-budget.json` is read by the installer, not by this package — the ceiling is
4
+ INJECTED and this service owns no RAM policy (`registry.py`'s header). But the
5
+ supervisor's derivation floors its answer at the pinned model's footprint, and
6
+ that floor is only correct if it is EXACTLY the boundary `ModelRegistry` refuses
7
+ below. One MiB either way and the two artifacts disagree in one of two ways:
8
+
9
+ * floor too LOW -> `ModelRegistry.__init__` raises on a small box, the process
10
+ exits non-zero, and all three supervisors restart it. A crash-loop instead of
11
+ a constraint, on exactly the boxes least able to report it.
12
+ * floor too HIGH -> the derivation quietly hands small boxes more than they
13
+ have, which is the OOM this whole portfolio design exists to prevent.
14
+
15
+ So the JSON is pinned to `labels.py` here, in the package that owns the number,
16
+ rather than restated in the JS that reads it.
17
+
18
+ Stdlib only, like every other suite in this directory.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import tempfile
25
+ import unittest
26
+ from pathlib import Path
27
+
28
+ from trillboards_detector.labels import RFDETR_MEDIUM, ModelVocabulary
29
+ from trillboards_detector.registry import ModelEntry, ModelRegistry
30
+
31
+ MB = 1024 * 1024
32
+ CONTRACT_PATH = Path(__file__).resolve().parent.parent / "ram-budget.json"
33
+
34
+
35
+ def _contract() -> dict:
36
+ return json.loads(CONTRACT_PATH.read_text())
37
+
38
+
39
+ class TestTheFloorIsThePinnedModel(unittest.TestCase):
40
+ def test_the_contract_ships_next_to_the_package(self):
41
+ # All three installers resolve the detector SOURCE directory and read the
42
+ # derivation out of it, so the file has to be here and not somewhere a
43
+ # packaging step would have to be taught about.
44
+ self.assertTrue(CONTRACT_PATH.is_file(), f"{CONTRACT_PATH} is missing")
45
+
46
+ def test_pinned_model_mb_is_exactly_the_pinned_vocabulary_footprint(self):
47
+ self.assertEqual(
48
+ _contract()["pinnedModelMb"] * MB,
49
+ RFDETR_MEDIUM.approx_ram_bytes,
50
+ "ram-budget.json's floor and RFDETR_MEDIUM.approx_ram_bytes are the same "
51
+ "quantity; a supervisor flooring below it crash-loops this service",
52
+ )
53
+
54
+ def test_every_reservation_is_a_positive_number_of_mib(self):
55
+ contract = _contract()
56
+ for key in (
57
+ "pinnedModelMb",
58
+ "localVlmWorkingSetMb",
59
+ "connectorProcessMb",
60
+ "captureMb",
61
+ ):
62
+ self.assertIsInstance(contract[key], int, key)
63
+ self.assertGreater(contract[key], 0, key)
64
+ os_mb = contract["operatingSystemMb"]
65
+ self.assertIn("default", os_mb)
66
+ for platform, value in os_mb.items():
67
+ self.assertIsInstance(value, int, platform)
68
+ self.assertGreater(value, 0, platform)
69
+ # An unrecognised platform must be treated at least as expensively as
70
+ # every recognised one — failing conservative is the only safe direction
71
+ # when the consequence is the OOM killer picking a victim.
72
+ self.assertEqual(
73
+ os_mb["default"],
74
+ max(v for k, v in os_mb.items() if k != "default"),
75
+ )
76
+
77
+
78
+ class TestTheFloorIsCONSTRUCTIBLE(unittest.TestCase):
79
+ """The boundary, exercised against the real registry rather than asserted.
80
+
81
+ `ModelRegistry.__init__` refuses a ceiling below the pinned model's footprint
82
+ — "the service would be permanently unavailable rather than merely
83
+ constrained". These two cases are the MiB either side of the floor the
84
+ supervisor derives.
85
+ """
86
+
87
+ def setUp(self):
88
+ self._tmp = tempfile.TemporaryDirectory()
89
+ self.root = Path(self._tmp.name)
90
+ self.addCleanup(self._tmp.cleanup)
91
+ self.pinned = ModelVocabulary(
92
+ model_name=RFDETR_MEDIUM.model_name,
93
+ slot_names=dict(RFDETR_MEDIUM.slot_names),
94
+ logit_slots=RFDETR_MEDIUM.logit_slots,
95
+ approx_ram_bytes=RFDETR_MEDIUM.approx_ram_bytes,
96
+ onnx_variant=RFDETR_MEDIUM.onnx_variant,
97
+ )
98
+
99
+ def _registry(self, budget_bytes: int) -> ModelRegistry:
100
+ entry = ModelEntry(
101
+ self.pinned,
102
+ self.root / "models" / self.pinned.model_name / "model.onnx",
103
+ pinned=True,
104
+ )
105
+ return ModelRegistry(
106
+ entries=[entry],
107
+ session_factory=lambda _e: object(),
108
+ probe=lambda _s: None,
109
+ ram_budget_bytes=budget_bytes,
110
+ log=lambda _m: None,
111
+ )
112
+
113
+ def test_the_derived_floor_constructs(self):
114
+ floor_mb = _contract()["pinnedModelMb"]
115
+ self.assertIsNotNone(self._registry(floor_mb * MB))
116
+
117
+ def test_one_mib_below_the_floor_refuses(self):
118
+ floor_mb = _contract()["pinnedModelMb"]
119
+ with self.assertRaises(ValueError):
120
+ self._registry((floor_mb - 1) * MB)
121
+
122
+
123
+ if __name__ == "__main__":
124
+ unittest.main()
@@ -12,9 +12,19 @@
12
12
  here. Port 9380; litert-lm owns 9379 and is not touched.
13
13
 
14
14
  This is a TEMPLATE: setup-detector.sh substitutes __DETECTOR__ (absolute path
15
- to the uv-installed `trillboards-detector` binary) / __PORT__ / __LOG_DIR__ and
16
- writes the result to ~/Library/LaunchAgents/com.trillboards.detector.plist,
17
- then loads it via `launchctl bootstrap gui/$UID`.
15
+ to the uv-installed `trillboards-detector` binary) / __PORT__ / __LOG_DIR__ /
16
+ __RAM_BUDGET_ARGS__ and writes the result to
17
+ ~/Library/LaunchAgents/com.trillboards.detector.plist, then loads it via
18
+ `launchctl bootstrap gui/$UID`.
19
+
20
+ __RAM_BUDGET_ARGS__ becomes either the two <string> elements of
21
+ `--ram-budget-mb <N>` or NOTHING AT ALL, and the emptiness is meaningful: the
22
+ detector distinguishes "the supervisor told me a ceiling" from "nobody has
23
+ told me one", and refuses to invent one in the second state
24
+ (`registry.py::_admit`). A placeholder value here would be this file quietly
25
+ becoming a second allocation authority. The number is derived from THIS
26
+ machine by deploy/detector/ram-budget.js — see its header for why a constant
27
+ is wrong on both of the two field boxes we have measured.
18
28
 
19
29
  KeepAlive=true + ThrottleInterval=30 exactly as com.trillboards.litertlm.plist.
20
30
  ThrottleInterval is load-bearing here for a specific reason: the model is OTA'd,
@@ -35,6 +45,7 @@
35
45
  <string>127.0.0.1</string>
36
46
  <string>--port</string>
37
47
  <string>__PORT__</string>
48
+ __RAM_BUDGET_ARGS__
38
49
  </array>
39
50
 
40
51
  <key>RunAtLoad</key>
@@ -135,6 +135,39 @@ if [ ! -x "$DETECTOR" ]; then
135
135
  fi
136
136
  log "trillboards-detector installed at $DETECTOR"
137
137
 
138
+ # 2b. THE RAM CEILING, derived from THIS Mac.
139
+ #
140
+ # Without it the service holds its pinned model and refuses to make any second
141
+ # model resident — `registry.py::_admit` says so in the 503 — so every delivered
142
+ # specialist stays permanently `idle`. The refusal is correct; the injection was
143
+ # missing from all six supervisor definitions until 2026-08-26.
144
+ #
145
+ # A COMMAND-LINE ARGUMENT here, not an environment variable, because this
146
+ # supervisor definition is GENERATED by this script — same for the Windows
147
+ # Scheduled Task. Only Linux, whose unit is a static file, uses the env var.
148
+ # One derivation (deploy/detector/ram-budget.js), three transports.
149
+ #
150
+ # EMPTY WHEN IT CANNOT BE DERIVED, never a placeholder: "nobody has told me a
151
+ # ceiling" is a state the detector handles correctly and loudly, and a number
152
+ # invented here would be a second allocation authority.
153
+ RAM_BUDGET_ARGS=""
154
+ NODE_BIN="$SCRIPT_DIR/../bin/node"
155
+ [ -x "$NODE_BIN" ] || NODE_BIN="$(command -v node || true)"
156
+ if [ -z "$NODE_BIN" ] || [ ! -x "$NODE_BIN" ]; then
157
+ log "WARN: no node runtime found, so no RAM ceiling is derived; the detector will refuse a second resident model"
158
+ elif [ ! -f "$SOURCE/ram-budget.js" ]; then
159
+ log "WARN: $SOURCE/ram-budget.js is missing, so no RAM ceiling is derived"
160
+ else
161
+ _budget="$("$NODE_BIN" "$SOURCE/ram-budget.js" --explain 2>>"$LOG")" || _budget=""
162
+ case "$_budget" in
163
+ ''|*[!0-9]*) log "WARN: could not derive a RAM ceiling for this Mac (see $LOG); the detector will refuse a second resident model" ;;
164
+ *)
165
+ RAM_BUDGET_ARGS="<string>--ram-budget-mb</string><string>$_budget</string>"
166
+ log "RAM ceiling $_budget MiB (derived from this Mac; see the log above for the subtraction)"
167
+ ;;
168
+ esac
169
+ fi
170
+
138
171
  # 3. Render + bootstrap the LaunchAgent.
139
172
  TEMPLATE="$SHARE_DIR/$SERVE_LABEL.plist"
140
173
  TARGET="$LA_DIR/$SERVE_LABEL.plist"
@@ -146,6 +179,7 @@ fi
146
179
  sed -e "s#__DETECTOR__#$DETECTOR#g" \
147
180
  -e "s#__PORT__#$SERVE_PORT#g" \
148
181
  -e "s#__LOG_DIR__#$LOG_DIR#g" \
182
+ -e "s#__RAM_BUDGET_ARGS__#$RAM_BUDGET_ARGS#g" \
149
183
  "$TEMPLATE" > "$TARGET" 2>/dev/null || { log "WARN: could not render the detector plist"; exit 0; }
150
184
 
151
185
  SERVICE_DOMAIN="gui/$(id -u)"
@@ -157,7 +157,20 @@ sudo -u trillboards HOME=/opt/trillboards bash \
157
157
  # starting left a fresh Linux box with no detector until its next reboot, with
158
158
  # the installer reporting success. `--now` starts it here, where we have root,
159
159
  # and after the provisioner so the binary the unit points at already exists.
160
- sudo systemctl enable --now trillboards-detector \
160
+ #
161
+ # `enable` THEN `restart`, NOT `enable --now`, AND THE DIFFERENCE ONLY SHOWS ON AN
162
+ # UPGRADE. `--now` starts a unit that is INACTIVE and does nothing at all to one
163
+ # that is already running — so on a box where the detector is already up, the
164
+ # provisioner above would write a new `TRILLBOARDS_DETECTOR_RAM_BUDGET_MB` into
165
+ # /etc/trillboards/env and the running process would keep the environment it was
166
+ # exec'd with, until the next reboot. A ceiling the service never re-read is a
167
+ # decision nobody acted on: exactly what the litert-lm block above this one
168
+ # records about `write_litertlm_config` without a restart.
169
+ #
170
+ # `restart` is correct in BOTH states — it starts an inactive unit and replaces a
171
+ # running one — so the fresh-install behaviour `--now` was added for is kept.
172
+ sudo systemctl enable trillboards-detector 2>/dev/null || true
173
+ sudo systemctl restart trillboards-detector \
161
174
  || echo " *** The detector unit did not start. Sensing is unaffected. Check:
162
175
  *** sudo systemctl status trillboards-detector"
163
176
 
@@ -23,6 +23,10 @@ LOCAL_BIN="$USER_HOME/.local/bin"
23
23
  SERVE_PORT="${TRILLBOARDS_DETECTOR_PORT:-9380}"
24
24
  SERVE_UNIT="trillboards-detector"
25
25
  DETECTOR_PYTHON="${TRILLBOARDS_DETECTOR_PYTHON:-3.12}"
26
+ # The environment file BOTH units declare (`EnvironmentFile=-/etc/trillboards/env`).
27
+ # Overridable like the four settings above it, and for the same reason those are:
28
+ # so this script can be RUN in a test rather than only read by one.
29
+ ENV_FILE="${TRILLBOARDS_ENV_FILE:-/etc/trillboards/env}"
26
30
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || echo .)"
27
31
 
28
32
  mkdir -p "$LOG_DIR" 2>/dev/null || LOG_DIR="$USER_HOME/.trillboards-logs"
@@ -75,6 +79,73 @@ DETECTOR="$LOCAL_BIN/trillboards-detector"
75
79
  [ -x "$DETECTOR" ] || { log "ERROR: trillboards-detector binary missing after install"; exit 0; }
76
80
  log "trillboards-detector installed at $DETECTOR"
77
81
 
82
+ # ── THE RAM CEILING ─────────────────────────────────────────────────────────
83
+ #
84
+ # Without this the detector holds its pinned model and REFUSES to make any
85
+ # second model resident — `registry.py::_admit`, "no RAM ceiling was injected,
86
+ # so this service has no basis for a portfolio decision and will not invent
87
+ # one". That refusal is correct and stays; what was missing is the injection.
88
+ # Measured on a live GCE connector 2026-08-26: every delivered specialist sat
89
+ # permanently `idle` and `POST /detect` answered 503 naming exactly this.
90
+ #
91
+ # THE ENVIRONMENT FILE, not the ExecStart line, because on this platform the
92
+ # unit is a STATIC file `install.sh` copies verbatim — there is nothing to
93
+ # template. Both units already declare `EnvironmentFile=-/etc/trillboards/env`
94
+ # and nothing has ever written it. macOS and Windows take the same number as a
95
+ # `--ram-budget-mb` argument instead, because their supervisor definitions ARE
96
+ # generated by their setup scripts. One derivation, three transports.
97
+ #
98
+ # REWRITTEN ON EVERY RUN: the box's memory can change (a VM resize, a stick
99
+ # added) and so can the committed reservations, so an upgrade must re-derive
100
+ # rather than inherit. A run that CANNOT derive leaves an existing line alone —
101
+ # it was derived from this same box — and injects nothing new, which leaves the
102
+ # service in its existing honest refusal rather than acting on a guess.
103
+ #
104
+ # ONLY THE CEILING GOES IN THIS FILE. Assignments from `EnvironmentFile=`
105
+ # OUTRANK a unit's own `Environment=` — measured on systemd 252 with the two
106
+ # directives in both orders, the file wins either way — so anything written here
107
+ # silently beats the units. That is harmless for a value the units do not set,
108
+ # and it is a fresh outage for one they do: a `TRILLBOARDS_DATA_DIR` line here
109
+ # would move the connector's state root out of `ReadWritePaths=`, which is a
110
+ # literal and cannot follow a variable. Do not add one.
111
+ inject_ram_ceiling() {
112
+ local budget_script env_file tmp_file budget
113
+ env_file="$ENV_FILE"
114
+ budget_script="$SOURCE/ram-budget.js"
115
+
116
+ command -v node >/dev/null 2>&1 || {
117
+ log "WARN: node not found, so no RAM ceiling is derived; the detector will hold its pinned model and refuse a second one"
118
+ return 0
119
+ }
120
+ [ -f "$budget_script" ] || {
121
+ log "WARN: $budget_script is missing, so no RAM ceiling is derived"
122
+ return 0
123
+ }
124
+
125
+ budget="$(node "$budget_script" --explain 2>>"$LOG")" || {
126
+ log "WARN: could not derive a RAM ceiling for this box (see $LOG); the detector will refuse a second resident model"
127
+ return 0
128
+ }
129
+ case "$budget" in
130
+ ''|*[!0-9]*) log "WARN: the RAM-ceiling derivation returned '$budget', which is not a number of MiB; injecting nothing"; return 0 ;;
131
+ esac
132
+
133
+ mkdir -p "$(dirname "$env_file")" 2>/dev/null || true
134
+ tmp_file="$env_file.tmp.$$"
135
+ # Rewrite through a temp file so a concurrent read never sees a half-file, and
136
+ # so the key is REPLACED rather than appended — systemd takes the last
137
+ # assignment, but a file that grows a line per install is its own bug.
138
+ { [ -f "$env_file" ] && grep -v '^TRILLBOARDS_DETECTOR_RAM_BUDGET_MB=' "$env_file"; true; } > "$tmp_file" 2>/dev/null || : > "$tmp_file"
139
+ printf 'TRILLBOARDS_DETECTOR_RAM_BUDGET_MB=%s\n' "$budget" >> "$tmp_file"
140
+ if mv "$tmp_file" "$env_file" 2>/dev/null; then
141
+ log "RAM ceiling $budget MiB written to $env_file (derived from this box; see the log above for the subtraction)"
142
+ else
143
+ rm -f "$tmp_file" 2>/dev/null || true
144
+ log "WARN: could not write $env_file (owned by another user?); the detector will refuse a second resident model"
145
+ fi
146
+ }
147
+ inject_ram_ceiling
148
+
78
149
  # The unit is installed AND STARTED by install.sh, which has root. This script
79
150
  # runs as the unprivileged `trillboards` service user (nologin, no sudoers
80
151
  # entry), so on a fresh install BOTH attempts below fail by design.
@@ -43,6 +43,65 @@ function Register-Connector([string]$Root) {
43
43
  if ($LASTEXITCODE -ne 0) { throw "Scheduled-task registration failed." }
44
44
  }
45
45
 
46
+ <#
47
+ Carry the detector's RAM ceiling onto an EXISTING install.
48
+
49
+ WHY THE UPDATE TRANSACTION HAS TO DO THIS. Nothing else on an upgraded box
50
+ will. This script only calls Register-Connector, and trillboards-edge.cmd
51
+ re-runs setup-detector.ps1 only when the task is MISSING or /health answers
52
+ 000 - so a HEALTHY detector keeps the task action it was registered with. A
53
+ box that updates normally would therefore keep an action with no
54
+ `--ram-budget-mb` for ever, every delivered specialist would stay `idle`, and
55
+ the fix would reach NEW INSTALLS ONLY. That is the exact shape of the
56
+ 2026-08-19 Windows BOM incident, and it is why this is here rather than left
57
+ to the repair path.
58
+
59
+ RE-REGISTER, NOT RE-PROVISION. `setup-detector.ps1` runs `uv tool install
60
+ --force`, which rebuilds a venv and needs the network; putting that inside an
61
+ update transaction would give the upgrade a new way to be slow and a new way
62
+ to fail. Re-registering the task is the whole of what has to change, and
63
+ register-detector-task.ps1 already stops the running service, replaces the
64
+ action and starts it again - so this both migrates and restarts it.
65
+
66
+ BEST EFFORT, ALWAYS. The detector is a cost optimisation: without it every
67
+ frame goes to the VLM exactly as it does today. It must never be able to fail
68
+ a customer's upgrade, so every path here returns quietly.
69
+ #>
70
+ function Update-DetectorTask([string]$Root) {
71
+ try {
72
+ $task = Get-ScheduledTask -TaskName "TrillboardsDetector" -ErrorAction SilentlyContinue
73
+ if (-not $task) { return } # nothing registered yet; the cmd repair path owns that case
74
+ # The interpreter path comes off the task we are replacing rather than being
75
+ # re-derived - setup-detector.ps1 resolved it under the USER profile, and
76
+ # re-deriving here could resolve somewhere else entirely. Same rule
77
+ # register-task.ps1 states for its own repair.
78
+ $exe = ($task.Actions | Select-Object -First 1).Execute
79
+ if (-not $exe) { return }
80
+
81
+ $register = Join-Path $Root "bin\register-detector-task.ps1"
82
+ if (-not (Test-Path -LiteralPath $register)) { return }
83
+
84
+ # Re-DERIVED, not carried: the box may have gained memory since it was
85
+ # installed, and the reservations this is computed from are committed values
86
+ # that move with a release. If it cannot be derived we pass nothing, and
87
+ # register-detector-task.ps1 keeps whatever ceiling the old action carried.
88
+ $budget = ""
89
+ $budgetScript = Join-Path $Root "bin\detector\ram-budget.js"
90
+ $nodeExe = Join-Path $Root "bin\node.exe"
91
+ if ((Test-Path -LiteralPath $budgetScript) -and (Test-Path -LiteralPath $nodeExe)) {
92
+ $derived = ("" + (& $nodeExe $budgetScript)).Trim()
93
+ if ($LASTEXITCODE -eq 0 -and $derived -match '^\d+$') { $budget = $derived }
94
+ }
95
+
96
+ $registerArgs = @('-DetectorPath', $exe)
97
+ if ($budget) { $registerArgs += @('-RamBudgetMb', $budget) }
98
+ & $register @registerArgs | Out-Null
99
+ Write-Host "Detector task migrated$(if ($budget) { " (RAM ceiling $budget MiB)" })."
100
+ } catch {
101
+ Write-Warning "Could not migrate the detector task: $($_.Exception.Message). Sensing is unaffected; every frame keeps going to the VLM."
102
+ }
103
+ }
104
+
46
105
  function Assert-ConnectorScheduled {
47
106
  # stop-existing.ps1 DISABLES the task so restart-on-failure cannot relaunch
48
107
  # node.exe mid-swap. A disabled task suppresses the AtLogOn trigger too, so
@@ -92,6 +151,7 @@ try {
92
151
  Assert-ConnectorVersion $InstallDir
93
152
  Register-Connector $InstallDir
94
153
  Assert-ConnectorScheduled
154
+ Update-DetectorTask $InstallDir
95
155
  if (Test-Path (Join-Path $InstallDir "trillboards.config.json")) {
96
156
  Start-ScheduledTask -TaskName "TrillboardsEdge" -ErrorAction Stop
97
157
  }
@@ -35,13 +35,48 @@
35
35
  param(
36
36
  [Parameter(Mandatory = $true)][string]$DetectorPath,
37
37
  [string]$Port = "9380",
38
- [string]$LogFile = ""
38
+ [string]$LogFile = "",
39
+ # The portfolio RAM ceiling, in MiB, DERIVED FROM THIS MACHINE by
40
+ # deploy/detector/ram-budget.js and passed in by setup-detector.ps1.
41
+ #
42
+ # EMPTY IS A REAL VALUE AND MUST STAY REACHABLE. `registry.py::_admit` treats
43
+ # an absent ceiling as "nobody has told me one" and refuses to make a second
44
+ # model resident rather than inventing a number, which is the correct thing
45
+ # for this task to say when the box could not be measured. A default here
46
+ # would make this file a second allocation authority - and one that cannot see
47
+ # how much memory the machine has.
48
+ [string]$RamBudgetMb = ""
39
49
  )
40
50
  $ErrorActionPreference = "Stop"
41
51
 
42
52
  $TaskName = "TrillboardsDetector"
43
53
  $UserId = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
44
54
 
55
+ # RECOVER THE CEILING FROM THE TASK WE ARE REPLACING, when the caller did not
56
+ # supply one.
57
+ #
58
+ # register-task.ps1 repairs a non-headless TrillboardsDetector inside its own
59
+ # elevated pass and calls us with ONLY -DetectorPath. Without this, the empty
60
+ # default would win and the replacement action would silently lose
61
+ # --ram-budget-mb -- turning a box that HAD a ceiling back into one that refuses
62
+ # every specialist, on a path whose whole purpose is repair.
63
+ #
64
+ # It is the same idiom that caller already applies to the interpreter path:
65
+ # "Recover ... from the task we are replacing rather than re-deriving it".
66
+ # Re-deriving here would also be wrong for a subtler reason -- an elevated
67
+ # repair pass may not see the same machine state the original derivation did,
68
+ # and a repair must not quietly change a value nobody asked it to change.
69
+ if ([string]::IsNullOrWhiteSpace($RamBudgetMb)) {
70
+ try {
71
+ $existingAction = (Get-ScheduledTask -TaskName $TaskName -ErrorAction Stop).Actions |
72
+ Select-Object -First 1
73
+ if ($existingAction -and $existingAction.Arguments -match '--ram-budget-mb\s+(\d+)') {
74
+ $RamBudgetMb = $Matches[1]
75
+ Write-Host "Carrying the existing RAM ceiling ($RamBudgetMb MiB) onto the replacement task."
76
+ }
77
+ } catch { }
78
+ }
79
+
45
80
  if ([string]::IsNullOrWhiteSpace($LogFile)) {
46
81
  $LogFile = Join-Path (Join-Path $env:LOCALAPPDATA "Trillboards\logs") "detector.service.log"
47
82
  }
@@ -51,6 +86,15 @@ try { New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LogFile) |
51
86
  # Loopback only -- LocalDetectorClient is local; nothing off-box has any
52
87
  # business reaching this.
53
88
  $argument = 'serve --host 127.0.0.1 --port ' + $Port + ' --log-file "' + $LogFile + '"'
89
+ # Appended only when it was derived, and validated here rather than trusted:
90
+ # this string becomes a command line, and a malformed one would either fail the
91
+ # service's own argument parse on every restart or - worse - be silently
92
+ # reinterpreted. Digits only.
93
+ if ($RamBudgetMb -match '^\d+$') {
94
+ $argument += ' --ram-budget-mb ' + $RamBudgetMb
95
+ } elseif (-not [string]::IsNullOrWhiteSpace($RamBudgetMb)) {
96
+ Write-Warning "Ignoring a RAM ceiling that is not a whole number of MiB: '$RamBudgetMb'. The detector will hold its pinned model and refuse to make a second one resident."
97
+ }
54
98
  $Action = New-ScheduledTaskAction -Execute $DetectorPath -Argument $argument
55
99
 
56
100
  # NOT AT LOGON ONLY -- see register-task.ps1 for the full rationale, including
@@ -140,6 +140,48 @@ if (-not (Test-Path -LiteralPath $detectorPath)) {
140
140
  }
141
141
  Log "trillboards-detector installed at $detectorPath"
142
142
 
143
+ # --- 2b. The RAM ceiling, derived from THIS machine --------------------------
144
+ #
145
+ # Without it the service holds its pinned model and refuses to make any second
146
+ # model resident - `registry.py::_admit`, "no RAM ceiling was injected, so this
147
+ # service has no basis for a portfolio decision and will not invent one". The
148
+ # refusal is correct; the injection was missing from all six supervisor
149
+ # definitions until 2026-08-26, so every delivered specialist stayed `idle`.
150
+ #
151
+ # A TASK ARGUMENT, not an environment variable. A Scheduled Task action has no
152
+ # environment block of its own, and setting a User-scope variable would put a
153
+ # per-machine tuning knob into global machine state where nothing associates it
154
+ # with this service. macOS also takes an argument (its plist is generated too);
155
+ # only Linux, whose unit is a static file with an EnvironmentFile already
156
+ # declared, uses the variable. ONE derivation, three transports.
157
+ #
158
+ # EMPTY WHEN IT CANNOT BE DERIVED, never a placeholder: "nobody has told me a
159
+ # ceiling" is a state the detector handles correctly and loudly.
160
+ $ramBudgetMb = ""
161
+ $budgetScript = Join-Path $source "ram-budget.js"
162
+ $nodeExe = Join-Path $Here "node.exe"
163
+ if (-not (Test-Path -LiteralPath $nodeExe)) {
164
+ $foundNode = Get-Command node -ErrorAction SilentlyContinue
165
+ if ($foundNode) { $nodeExe = $foundNode.Source } else { $nodeExe = $null }
166
+ }
167
+ if (-not $nodeExe) {
168
+ Log "WARN: no node runtime found, so no RAM ceiling is derived; the detector will refuse a second resident model"
169
+ } elseif (-not (Test-Path -LiteralPath $budgetScript)) {
170
+ Log "WARN: $budgetScript is missing, so no RAM ceiling is derived"
171
+ } else {
172
+ # `--explain` writes the subtraction to stderr and the bare integer to stdout,
173
+ # so the log carries WHY and the variable carries only the number. 2>&1 is
174
+ # deliberately NOT used: it would fold the explanation into the value.
175
+ $derived = & $nodeExe $budgetScript "--explain" 2>> $LogPath
176
+ $derived = ("$derived").Trim()
177
+ if ($LASTEXITCODE -eq 0 -and $derived -match '^\d+$') {
178
+ $ramBudgetMb = $derived
179
+ Log "RAM ceiling $ramBudgetMb MiB (derived from this machine; see the log above for the subtraction)"
180
+ } else {
181
+ Log "WARN: could not derive a RAM ceiling for this machine (see $LogPath); the detector will refuse a second resident model"
182
+ }
183
+ }
184
+
143
185
  # --- 3. The Scheduled Task ---------------------------------------------------
144
186
  $registration = Join-Path $Here "register-detector-task.ps1"
145
187
  if (-not (Test-Path -LiteralPath $registration)) {
@@ -147,7 +189,17 @@ if (-not (Test-Path -LiteralPath $registration)) {
147
189
  exit 0
148
190
  }
149
191
  Log "registering the detector Scheduled Task on :$Port"
150
- & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $registration -DetectorPath $detectorPath -Port $Port *>> $LogPath
192
+ # OMITTED, not passed empty, when nothing was derived. Windows PowerShell drops
193
+ # an empty-string argument to a native command, so `-RamBudgetMb ""` arrives as a
194
+ # bare `-RamBudgetMb` with nothing after it and the whole registration dies with
195
+ # "Missing an argument for parameter" - turning an undeterminable ceiling into an
196
+ # unsupervised detector. The task script's own default covers the omission.
197
+ $registrationArgs = @(
198
+ '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $registration,
199
+ '-DetectorPath', $detectorPath, '-Port', $Port
200
+ )
201
+ if ($ramBudgetMb) { $registrationArgs += @('-RamBudgetMb', $ramBudgetMb) }
202
+ & powershell.exe @registrationArgs *>> $LogPath
151
203
  if ($LASTEXITCODE -ne 0) {
152
204
  Log "ERROR: the detector Scheduled Task could not be registered; the cascade stays off"
153
205
  exit 0
package/dist/EdgeAgent.js CHANGED
@@ -44,7 +44,7 @@ const edge_ads_1 = require("@trillboards/edge-ads");
44
44
  const CommandRouter_1 = require("./CommandRouter");
45
45
  const config_1 = require("./config");
46
46
  const dataDir_1 = require("./dataDir");
47
- const SDK_VERSION = '0.2.104';
47
+ const SDK_VERSION = '0.2.106';
48
48
  class EdgeAgent extends events_1.EventEmitter {
49
49
  constructor(config, deps) {
50
50
  super();
@@ -31,7 +31,7 @@ export interface DiagnosticReport {
31
31
  skipped: number;
32
32
  };
33
33
  }
34
- declare const SDK_VERSION = "0.2.104";
34
+ declare const SDK_VERSION = "0.2.106";
35
35
  declare function checkNodeVersion(): CheckResult;
36
36
  declare function checkPlatform(): CheckResult;
37
37
  declare function checkMemory(): CheckResult;
package/dist/diagnose.js CHANGED
@@ -63,7 +63,7 @@ const localNetworkAccess_1 = require("./localNetworkAccess");
63
63
  // ---------------------------------------------------------------------------
64
64
  // SDK version — mirrors package.json; kept in sync by CI
65
65
  // ---------------------------------------------------------------------------
66
- const SDK_VERSION = '0.2.104';
66
+ const SDK_VERSION = '0.2.106';
67
67
  exports._SDK_VERSION = SDK_VERSION;
68
68
  // ---------------------------------------------------------------------------
69
69
  // ANSI helpers (no external deps)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trillboards/edge-sdk",
3
- "version": "0.2.104",
3
+ "version": "0.2.106",
4
4
  "description": "Composable cross-platform Edge AI SDK for audience sensing on DOOH/CTV devices",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",