@trillboards/edge-sdk 0.2.154 → 0.2.157

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.
@@ -6,6 +6,7 @@ the recorded kwargs prove WHICH configs are dropped and which survive.
6
6
  """
7
7
 
8
8
  import importlib
9
+ import base64
9
10
  import hashlib
10
11
  import os
11
12
  import sys
@@ -208,6 +209,111 @@ class ShimTest(unittest.TestCase):
208
209
  self.assertEqual(captured["response"]["choices"], [{"text": "unchanged"}])
209
210
  self.assertNotIn("runtimeModel", captured["response"])
210
211
 
212
+ def test_native_images_are_hashed_at_the_ffi_boundary_not_copied_from_headers(self):
213
+ shim = self._load(TRILLBOARDS_LITERTLM_SHIM="1")
214
+ seen = []
215
+ class Conversation:
216
+ def send_message_async(self, message):
217
+ seen.append(message)
218
+ return "native result"
219
+ shim._apply_native_images(SimpleNamespace(Conversation=Conversation))
220
+ payloads = [b"whole marked area", b"person one", b"person two"]
221
+ expected = [hashlib.sha256(b).hexdigest() for b in payloads]
222
+ state = {"expected": expected, "consumed": None}
223
+ token = shim._image_request.set(state)
224
+ self.addCleanup(lambda: shim._image_request.reset(token))
225
+ message = {"content": [{"type": "image", "blob": base64.b64encode(b).decode()} for b in payloads]}
226
+ self.assertEqual(Conversation().send_message_async(message), "native result")
227
+ self.assertEqual(state["consumed"], expected)
228
+ self.assertEqual(len(seen), 1)
229
+ with self.assertRaisesRegex(ValueError, "native image"):
230
+ Conversation().send_message_async({"content": message["content"][:1]})
231
+ self.assertEqual(len(seen), 1)
232
+
233
+ def test_image_capacity_is_lazy_and_only_added_for_explicit_native_requests(self):
234
+ shim = self._load(TRILLBOARDS_LITERTLM_SHIM="1")
235
+ calls = []
236
+ class Engine:
237
+ def __init__(self, **kwargs):
238
+ calls.append(kwargs)
239
+ shim._apply_model_snapshot(Engine)
240
+ Engine()
241
+ self.assertEqual(calls, [{}])
242
+ token = shim._image_request.set({"expected": ["a" * 64, "b" * 64], "consumed": None})
243
+ try:
244
+ engine = Engine()
245
+ self.assertEqual(calls[1], {"max_num_images": 5})
246
+ self.assertEqual(engine._trillboards_image_capacity, 5)
247
+ finally:
248
+ shim._image_request.reset(token)
249
+ Engine()
250
+ self.assertEqual(calls[2], {})
251
+
252
+ def _native_http(self, low_memory=False):
253
+ shim = self._load(TRILLBOARDS_LITERTLM_SHIM="1")
254
+ events = []
255
+ images = [b"area", b"person"]
256
+ hashes = [hashlib.sha256(b).hexdigest() for b in images]
257
+ class Engine:
258
+ def __init__(self, **kwargs):
259
+ events.append(("allocate", kwargs.get("max_num_images", 1)))
260
+ if low_memory and kwargs.get("max_num_images") == 5:
261
+ raise MemoryError("test image capacity allocation")
262
+ self.backend = SimpleNamespace(get_name=lambda: "cpu")
263
+ self._trillboards_artifact_sha256 = "a" * 64
264
+ def __exit__(self, *args):
265
+ events.append(("release", self._trillboards_image_capacity))
266
+ class Conversation:
267
+ def send_message_async(self, message):
268
+ events.append(("native", len(message["content"])))
269
+ shim._apply_model_snapshot(Engine)
270
+ shim._apply_native_images(SimpleNamespace(Conversation=Conversation))
271
+ server = SimpleNamespace(litert_lm_engine=Engine())
272
+ class Handler:
273
+ headers = {"X-Trillboards-Model-Sha256": "a" * 64, "X-Trillboards-Image-Sha256": ",".join(hashes)}
274
+ def __init__(self):
275
+ self.server = server
276
+ def send_error(self, code, message):
277
+ events.append(("error", code))
278
+ def _get_engine(self, model_id, *, backend):
279
+ if self.server.litert_lm_engine is None:
280
+ try:
281
+ self.server.litert_lm_engine = Engine()
282
+ except MemoryError:
283
+ self.send_error(500, "allocation failed")
284
+ return None
285
+ return self.server.litert_lm_engine
286
+ def do_POST(self):
287
+ if self._get_engine("gemma", backend="cpu") is not None:
288
+ Conversation().send_message_async({"content": [
289
+ {"type": "image", "blob": base64.b64encode(b).decode()} for b in images]})
290
+ module._dump_json({"object": "chat.completion"})
291
+ output = []
292
+ module = SimpleNamespace(OpenAIHandler=Handler, _dump_json=lambda data, **kwargs: output.append(data))
293
+ shim.apply_runtime_handler(module)
294
+ handler = Handler()
295
+ handler.do_POST()
296
+ return handler, events, output, hashes
297
+
298
+ def test_native_http_receipt_is_outside_model_text_and_capacity_reuses_one_engine(self):
299
+ handler, events, output, hashes = self._native_http()
300
+ self.assertEqual(events[:3], [("allocate", 1), ("release", 1), ("allocate", 5)])
301
+ self.assertEqual(output[0]["nativeImageInputs"], {"version": 1, "sha256": hashes})
302
+ handler.do_POST()
303
+ self.assertEqual(sum(e[0] == "allocate" for e in events), 2)
304
+ handler.headers = {"X-Trillboards-Model-Sha256": "a" * 64}
305
+ handler.do_POST()
306
+ self.assertNotIn("nativeImageInputs", output[-1])
307
+
308
+ def test_capacity_allocation_failure_leaves_single_image_recovery_available(self):
309
+ handler, events, output, _ = self._native_http(low_memory=True)
310
+ self.assertEqual(events[:4], [("allocate", 1), ("release", 1), ("allocate", 5), ("error", 500)])
311
+ self.assertEqual(output, [])
312
+ handler.headers = {"X-Trillboards-Model-Sha256": "a" * 64}
313
+ handler.do_POST()
314
+ self.assertEqual(events[-2:], [("allocate", 1), ("native", 2)])
315
+ self.assertNotIn("nativeImageInputs", output[-1])
316
+
211
317
 
212
318
  if __name__ == "__main__":
213
319
  unittest.main()
@@ -82,6 +82,9 @@ from __future__ import annotations
82
82
 
83
83
  import os
84
84
  import sys
85
+ import contextvars
86
+
87
+ _image_request = contextvars.ContextVar('trillboards_native_images', default=None)
85
88
 
86
89
  __all__ = ["install", "apply_to", "should_drop", "TAG"]
87
90
 
@@ -129,6 +132,7 @@ def apply_to(litert_lm_module: object) -> bool:
129
132
  Returns True when the wrap is in place (including when it already was).
130
133
  """
131
134
  engine_cls = getattr(litert_lm_module, "Engine", None)
135
+ _apply_native_images(litert_lm_module)
132
136
  if engine_cls is not None:
133
137
  _apply_model_snapshot(engine_cls)
134
138
  original = getattr(engine_cls, "create_conversation", None)
@@ -261,9 +265,16 @@ def _apply_model_snapshot(engine_cls):
261
265
  return
262
266
 
263
267
  def initialize(self, *args, **kwargs):
268
+ images = _image_request.get()
269
+ if images is not None:
270
+ # The public option used by the saved-frame comparison. No larger
271
+ # engine is allocated on ordinary single-image/off-switch starts.
272
+ kwargs['max_num_images'] = 5
264
273
  path = kwargs.get('model_path', args[0] if args else None)
265
274
  if path is None: # lightweight contract-test engines have no artifact
266
- return original(self, *args, **kwargs)
275
+ result = original(self, *args, **kwargs)
276
+ self._trillboards_image_capacity = kwargs.get('max_num_images', 1)
277
+ return result
267
278
  snapshot, sha = _verified_snapshot(path)
268
279
  if 'model_path' in kwargs:
269
280
  kwargs['model_path'] = snapshot
@@ -271,11 +282,41 @@ def _apply_model_snapshot(engine_cls):
271
282
  args = (snapshot, *args[1:])
272
283
  original(self, *args, **kwargs)
273
284
  self._trillboards_artifact_sha256 = sha
285
+ self._trillboards_image_capacity = kwargs.get('max_num_images', 1)
274
286
 
275
287
  initialize._trillboards_model_snapshot = True
276
288
  engine_cls.__init__ = initialize
277
289
 
278
290
 
291
+ def _apply_native_images(module):
292
+ """Prove the exact JPEGs consumed immediately before native serialization."""
293
+ import base64
294
+ import hashlib
295
+ conversation = getattr(module, 'Conversation', None)
296
+ original = getattr(conversation, 'send_message_async', None)
297
+ if original is None or getattr(original, '_trillboards_native_images', False):
298
+ return
299
+
300
+ def send(self, message, *args, **kwargs):
301
+ state = _image_request.get()
302
+ if state is not None:
303
+ parts = message.get('content', []) if isinstance(message, dict) else []
304
+ hashes = []
305
+ for part in parts:
306
+ if isinstance(part, dict) and part.get('type') == 'image':
307
+ blob = part.get('blob')
308
+ if not isinstance(blob, str):
309
+ raise ValueError('native image must be an inline JPEG')
310
+ hashes.append(hashlib.sha256(base64.b64decode(blob, validate=True)).hexdigest())
311
+ if hashes != state['expected']:
312
+ raise ValueError('native image input count, order or bytes do not match')
313
+ state['consumed'] = hashes
314
+ return original(self, message, *args, **kwargs)
315
+
316
+ send._trillboards_native_images = True
317
+ conversation.send_message_async = send
318
+
319
+
279
320
  def apply_runtime_handler(module):
280
321
  """Bind proof to the engine used by this HTTP request, outside model text."""
281
322
  import contextvars
@@ -291,13 +332,31 @@ def apply_runtime_handler(module):
291
332
  if expected is not None and not re.fullmatch(r'[a-f0-9]{64}', expected):
292
333
  self.send_error(400, 'invalid selected artifact hash')
293
334
  return
335
+ raw_images = self.headers.get('X-Trillboards-Image-Sha256')
336
+ images = raw_images.split(',') if raw_images is not None else None
337
+ if images is not None and (expected is None or not 2 <= len(images) <= 5
338
+ or any(not re.fullmatch(r'[a-f0-9]{64}', value) for value in images)):
339
+ self.send_error(400, 'invalid pinned native image request')
340
+ return
341
+ image_token = _image_request.set({'expected': images, 'consumed': None} if images is not None else None)
294
342
  token = active.set({'expected': expected, 'proof': None})
295
343
  try:
296
344
  return original_post(self)
297
345
  finally:
298
346
  active.reset(token)
347
+ _image_request.reset(image_token)
299
348
 
300
349
  def get_engine(self, *args, **kwargs):
350
+ images = _image_request.get()
351
+ if images is not None:
352
+ server = getattr(self, 'server', None)
353
+ previous = getattr(server, 'litert_lm_engine', None)
354
+ if previous is not None and getattr(previous, '_trillboards_image_capacity', 1) < 5:
355
+ # The pinned CLI's HTTPServer is serial. Release its previous
356
+ # engine BEFORE allocating the larger one; no duplicate model.
357
+ previous.__exit__(None, None, None)
358
+ server.litert_lm_engine = None
359
+ _say('native image request: upgrading idle engine capacity to five')
301
360
  engine = original_engine(self, *args, **kwargs)
302
361
  state = active.get()
303
362
  if engine is None or state is None:
@@ -326,6 +385,11 @@ def apply_runtime_handler(module):
326
385
  state = active.get()
327
386
  if isinstance(data, dict) and data.get('object') == 'chat.completion' and state and state['proof']:
328
387
  data = {**data, 'runtimeModel': dict(state['proof'])}
388
+ images = _image_request.get()
389
+ if isinstance(data, dict) and data.get('object') == 'chat.completion' and images is not None:
390
+ if images['consumed'] != images['expected']:
391
+ raise ValueError('native image consumption was not verified')
392
+ data = {**data, 'nativeImageInputs': {'version': 1, 'sha256': images['consumed']}}
329
393
  return original_dump(data, **kwargs)
330
394
 
331
395
  handler.do_POST, handler._get_engine, module._dump_json = do_post, get_engine, dump_json
@@ -499,7 +499,7 @@ function Invoke-Commit {
499
499
  # failure and $ErrorActionPreference = 'Stop' turns that into ours.
500
500
  & $installUpdate -InstallDir $InstallDir -StagingDir $PayloadDir
501
501
  } catch {
502
- Fail 'failed' "the swap failed and the previous version was restored: $($_.Exception.Message)"
502
+ Fail 'failed' "the update transaction failed: $($_.Exception.Message)"
503
503
  }
504
504
 
505
505
  # The measured half. The probe predicts; this observes.
@@ -10,9 +10,17 @@ param(
10
10
 
11
11
  $ErrorActionPreference = "Stop"
12
12
  $previousDir = Join-Path $InstallDir ".previous"
13
+ $runtimeRepairMarker = Join-Path $previousDir 'model-runtime-rollback-failed.json'
13
14
  $parts = @("bin", "lib")
14
15
  $movedPrevious = New-Object System.Collections.Generic.List[string]
15
16
  $movedStaged = New-Object System.Collections.Generic.List[string]
17
+ $script:modelRuntimeUpdate = $null
18
+
19
+ # A failed external-runtime rollback owns this recovery tree. A later update
20
+ # must not delete it in the normal .previous cleanup or restart sensing over it.
21
+ if (Test-Path -LiteralPath $runtimeRepairMarker) {
22
+ throw 'The previous local AI rollback needs repair; saved connector/runtime backups were left untouched'
23
+ }
16
24
 
17
25
  function Assert-ConnectorVersion([string]$Root) {
18
26
  $node = Join-Path $Root "bin\node.exe"
@@ -43,6 +51,109 @@ function Register-Connector([string]$Root) {
43
51
  if ($LASTEXITCODE -ne 0) { throw "Scheduled-task registration failed." }
44
52
  }
45
53
 
54
+ function Get-OwnedLocalAiProcesses {
55
+ # Task Scheduler can stop the uv launcher while leaving its Python child
56
+ # alive. Match only this user's model server on the verified task's port;
57
+ # never kill all Python processes or a different user's listener.
58
+ $portPattern = '(?:^|\s)--port(?:\s+|=)' + $script:modelRuntimeUpdate.Port + '(?=\s|$)'
59
+ $processes = Get-CimInstance Win32_Process -Filter "Name = 'python.exe' OR Name = 'pythonw.exe' OR Name = 'litert-lm.exe'" -OperationTimeoutSec 5 -ErrorAction Stop
60
+ foreach ($process in $processes) {
61
+ if (-not $process.CommandLine -or $process.CommandLine -notmatch 'litert[-_]lm' -or
62
+ $process.CommandLine -notmatch '(?:^|\s)serve(?=\s|$)' -or $process.CommandLine -notmatch $portPattern) { continue }
63
+ $owner = Invoke-CimMethod -InputObject $process -MethodName GetOwnerSid -OperationTimeoutSec 5 -ErrorAction Stop
64
+ if ($owner.ReturnValue -ne 0 -or -not $owner.Sid) { throw 'Cannot verify the model process owner; no unverified process was stopped' }
65
+ if ($owner.Sid -eq $script:modelRuntimeUpdate.OwnerSid) { $process }
66
+ }
67
+ }
68
+
69
+ function Stop-LocalAiForUpdate {
70
+ Disable-ScheduledTask -TaskName 'TrillboardsLiteRtLm' -ErrorAction Stop | Out-Null
71
+ Stop-ScheduledTask -TaskName 'TrillboardsLiteRtLm' -ErrorAction Stop
72
+ for ($attempt = 0; $attempt -lt 15; $attempt++) {
73
+ $task = Get-ScheduledTask -TaskName 'TrillboardsLiteRtLm' -ErrorAction Stop
74
+ $remaining = @(Get-OwnedLocalAiProcesses)
75
+ foreach ($process in $remaining) { Stop-Process -Id $process.ProcessId -Force -ErrorAction Stop }
76
+ $listenerErrors = @()
77
+ $listeners = @(Get-NetTCPConnection -LocalPort $script:modelRuntimeUpdate.Port -State Listen -ErrorAction SilentlyContinue -ErrorVariable listenerErrors)
78
+ if (@($listenerErrors | Where-Object { $_.CategoryInfo.Category -ne 'ObjectNotFound' }).Count) { throw 'Cannot verify the local AI port is free' }
79
+ if ($task.State -notin @('Running', 'Queued') -and $remaining.Count -eq 0 -and $listeners.Count -eq 0) { return }
80
+ Start-Sleep -Seconds 1
81
+ }
82
+ throw 'The local AI task, server process or listening port did not stop; runtime files were not replaced'
83
+ }
84
+
85
+ function Restore-LocalAiTaskState {
86
+ if ($null -eq $script:modelRuntimeUpdate) { return }
87
+ if ($script:modelRuntimeUpdate.Enabled) {
88
+ Enable-ScheduledTask -TaskName 'TrillboardsLiteRtLm' -ErrorAction Stop | Out-Null
89
+ if ($script:modelRuntimeUpdate.Running) { Start-ScheduledTask -TaskName 'TrillboardsLiteRtLm' -ErrorAction Stop }
90
+ } else {
91
+ Disable-ScheduledTask -TaskName 'TrillboardsLiteRtLm' -ErrorAction Stop | Out-Null
92
+ }
93
+ }
94
+
95
+ function Update-LocalAiRuntime([string]$Root) {
96
+ $modelTaskErrors = @()
97
+ $task = Get-ScheduledTask -TaskName 'TrillboardsLiteRtLm' -ErrorAction SilentlyContinue -ErrorVariable modelTaskErrors
98
+ if (-not $task) {
99
+ # Only a genuine absence belongs to first-time setup. An inaccessible task
100
+ # must not be mistaken for "no model service" and silently keep old code.
101
+ if (@($modelTaskErrors | Where-Object { $_.CategoryInfo.Category -ne 'ObjectNotFound' }).Count) {
102
+ throw 'The local AI task is inaccessible; runtime refresh was not skipped'
103
+ }
104
+ return
105
+ }
106
+ $taskUser = [string]$task.Principal.UserId
107
+ $taskSid = if ($taskUser -match '^S-1-') { $taskUser } else {
108
+ (New-Object Security.Principal.NTAccount($taskUser)).Translate([Security.Principal.SecurityIdentifier]).Value
109
+ }
110
+ $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
111
+ $action = @($task.Actions)
112
+ $ownTool = Join-Path $env:USERPROFILE '.local\bin\litert-lm.exe'
113
+ if ($taskSid -ne $currentSid -or $action.Count -ne 1 -or
114
+ [IO.Path]::GetFullPath([string]$action[0].Execute) -ine [IO.Path]::GetFullPath($ownTool)) {
115
+ throw 'Cannot verify ownership of the local AI task; no runtime was changed'
116
+ }
117
+ $serverArguments = [string]$action[0].Arguments
118
+ $ports = [regex]::Matches($serverArguments, '(?:^|\s)--port(?:\s+|=)([0-9]+)(?=\s|$)')
119
+ if ($serverArguments -notmatch '^serve\s' -or $ports.Count -ne 1 -or
120
+ [long]$ports[0].Groups[1].Value -lt 1 -or [long]$ports[0].Groups[1].Value -gt 65535) {
121
+ throw 'Cannot verify the local AI task port; no runtime was changed'
122
+ }
123
+ $setup = Join-Path $Root 'bin\setup-litertlm.ps1'
124
+ if (-not (Test-Path -LiteralPath $setup -PathType Leaf)) { throw 'The released local AI setup is missing' }
125
+ # Inspect first: an unchanged shim does NOT restart a healthy model server on
126
+ # every SDK update. /v1/models and the CLI version cannot detect stale code.
127
+ $state = (& $setup -RuntimeAction Inspect | Out-String).Trim() | ConvertFrom-Json
128
+ if ($LASTEXITCODE -ne 0 -or $state.version -ne 1) { throw 'Could not inspect the installed local AI runtime' }
129
+ if (-not $state.needsRefresh) { return }
130
+ $script:modelRuntimeUpdate = [pscustomobject]@{
131
+ Enabled = [bool]$task.Settings.Enabled
132
+ Running = ($task.State -eq 'Running')
133
+ OwnerSid = $currentSid
134
+ Port = [int]$ports[0].Groups[1].Value
135
+ Setup = $setup
136
+ Backup = (Join-Path $previousDir 'litertlm-runtime')
137
+ }
138
+ Stop-LocalAiForUpdate
139
+ # This mode is local file replacement only. It never downloads a CLI/model,
140
+ # imports weights, resets tuning, re-registers tasks or runs image inference.
141
+ & $setup -RuntimeAction Refresh -RuntimeBackupDir $script:modelRuntimeUpdate.Backup
142
+ if ($LASTEXITCODE -ne 0) { throw 'The local AI runtime refresh failed' }
143
+ Restore-LocalAiTaskState
144
+ }
145
+
146
+ function Restore-LocalAiRuntime {
147
+ if ($null -eq $script:modelRuntimeUpdate) { return }
148
+ Stop-LocalAiForUpdate
149
+ $receipt = Join-Path $script:modelRuntimeUpdate.Backup 'runtime-backup.json'
150
+ if (Test-Path -LiteralPath $receipt -PathType Leaf) {
151
+ & $script:modelRuntimeUpdate.Setup -RuntimeAction Restore -RuntimeBackupDir $script:modelRuntimeUpdate.Backup
152
+ if ($LASTEXITCODE -ne 0) { throw 'The previous local AI runtime could not be verified; rollback backup was preserved' }
153
+ }
154
+ Restore-LocalAiTaskState
155
+ }
156
+
46
157
  <#
47
158
  Carry the detector's RAM ceiling onto an EXISTING install.
48
159
 
@@ -192,6 +303,9 @@ try {
192
303
  }
193
304
 
194
305
  Assert-ConnectorVersion $InstallDir
306
+ # The Python venv is OUTSIDE bin/lib. Refresh it transactionally before the
307
+ # connector can review a saved frame with the new native-image contract.
308
+ Update-LocalAiRuntime $InstallDir
195
309
  Register-Connector $InstallDir
196
310
  Assert-ConnectorScheduled
197
311
  if (Test-Path (Join-Path $InstallDir "trillboards.config.json")) {
@@ -206,11 +320,21 @@ try {
206
320
  Write-Host "Trillboards Edge update installed successfully."
207
321
  } catch {
208
322
  $failure = $_
323
+ $runtimeRollbackFailure = $null
209
324
  try {
210
325
  $stopScript = Join-Path $InstallDir "bin\stop-existing.ps1"
211
326
  if (Test-Path $stopScript) { & $stopScript -InstallDir $InstallDir }
212
327
  } catch {}
213
328
 
329
+ # Use the NEW setup while its bin tree still exists. Restore external runtime
330
+ # bytes before rolling bin/lib back; neither path touches pairings or spools.
331
+ try { Restore-LocalAiRuntime } catch { $runtimeRollbackFailure = $_.Exception.Message }
332
+ if ($runtimeRollbackFailure) {
333
+ [IO.File]::WriteAllText($runtimeRepairMarker,
334
+ (@{ version = 1; failedAtUtc = [DateTime]::UtcNow.ToString('o'); detail = $runtimeRollbackFailure } | ConvertTo-Json),
335
+ [Text.UTF8Encoding]::new($false))
336
+ throw "Update failed: $($failure.Exception.Message). Local AI rollback needs repair: $runtimeRollbackFailure. Preserved both connector trees, runtime backups, pairings and saved evidence."
337
+ }
214
338
  foreach ($part in $movedStaged) {
215
339
  Remove-Item -Recurse -Force (Join-Path $InstallDir $part) -ErrorAction SilentlyContinue
216
340
  }
@@ -24,6 +24,10 @@ param(
24
24
  [switch]$RequireBundledModel,
25
25
  [switch]$AllowModelDownload,
26
26
  [string]$ProgressPath = "",
27
+ # Upgrades refresh only our shipped Python adapter, never the CLI, weights,
28
+ # model config or task registration. The caller owns stopping/restarting it.
29
+ [ValidateSet('Setup', 'Inspect', 'Refresh', 'Restore')][string]$RuntimeAction = 'Setup',
30
+ [string]$RuntimeBackupDir = "",
27
31
  [switch]$SmokeTest
28
32
  )
29
33
 
@@ -333,6 +337,128 @@ function Import-VerifiedModel([string]$SourcePath, [string]$ExecutablePath) {
333
337
  return (($LASTEXITCODE -eq 0) -and (Test-LitertLmModel $ExecutablePath))
334
338
  }
335
339
 
340
+ # One owner for the shim on fresh installs AND upgrades. None of these helpers
341
+ # import a model, rewrite model config, install dependencies or register tasks.
342
+ function Get-LitertlmShimPaths([string]$UvPath) {
343
+ $shim = $null
344
+ foreach ($candidate in @(
345
+ (Join-Path $PSScriptRoot 'trillboards_litertlm_shim.py'),
346
+ (Join-Path $PSScriptRoot '..\litertlm\trillboards_litertlm_shim.py')
347
+ )) { if (Test-Path -LiteralPath $candidate -PathType Leaf) { $shim = $candidate; break } }
348
+ if (-not $shim) { throw 'The released litert-lm shim is missing' }
349
+ $toolDir = (& $UvPath tool dir 2>$null | Select-Object -First 1)
350
+ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($toolDir)) { throw 'Cannot resolve uv tool dir' }
351
+ $venv = [IO.Path]::GetFullPath((Join-Path $toolDir.Trim() 'litert-lm'))
352
+ $py = Join-Path $venv 'Scripts\python.exe'
353
+ if (-not (Test-Path -LiteralPath $py -PathType Leaf)) { throw 'The installed litert-lm Python is missing' }
354
+ $site = (& $py -c "import sysconfig;print(sysconfig.get_paths()['purelib'])" 2>$null | Select-Object -First 1)
355
+ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($site)) { throw 'Cannot resolve litert-lm site-packages' }
356
+ $site = [IO.Path]::GetFullPath($site.Trim())
357
+ if (-not $site.StartsWith(($venv.TrimEnd('\') + '\'), [StringComparison]::OrdinalIgnoreCase) -or
358
+ -not (Test-Path -LiteralPath $site -PathType Container)) { throw 'The runtime site is outside its managed venv' }
359
+ return [pscustomobject]@{ Source = $shim; Python = $py; Site = $site }
360
+ }
361
+
362
+ function Get-LitertlmShimFiles($Paths) {
363
+ return @(
364
+ [pscustomobject]@{ Name = 'trillboards_litertlm_shim.py'; Bytes = [IO.File]::ReadAllBytes($Paths.Source) },
365
+ [pscustomobject]@{ Name = 'trillboards-litertlm-shim.pth'; Bytes = [Text.Encoding]::ASCII.GetBytes("import trillboards_litertlm_shim`r`n") }
366
+ )
367
+ }
368
+
369
+ function Get-ShimBytesHash([byte[]]$Bytes) {
370
+ $hasher = [Security.Cryptography.SHA256]::Create()
371
+ try { return ([BitConverter]::ToString($hasher.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant() }
372
+ finally { $hasher.Dispose() }
373
+ }
374
+
375
+ function Get-LitertlmShimState([string]$UvPath) {
376
+ $paths = Get-LitertlmShimPaths $UvPath
377
+ $needsRefresh = $false
378
+ foreach ($file in (Get-LitertlmShimFiles $paths)) {
379
+ $target = Join-Path $paths.Site $file.Name
380
+ if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { $needsRefresh = $true; continue }
381
+ if ((Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant() -ne (Get-ShimBytesHash $file.Bytes)) { $needsRefresh = $true }
382
+ }
383
+ return [pscustomobject]@{ version = 1; needsRefresh = $needsRefresh }
384
+ }
385
+
386
+ function Install-LitertlmShim {
387
+ param([string]$UvPath, [string]$BackupDirectory = '', [switch]$Restore)
388
+ $ErrorActionPreference = 'Stop'
389
+ try {
390
+ $paths = Get-LitertlmShimPaths $UvPath
391
+ $files = @(Get-LitertlmShimFiles $paths)
392
+ $receiptPath = if ($BackupDirectory) { Join-Path $BackupDirectory 'runtime-backup.json' } else { '' }
393
+ if ($Restore) {
394
+ if (-not $receiptPath) { throw 'Runtime rollback requires its backup directory' }
395
+ $receipt = Get-Content -LiteralPath $receiptPath -Raw | ConvertFrom-Json
396
+ if ($receipt.version -ne 1 -or $receipt.site -ine $paths.Site -or @($receipt.files).Count -ne $files.Count) { throw 'Runtime rollback scope mismatch' }
397
+ # Validate the COMPLETE restore before changing either file. Refuse an
398
+ # unexpected concurrent write rather than overwriting another repair.
399
+ foreach ($file in $files) {
400
+ $entry = @($receipt.files | Where-Object { $_.name -eq $file.Name })
401
+ if ($entry.Count -ne 1) { throw 'Runtime rollback file set mismatch' }
402
+ $target = Join-Path $paths.Site $file.Name
403
+ if (Test-Path -LiteralPath $target) {
404
+ $current = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant()
405
+ if ($current -ne $entry[0].installedSha256 -and $current -ne $entry[0].previousSha256) { throw 'Runtime changed after the update; rollback refused' }
406
+ }
407
+ if ($entry[0].existed -and (Get-FileHash -LiteralPath (Join-Path $BackupDirectory $file.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -ne $entry[0].previousSha256) { throw 'Runtime rollback backup hash mismatch' }
408
+ }
409
+ foreach ($entry in $receipt.files) {
410
+ $target = Join-Path $paths.Site $entry.name
411
+ if ($entry.existed) { Copy-Item -LiteralPath (Join-Path $BackupDirectory $entry.name) -Destination $target -Force }
412
+ elseif (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target -Force }
413
+ if ($entry.existed) {
414
+ if ((Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant() -ne $entry.previousSha256) { throw 'Restored runtime hash mismatch' }
415
+ } elseif (Test-Path -LiteralPath $target) { throw 'Runtime rollback left a newly introduced file' }
416
+ }
417
+ Log 'restored the previous litert-lm shim bytes'
418
+ return $true
419
+ }
420
+ if ($BackupDirectory) {
421
+ if (Test-Path -LiteralPath $receiptPath) { throw 'Preserve the existing runtime rollback receipt' }
422
+ New-Item -ItemType Directory -Force -Path $BackupDirectory | Out-Null
423
+ $backups = @(foreach ($file in $files) {
424
+ $target = Join-Path $paths.Site $file.Name
425
+ $existed = Test-Path -LiteralPath $target -PathType Leaf
426
+ $previousSha = $null
427
+ if ($existed) {
428
+ $previousSha = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant()
429
+ $backup = Join-Path $BackupDirectory $file.Name
430
+ Copy-Item -LiteralPath $target -Destination $backup
431
+ if ((Get-FileHash -LiteralPath $backup -Algorithm SHA256).Hash.ToLowerInvariant() -ne $previousSha) { throw 'Runtime rollback copy did not verify' }
432
+ }
433
+ [pscustomobject]@{ name = $file.Name; existed = [bool]$existed; previousSha256 = $previousSha; installedSha256 = (Get-ShimBytesHash $file.Bytes) }
434
+ })
435
+ [IO.File]::WriteAllText($receiptPath, (@{ version = 1; site = $paths.Site; files = $backups } | ConvertTo-Json -Depth 5), [Text.UTF8Encoding]::new($false))
436
+ }
437
+ foreach ($file in $files) {
438
+ $target = Join-Path $paths.Site $file.Name
439
+ $temporary = "$target.$PID.update"
440
+ try {
441
+ [IO.File]::WriteAllBytes($temporary, $file.Bytes)
442
+ if ((Get-FileHash -LiteralPath $temporary -Algorithm SHA256).Hash.ToLowerInvariant() -ne (Get-ShimBytesHash $file.Bytes)) { throw 'Runtime refresh hash mismatch' }
443
+ # PS 5.1 binds an ordinary $null to an empty String here, which .NET
444
+ # treats as an invalid backup path. Pass an actual null String.
445
+ if (Test-Path -LiteralPath $target) { [IO.File]::Replace($temporary, $target, [NullString]::Value) }
446
+ else { [IO.File]::Move($temporary, $target) }
447
+ if ((Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant() -ne (Get-ShimBytesHash $file.Bytes)) { throw 'Installed runtime refresh hash mismatch' }
448
+ } finally { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force } }
449
+ }
450
+ # A fresh interpreter proves the installed wrapper imports. No engine,
451
+ # weights, image inference, model config or camera credentials are loaded.
452
+ & $paths.Python -c "import litert_lm,sys; sys.exit(0 if getattr(litert_lm.Engine.create_conversation,'_trillboards_litertlm_shim',False) else 1)" *>> $LogPath
453
+ if ($LASTEXITCODE -ne 0) { throw 'The refreshed model-service shim did not import' }
454
+ Log "litert-lm fast-conversation shim active in $($paths.Site)"
455
+ return $true
456
+ } catch {
457
+ Log "WARN: litert-lm shim operation failed: $_"
458
+ return $false
459
+ }
460
+ }
461
+
336
462
  if ($SmokeTest) {
337
463
  if ([string]::IsNullOrWhiteSpace($ProgressPath)) { throw "SmokeTest requires ProgressPath" }
338
464
  Write-SetupProgress -Stage "local_ai" -Title "Installing local AI" -Message "Smoke test" -Phase "installing_model" -Percent 62 -TotalBytes $ExpectedModelBytes
@@ -345,7 +471,10 @@ if ($SmokeTest) {
345
471
  exit 0
346
472
  }
347
473
 
348
- $setupMutex = New-Object System.Threading.Mutex($false, "Local\TrillboardsLiteRtLmSetup")
474
+ # S4U/boot tasks and the interactive installer run in different sessions. A
475
+ # Local mutex cannot serialize them; scope the Global name to this user's SID.
476
+ $setupSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
477
+ $setupMutex = New-Object System.Threading.Mutex($false, ("Global\TrillboardsLiteRtLmSetup-" + $setupSid))
349
478
  $haveSetupMutex = $false
350
479
  try {
351
480
  $haveSetupMutex = $setupMutex.WaitOne(0)
@@ -354,6 +483,10 @@ try {
354
483
  }
355
484
 
356
485
  if (-not $haveSetupMutex) {
486
+ if ($RuntimeAction -ne 'Setup') {
487
+ $setupMutex.Dispose()
488
+ throw 'Local AI setup is busy; no concurrent runtime refresh was attempted'
489
+ }
357
490
  Log "LiteRT-LM provisioning is already running; attaching setup progress"
358
491
  if ([string]::IsNullOrWhiteSpace($ProgressPath) -and -not $RequireBundledModel) {
359
492
  $setupMutex.Dispose()
@@ -389,6 +522,24 @@ function Stop-SetupLock {
389
522
  $setupMutex.Dispose()
390
523
  }
391
524
 
525
+ if ($RuntimeAction -ne 'Setup') {
526
+ try {
527
+ $refreshUvPath = Join-Path $LocalBin 'uv.exe'
528
+ if (-not (Test-Path -LiteralPath $refreshUvPath -PathType Leaf)) {
529
+ $refreshUv = Get-Command uv -ErrorAction Stop
530
+ $refreshUvPath = $refreshUv.Source
531
+ }
532
+ if ($RuntimeAction -eq 'Inspect') {
533
+ Get-LitertlmShimState $refreshUvPath | ConvertTo-Json -Compress
534
+ } else {
535
+ if ([string]::IsNullOrWhiteSpace($RuntimeBackupDir)) { throw 'A runtime rollback directory is required' }
536
+ $refreshArgs = @{ UvPath = $refreshUvPath; BackupDirectory = $RuntimeBackupDir; Restore = ($RuntimeAction -eq 'Restore') }
537
+ if (-not (Install-LitertlmShim @refreshArgs)) { throw 'The local AI runtime refresh/rollback did not verify' }
538
+ }
539
+ } finally { Stop-SetupLock }
540
+ exit 0
541
+ }
542
+
392
543
  Log "starting LiteRT-LM provisioning (Windows x64, CPU backend)"
393
544
  Write-SetupProgress -Stage "local_ai" -Title "Installing local AI" -Message "Preparing the private on-device AI runtime. Cloud inference remains off." -Phase "installing_runtime" -Percent 0
394
545
 
@@ -585,41 +736,6 @@ if ((Test-LitertLmCli) -and ($installedLitertLmVersion -eq $LitertLmVersion)) {
585
736
  # short-circuits that branch on a healthy CLI -- gating this behind it is exactly
586
737
  # how a repaired-and-healthy machine would keep the slow serve and report fine.
587
738
  # Never throws: this is a speed fix, and it must not be able to fail an install.
588
- function Install-LitertlmShim {
589
- param([string]$UvPath)
590
- try {
591
- $shim = $null
592
- foreach ($c in @(
593
- (Join-Path $PSScriptRoot "trillboards_litertlm_shim.py"),
594
- (Join-Path $PSScriptRoot "..\litertlm\trillboards_litertlm_shim.py")
595
- )) { if (Test-Path -LiteralPath $c -PathType Leaf) { $shim = $c; break } }
596
- if (-not $shim) { Log "WARN: litert-lm shim source not found; serve keeps stock (slower) conversation setup"; return $false }
597
-
598
- $toolDir = (& $UvPath tool dir 2>$null | Select-Object -First 1)
599
- if ([string]::IsNullOrWhiteSpace($toolDir)) { Log "WARN: could not resolve 'uv tool dir'; skipping litert-lm shim"; return $false }
600
- $py = Join-Path $toolDir.Trim() "litert-lm\Scripts\python.exe"
601
- if (-not (Test-Path -LiteralPath $py -PathType Leaf)) { Log "WARN: litert-lm venv python missing at $py; skipping shim"; return $false }
602
-
603
- $site = (& $py -c "import sysconfig;print(sysconfig.get_paths()['purelib'])" 2>$null | Select-Object -First 1)
604
- if ([string]::IsNullOrWhiteSpace($site) -or -not (Test-Path -LiteralPath $site.Trim())) {
605
- Log "WARN: could not resolve litert-lm site-packages; skipping shim"; return $false
606
- }
607
- $site = $site.Trim()
608
- Copy-Item -LiteralPath $shim -Destination (Join-Path $site "trillboards_litertlm_shim.py") -Force
609
- Set-Content -LiteralPath (Join-Path $site "trillboards-litertlm-shim.pth") -Value "import trillboards_litertlm_shim" -Encoding ASCII
610
-
611
- # Capability is established by ATTEMPTING THE OPERATION. Importing litert_lm
612
- # here is the only thing that proves the wrap landed on the class the serve
613
- # will use; a file that merely exists proves nothing.
614
- & $py -c "import litert_lm,sys; sys.exit(0 if getattr(litert_lm.Engine.create_conversation,'_trillboards_litertlm_shim',False) else 1)" *>> $LogPath
615
- if ($LASTEXITCODE -eq 0) { Log "litert-lm fast-conversation shim active in $site"; return $true }
616
- Log "WARN: litert-lm shim did not take effect; serve keeps stock (slower) conversation setup"
617
- return $false
618
- } catch {
619
- Log "WARN: litert-lm shim install failed: $_"
620
- return $false
621
- }
622
- }
623
739
  Install-LitertlmShim -UvPath $uvCmd.Source | Out-Null
624
740
 
625
741
  $bundledModelVerified = $false
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.154';
47
+ const SDK_VERSION = '0.2.157';
48
48
  class EdgeAgent extends events_1.EventEmitter {
49
49
  constructor(config, deps) {
50
50
  super();
@@ -381,6 +381,8 @@ export interface LocalAnalyzeRequest {
381
381
  expectedModel?: ModelPin;
382
382
  /** Raw JPEG bytes for this frame. */
383
383
  jpeg: Buffer;
384
+ /** One moment: entire scoped area first, then separate person views. */
385
+ images?: Buffer[];
384
386
  /** The JSON Schema the output is validated against (the profile contract). */
385
387
  schema: Record<string, unknown>;
386
388
  /** The analysis prompt (same instruction the cloud VLM gets). */
@@ -416,6 +418,7 @@ export declare class LiteRtVlmProvider {
416
418
  * an observation or a request?" — see LiteRtAcceleratorHealth.delegateConfirmed.
417
419
  */
418
420
  private confirmedModel;
421
+ private static readonly unavailableMultiImageRuntimes;
419
422
  /** Did the serve list the `,gpu` id? null = never asked (the healthy path). */
420
423
  private gpuAdvertised;
421
424
  /** Set once the GPU variant failed a whole frame and we fell back to CPU. */