@camstack/addon-post-analysis 1.2.76 → 1.2.78

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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-BsBjSNIo.js");
5
+ const require_dist = require("../dist-CFOT1Yoz.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -3390,19 +3390,26 @@ function cosineSimilarity(a, b) {
3390
3390
  }
3391
3391
  /**
3392
3392
  * Select the references a live crop is scored against for a given condition —
3393
- * **exact condition match, with NO fallback.**
3394
- *
3395
- * There used to be a silent fallback to every reference when the current
3396
- * condition had none. For a named-states monitor that was a defensible "better
3397
- * than blind". For a latched alarm it is a false positive every single night:
3398
- * the day reference is compared against an IR frame, the cosine collapses, the
3399
- * scene latches `diverged`, and the operator is told the bin was taken at
3400
- * 21:40. Cross-condition cosines are not comparable, so the honest answer to "I
3401
- * have never seen this scene in this light" is *I don't know* — never a guess
3402
- * in the direction that raises an alarm.
3403
- */
3404
- function selectConditionRefs(references, currentCondition) {
3405
- return references.filter((r) => r.condition === currentCondition);
3393
+ * **exact condition match, and by default NO fallback.**
3394
+ *
3395
+ * There used to be an unconditional silent fallback to every reference when the
3396
+ * current condition had none. For a named-states monitor that was a defensible
3397
+ * "better than blind". For a latched alarm it is a false positive every single
3398
+ * night: the day reference is compared against an IR frame, the cosine
3399
+ * collapses, the scene latches `diverged`, and the operator is told the bin was
3400
+ * taken at 21:40. Cross-condition cosines are not comparable, so the honest
3401
+ * answer to "I have never seen this scene in this light" is *I don't know* —
3402
+ * never a guess in the direction that raises an alarm.
3403
+ *
3404
+ * The fallback survives only as an explicit per-scene opt-in, and even then it
3405
+ * is a LAST resort: when the exact condition has references of its own they
3406
+ * win outright, so opting in never widens a comparison the operator already
3407
+ * covered properly.
3408
+ */
3409
+ function selectConditionRefs(references, currentCondition, crossConditionFallback) {
3410
+ const exact = references.filter((r) => r.condition === currentCondition);
3411
+ if (exact.length > 0 || !crossConditionFallback) return exact;
3412
+ return references;
3406
3413
  }
3407
3414
  /** A reference is comparable only when its `modelId` equals the live encoder's
3408
3415
  * model AND its dimension equals the encoder's embedding dim (face-matcher
@@ -3418,11 +3425,12 @@ function comparable(r, encoderModelId, encoderDim) {
3418
3425
  * Returns `{ bestStateId: null, bestScore: null }` when NOTHING was comparable
3419
3426
  * — see `ScoreResult.bestScore`.
3420
3427
  */
3421
- function scoreStates(states, liveEmbedding, currentCondition, encoderModelId, encoderDim) {
3428
+ function scoreStates(states, liveEmbedding, currentCondition, encoderModelId, encoderDim, options) {
3422
3429
  let bestStateId = null;
3423
3430
  let bestScore = null;
3431
+ const crossConditionFallback = options?.crossConditionFallback === true;
3424
3432
  for (const s of states) {
3425
- const refs = selectConditionRefs(s.references, currentCondition);
3433
+ const refs = selectConditionRefs(s.references, currentCondition, crossConditionFallback);
3426
3434
  let stateScore = null;
3427
3435
  for (const r of refs) {
3428
3436
  if (!comparable(r, encoderModelId, encoderDim)) continue;
@@ -3608,7 +3616,8 @@ var SceneEngine = class {
3608
3616
  lastCountedAt: 0,
3609
3617
  lastAvailability: "ok",
3610
3618
  lastVerdict: currentStateId === null ? "unknown" : "matched",
3611
- lastUnavailable: null
3619
+ lastUnavailable: null,
3620
+ suspendedCondition: null
3612
3621
  });
3613
3622
  }
3614
3623
  /** Force an out-of-cycle check for a single monitor. */
@@ -3631,7 +3640,8 @@ var SceneEngine = class {
3631
3640
  lastCountedAt: 0,
3632
3641
  lastAvailability: monitor.availability,
3633
3642
  lastVerdict: monitor.verdict,
3634
- lastUnavailable: monitor.unavailable
3643
+ lastUnavailable: monitor.unavailable,
3644
+ suspendedCondition: monitor.suspendedCondition
3635
3645
  };
3636
3646
  rt.lastCheckAt = now;
3637
3647
  this.runtime.set(k, rt);
@@ -3682,6 +3692,7 @@ var SceneEngine = class {
3682
3692
  availability: "unavailable",
3683
3693
  unavailableReason: llmResult.reason,
3684
3694
  coveredConditions: [],
3695
+ suspendedCondition: null,
3685
3696
  transitioned: false,
3686
3697
  checkedAt: now
3687
3698
  });
@@ -3735,12 +3746,20 @@ var SceneEngine = class {
3735
3746
  if (monitor.check.mode !== "similarity") return;
3736
3747
  const { embedding } = await this.deps.encoder.encode(crop, cropWidth, cropHeight);
3737
3748
  const info = await this.encoderInfo();
3738
- const { bestStateId, bestScore } = scoreStates(monitor.states, embedding, condition, info.modelId, info.embeddingDim);
3749
+ const { bestStateId, bestScore } = scoreStates(monitor.states, embedding, condition, info.modelId, info.embeddingDim, { crossConditionFallback: monitor.onUncoveredCondition === "judge-anyway" });
3739
3750
  const covered = [...coveredConditions(monitor.states, info.modelId, info.embeddingDim)];
3740
3751
  if (bestScore === null) {
3741
3752
  const cause = unknownCause(monitor, condition);
3742
3753
  rt.lastAvailability = "ok";
3743
3754
  this.runtime.set(k, rt);
3755
+ if (cause === "no-reference-for-condition" && covered.length > 0 && monitor.onUncoveredCondition === "skip") {
3756
+ await this.reportSuspended(deviceId, monitor, rt, {
3757
+ condition,
3758
+ covered,
3759
+ now
3760
+ });
3761
+ return;
3762
+ }
3744
3763
  await this.reportUnknown(deviceId, monitor, rt, {
3745
3764
  condition,
3746
3765
  cause,
@@ -3808,9 +3827,11 @@ var SceneEngine = class {
3808
3827
  rt.lastAvailability = "ok";
3809
3828
  this.runtime.set(k, rt);
3810
3829
  const verdict = projectVerdict(step.currentStateId, monitor.baselineStateId);
3811
- const changed = step.transitioned || verdict !== rt.lastVerdict || rt.lastAvailability !== monitor.availability || rt.lastUnavailable !== null;
3830
+ const resumed = rt.suspendedCondition !== null;
3831
+ const changed = step.transitioned || verdict !== rt.lastVerdict || rt.lastAvailability !== monitor.availability || rt.lastUnavailable !== null || resumed;
3812
3832
  rt.lastVerdict = verdict;
3813
3833
  rt.lastUnavailable = null;
3834
+ rt.suspendedCondition = null;
3814
3835
  this.runtime.set(k, rt);
3815
3836
  if (changed) await this.deps.applyEngineResult(deviceId, monitor.id, {
3816
3837
  currentStateId: step.currentStateId,
@@ -3822,20 +3843,61 @@ var SceneEngine = class {
3822
3843
  availability: "ok",
3823
3844
  unavailableReason: null,
3824
3845
  coveredConditions: covered,
3846
+ suspendedCondition: null,
3825
3847
  transitioned: step.transitioned,
3826
3848
  checkedAt: now
3827
3849
  });
3828
3850
  }
3829
3851
  /**
3852
+ * Announce that this scene is SITTING OUT the current light — once per
3853
+ * transition into it, never once per tick. At a 60 s cadence a per-tick line
3854
+ * would be ~480 lines a night per scene, which is how a log stops being read
3855
+ * at all.
3856
+ *
3857
+ * `info`, not `warn`: nothing is wrong. The operator chose not to capture
3858
+ * this light, or has not got round to it, and either way the scene is doing
3859
+ * exactly what it was configured to do. A branch that drops work still has to
3860
+ * SAY so — silence reads as "never happened" — but saying it at warning level
3861
+ * would train the operator to ignore the one channel that reports real
3862
+ * faults.
3863
+ */
3864
+ async reportSuspended(deviceId, monitor, rt, input) {
3865
+ if (rt.suspendedCondition === input.condition) return;
3866
+ rt.suspendedCondition = input.condition;
3867
+ this.deps.logger.info("scene checks paused — nothing captured in this light", {
3868
+ tags: { deviceId },
3869
+ meta: {
3870
+ monitorId: monitor.id,
3871
+ condition: input.condition,
3872
+ covered: input.covered.join(",")
3873
+ }
3874
+ });
3875
+ await this.deps.applyEngineResult(deviceId, monitor.id, {
3876
+ currentStateId: rt.hysteresis.currentStateId,
3877
+ previousStateId: rt.hysteresis.currentStateId,
3878
+ lastConfidence: null,
3879
+ currentCondition: input.condition,
3880
+ verdict: rt.lastVerdict,
3881
+ unavailable: rt.lastUnavailable,
3882
+ availability: "ok",
3883
+ unavailableReason: null,
3884
+ coveredConditions: input.covered,
3885
+ suspendedCondition: input.condition,
3886
+ transitioned: false,
3887
+ checkedAt: input.now
3888
+ });
3889
+ }
3890
+ /**
3830
3891
  * Push an `unknown` verdict — once per transition, not once per poll. The
3831
3892
  * held state is deliberately NOT destroyed: a scene that cannot judge is a
3832
3893
  * scene that has not observed anything, and an observation it could not make
3833
3894
  * must never spend or clear hysteresis credit in either direction.
3834
3895
  */
3835
3896
  async reportUnknown(deviceId, monitor, rt, input) {
3836
- if (rt.lastVerdict === "unknown" && rt.lastUnavailable === input.cause) return;
3897
+ if (rt.lastVerdict === "unknown" && rt.lastUnavailable === input.cause && rt.suspendedCondition === null) return;
3837
3898
  rt.lastVerdict = "unknown";
3838
3899
  rt.lastUnavailable = input.cause;
3900
+ rt.suspendedCondition = null;
3839
3901
  this.deps.logger.warn("scene cannot judge — reporting unknown", {
3840
3902
  tags: { deviceId },
3841
3903
  meta: {
@@ -3855,6 +3917,7 @@ var SceneEngine = class {
3855
3917
  availability: "ok",
3856
3918
  unavailableReason: `no comparable reference for "${input.condition}"`,
3857
3919
  coveredConditions: input.covered,
3920
+ suspendedCondition: null,
3858
3921
  transitioned: false,
3859
3922
  checkedAt: input.now
3860
3923
  });
@@ -4577,6 +4640,8 @@ var SceneMonitorProvider = class {
4577
4640
  minObservationSpacingSec: 120,
4578
4641
  anchorThreshold: require_dist.SCENE_DEFAULT_ANCHOR_THRESHOLD,
4579
4642
  autoRestore: false,
4643
+ onUncoveredCondition: require_dist.SCENE_DEFAULT_UNCOVERED_POLICY,
4644
+ suspendedCondition: null,
4580
4645
  unavailable: "no-reference-for-condition",
4581
4646
  coveredConditions: []
4582
4647
  };
@@ -4606,7 +4671,9 @@ var SceneMonitorProvider = class {
4606
4671
  this.refresh(input.deviceId);
4607
4672
  }
4608
4673
  async deleteScene(input) {
4674
+ const doomed = this.deps.store.getScene(input.deviceId, input.monitorId);
4609
4675
  await this.deps.store.deleteScene(input.deviceId, input.monitorId);
4676
+ await this.dropOrphanedMedia(doomed?.monitor.states.flatMap((s) => s.references) ?? []);
4610
4677
  this.deps.logger.info("scene deleted", {
4611
4678
  tags: { deviceId: input.deviceId },
4612
4679
  meta: { monitorId: input.monitorId }
@@ -4629,7 +4696,9 @@ var SceneMonitorProvider = class {
4629
4696
  const stateId = input.stateId ?? monitor.states[0]?.id ?? (0, node_crypto.randomUUID)();
4630
4697
  const existing = monitor.states.find((s) => s.id === stateId);
4631
4698
  const label = input.label ?? existing?.label ?? monitor.label;
4632
- const references = boundReferences([...existing?.references ?? [], reference], condition);
4699
+ const kept = [...existing?.references ?? [], reference];
4700
+ const references = boundReferences(kept, condition);
4701
+ await this.dropOrphanedMedia(kept.filter((r) => !references.includes(r)));
4633
4702
  const state = {
4634
4703
  id: stateId,
4635
4704
  label,
@@ -4663,6 +4732,7 @@ var SceneMonitorProvider = class {
4663
4732
  }
4664
4733
  async deleteReference(input) {
4665
4734
  const monitor = this.requireScene(input.deviceId, input.monitorId);
4735
+ const removed = monitor.states.find((s) => s.id === input.stateId)?.references[input.index];
4666
4736
  const states = monitor.states.map((s) => {
4667
4737
  if (s.id !== input.stateId) return s;
4668
4738
  const references = s.references.filter((_, i) => i !== input.index);
@@ -4676,6 +4746,17 @@ var SceneMonitorProvider = class {
4676
4746
  ...monitor,
4677
4747
  states
4678
4748
  }, this.now());
4749
+ await this.dropOrphanedMedia(removed === void 0 ? [] : [removed]);
4750
+ this.deps.logger.info("scene reference deleted", {
4751
+ tags: { deviceId: input.deviceId },
4752
+ meta: {
4753
+ monitorId: input.monitorId,
4754
+ stateId: input.stateId,
4755
+ index: input.index,
4756
+ condition: removed?.condition ?? "(none)",
4757
+ remaining: states.reduce((n, s) => n + s.references.length, 0)
4758
+ }
4759
+ });
4679
4760
  this.refresh(input.deviceId);
4680
4761
  }
4681
4762
  async recheckNow(input) {
@@ -4760,6 +4841,15 @@ var SceneMonitorProvider = class {
4760
4841
  const row = this.deps.store.getScene(deviceId, monitorId);
4761
4842
  if (row === void 0) return;
4762
4843
  const monitor = row.monitor;
4844
+ if (result.suspendedCondition !== null) {
4845
+ await this.deps.store.putScene(deviceId, {
4846
+ ...monitor,
4847
+ suspendedCondition: result.suspendedCondition,
4848
+ coveredConditions: [...result.coveredConditions]
4849
+ }, result.checkedAt);
4850
+ this.refresh(deviceId);
4851
+ return;
4852
+ }
4763
4853
  const previous = this.deps.store.getLatch(deviceId, monitorId);
4764
4854
  const wasLatched = previous?.latched ?? monitor.latched;
4765
4855
  const wasVerdict = previous?.verdict ?? monitor.verdict;
@@ -4786,6 +4876,7 @@ var SceneMonitorProvider = class {
4786
4876
  const merged = {
4787
4877
  ...monitor,
4788
4878
  coveredConditions: [...result.coveredConditions],
4879
+ suspendedCondition: null,
4789
4880
  availability: result.availability,
4790
4881
  unavailableReason: result.unavailableReason
4791
4882
  };
@@ -4821,6 +4912,7 @@ var SceneMonitorProvider = class {
4821
4912
  direction,
4822
4913
  verdict: result.verdict,
4823
4914
  latched,
4915
+ emit: monitor.emit,
4824
4916
  condition: result.currentCondition,
4825
4917
  confidence: result.lastConfidence,
4826
4918
  timestamp: result.checkedAt
@@ -4888,8 +4980,29 @@ var SceneMonitorProvider = class {
4888
4980
  updatedAt: now
4889
4981
  };
4890
4982
  }
4983
+ /**
4984
+ * Drop the pictures of references that are no longer stored anywhere.
4985
+ *
4986
+ * Best-effort per blob and never throwing: the reference row is already gone,
4987
+ * and refusing the operator's delete because a JPEG would not unlink would be
4988
+ * failing the request over the follower rather than the fact.
4989
+ */
4990
+ async dropOrphanedMedia(references) {
4991
+ const drop = this.deps.dropMedia;
4992
+ if (drop === void 0) return;
4993
+ for (const r of references) {
4994
+ if (r.thumbnailMediaId === void 0) continue;
4995
+ await drop(r.thumbnailMediaId).catch((err) => {
4996
+ this.deps.logger.debug("scene reference thumbnail delete failed", { meta: {
4997
+ mediaId: r.thumbnailMediaId,
4998
+ error: String(err)
4999
+ } });
5000
+ });
5001
+ }
5002
+ }
4891
5003
  /** Snapshot → ROI crop + whole-frame anchor → two embeddings + a thumbnail. */
4892
5004
  async encodeReference(deviceId, monitor, condition) {
5005
+ const capturedAt = this.now();
4893
5006
  const snap = await this.deps.getSnapshot(deviceId, true);
4894
5007
  if (snap === null) throw new Error("no snapshot available for this camera");
4895
5008
  const encoded = Buffer.from(snap.base64, "base64");
@@ -4907,15 +5020,15 @@ var SceneMonitorProvider = class {
4907
5020
  });
4908
5021
  const thumbnailMediaId = await this.deps.putMedia?.({
4909
5022
  deviceId,
4910
- ownerId: `scene-${monitor.id}-${condition}`,
5023
+ ownerId: `scene-${monitor.id}-${condition}-${capturedAt}`,
4911
5024
  data: roi.crop,
4912
- timestamp: this.now()
5025
+ timestamp: capturedAt
4913
5026
  }).catch(() => void 0);
4914
5027
  return {
4915
5028
  embedding,
4916
5029
  modelId: info.modelId,
4917
5030
  condition,
4918
- capturedAt: this.now(),
5031
+ capturedAt,
4919
5032
  ...thumbnailMediaId !== void 0 ? { thumbnailMediaId } : {},
4920
5033
  ...anchor !== null ? { anchorEmbedding: anchor } : {}
4921
5034
  };
@@ -5411,6 +5524,256 @@ function observeLabel(deviceId, spec, sample, now) {
5411
5524
  spec
5412
5525
  };
5413
5526
  }
5527
+ /** JPEG quality for the downscaled full frame — matches the crop path. */
5528
+ var FULL_FRAME_QUALITY = 80;
5529
+ /**
5530
+ * Downscale an already-encoded JPEG full frame to FIT WITHIN
5531
+ * {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
5532
+ * ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
5533
+ * box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
5534
+ * track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
5535
+ * at night) is never stored or served — the privacy fix moved to CAPTURE time.
5536
+ */
5537
+ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
5538
+ return (0, sharp.default)(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
5539
+ fit: "inside",
5540
+ withoutEnlargement: true
5541
+ }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
5542
+ }
5543
+ //#endregion
5544
+ //#region src/notification-center/audio-still.ts
5545
+ /**
5546
+ * The audio still shelf — the PHOTOGRAPH a sound rule's notification carries.
5547
+ *
5548
+ * ## Why a sound needs one at all
5549
+ *
5550
+ * The attachment ladder resolves media by OWNER, and every other trigger has
5551
+ * one: an object or package event owns its crops, a closed track owns its best
5552
+ * shot, a doorbell press owns the marker track the same press projected
5553
+ * (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
5554
+ * counted (`chooseOccupancyMediaOwner`). An audio match owns nothing. Nothing
5555
+ * was boxed, nothing was tracked, and — deliberately — nothing is persisted at
5556
+ * all: a confirmed window is a claim about sound that has already stopped, and
5557
+ * `event-intake.ts` states why replaying it later would be wrong.
5558
+ *
5559
+ * So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
5560
+ * of the match. Not of the sound — of what the camera could see while it was
5561
+ * heard.
5562
+ *
5563
+ * ## Three properties, and each one is a decision
5564
+ *
5565
+ * **It is not a record.** The bytes live here, in RAM, under an owner id and a
5566
+ * TTL that covers the outbox's whole retry horizon — and nowhere else. The
5567
+ * alternative was the doorbell's: materialise a synthetic marker track through
5568
+ * `SyntheticTrackMaterializer` and let the notification name it. That would put
5569
+ * a durable Track on the camera's timeline for every confirmed window, feeding
5570
+ * the digest's `listTracks`, retention, and the audio-marker feature's own
5571
+ * operator ceilings (`audio-marker-projector.ts` exists precisely to bound how
5572
+ * many audio markers a camera may emit). A notification must not manufacture
5573
+ * timeline history as a side effect of wanting a picture.
5574
+ *
5575
+ * **The capture STARTS immediately and is never awaited.** A sound is transient
5576
+ * — a scream is over before a snapshot round-trip completes — so the fetch is
5577
+ * kicked off at the confirmation, before the rule evaluation runs, and the
5578
+ * owner id is minted synchronously so the outbox row can name it. The bytes
5579
+ * land while the row waits in the queue, and the dispatcher's existing bounded
5580
+ * still-wait (or the pause its own footage render already costs) picks them up.
5581
+ * A camera that never answers costs the picture and never the notification.
5582
+ *
5583
+ * **Two confirmations seconds apart share ONE capture.** A barking dog confirms
5584
+ * repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
5585
+ * rule's cooldown is its only brake, and the cooldown is applied AFTER this.
5586
+ * Without a reuse window this would photograph a camera at whatever rate the
5587
+ * sound happens to occur. Each confirmation still gets its OWN owner id, so two
5588
+ * outbox rows are never mistaken for one subject; they merely point at the same
5589
+ * frame, which is the truth — the scene did not change in ten seconds.
5590
+ *
5591
+ * Nothing here is silent: a capture that lands and a camera that refuses each
5592
+ * emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
5593
+ * and 615 not" is the only form that question is ever asked in.
5594
+ */
5595
+ /**
5596
+ * The owner-id namespace. It is what routes a lookup here instead of to the
5597
+ * media store, and it is the reason the dispatcher needs no audio branch —
5598
+ * `getMediaForOwner` answers for both under one signature.
5599
+ */
5600
+ var NC_AUDIO_STILL_PREFIX = "nc-audio-still:";
5601
+ /** True for an owner id this shelf minted. */
5602
+ function isAudioStillId(id) {
5603
+ return id.startsWith(NC_AUDIO_STILL_PREFIX);
5604
+ }
5605
+ /**
5606
+ * How long the bytes are held.
5607
+ *
5608
+ * The outbox retries 8 times with a 5 s → 300 s backoff, which tops out around
5609
+ * ten minutes; fifteen covers that with room for a slow drain. Past it the row
5610
+ * ships text-only, which is the correct degradation for a photograph of a scene
5611
+ * that is a quarter of an hour stale anyway.
5612
+ */
5613
+ var NC_AUDIO_STILL_TTL_MS = 15 * 6e4;
5614
+ /** A hanging snapshot cap must not pin a capture slot forever. */
5615
+ var SNAPSHOT_TIMEOUT_MS = 8e3;
5616
+ /**
5617
+ * Hard bound on held captures. Reached only if every camera on the hub confirms
5618
+ * an audio rule inside one TTL; the oldest is dropped first, which costs a
5619
+ * fifteen-minute-old picture nobody is waiting for.
5620
+ */
5621
+ var MAX_CAPTURES = 64;
5622
+ var NcAudioStillShelf = class {
5623
+ deps;
5624
+ /** captureId → the photograph. */
5625
+ captures = /* @__PURE__ */ new Map();
5626
+ /** ownerId → captureId. Several owners may name one capture (the reuse window). */
5627
+ owners = /* @__PURE__ */ new Map();
5628
+ /** deviceId → its newest capture, for the reuse window. */
5629
+ newest = /* @__PURE__ */ new Map();
5630
+ constructor(deps) {
5631
+ this.deps = deps;
5632
+ }
5633
+ /**
5634
+ * Photograph `deviceId` for a match at `atMs`, and return the owner id the
5635
+ * subject should name. SYNCHRONOUS by contract: the caller is on the audio
5636
+ * confirmation path and the outbox row is built from what this returns.
5637
+ */
5638
+ capture(deviceId, atMs) {
5639
+ const now = this.deps.now();
5640
+ this.prune(now);
5641
+ const ownerId = `${NC_AUDIO_STILL_PREFIX}${(0, node_crypto.randomUUID)()}`;
5642
+ const recent = this.newest.get(deviceId);
5643
+ if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
5644
+ this.owners.set(ownerId, recent.captureId);
5645
+ return ownerId;
5646
+ }
5647
+ const captureId = (0, node_crypto.randomUUID)();
5648
+ this.captures.set(captureId, {
5649
+ deviceId,
5650
+ startedAt: now,
5651
+ expiresAt: now + NC_AUDIO_STILL_TTL_MS,
5652
+ files: []
5653
+ });
5654
+ this.newest.set(deviceId, {
5655
+ captureId,
5656
+ at: now
5657
+ });
5658
+ this.owners.set(ownerId, captureId);
5659
+ this.fetch(captureId, deviceId, atMs);
5660
+ return ownerId;
5661
+ }
5662
+ /**
5663
+ * The media an owner id holds, or `undefined` when this shelf never minted
5664
+ * it. An EMPTY array is a different answer: the capture exists and has not
5665
+ * landed (or never will), which is exactly the case the dispatcher's bounded
5666
+ * wait and its `no still could be resolved` line are for.
5667
+ */
5668
+ get(ownerId) {
5669
+ const captureId = this.owners.get(ownerId);
5670
+ if (captureId === void 0) return void 0;
5671
+ return this.captures.get(captureId)?.files ?? [];
5672
+ }
5673
+ /** Drop expired captures and the owners that named them. */
5674
+ prune(now) {
5675
+ for (const [captureId, held] of this.captures) {
5676
+ if (held.expiresAt > now) continue;
5677
+ this.captures.delete(captureId);
5678
+ if (this.newest.get(held.deviceId)?.captureId === captureId) this.newest.delete(held.deviceId);
5679
+ }
5680
+ while (this.captures.size > MAX_CAPTURES) {
5681
+ const oldest = this.captures.keys().next();
5682
+ if (oldest.done === true) break;
5683
+ const held = this.captures.get(oldest.value);
5684
+ this.captures.delete(oldest.value);
5685
+ if (held !== void 0 && this.newest.get(held.deviceId)?.captureId === oldest.value) this.newest.delete(held.deviceId);
5686
+ }
5687
+ for (const [ownerId, captureId] of this.owners) if (!this.captures.has(captureId)) this.owners.delete(ownerId);
5688
+ }
5689
+ /** Drop everything (shutdown). */
5690
+ clear() {
5691
+ this.captures.clear();
5692
+ this.owners.clear();
5693
+ this.newest.clear();
5694
+ }
5695
+ async fetch(captureId, deviceId, atMs) {
5696
+ try {
5697
+ const shot = await withTimeout$2(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
5698
+ if (shot === null || shot.base64.length === 0) {
5699
+ this.reportMiss(deviceId, "the camera returned no snapshot");
5700
+ return;
5701
+ }
5702
+ const raw = Buffer.from(shot.base64, "base64");
5703
+ let data = raw;
5704
+ try {
5705
+ data = await downscaleFullFrameJpeg(raw, 960, 540);
5706
+ } catch {}
5707
+ const held = this.captures.get(captureId);
5708
+ if (held === void 0) return;
5709
+ this.captures.set(captureId, {
5710
+ ...held,
5711
+ files: audioStillMedia(data, atMs)
5712
+ });
5713
+ this.deps.logger.info("audio still captured", {
5714
+ tags: { deviceId },
5715
+ meta: {
5716
+ bytes: data.byteLength,
5717
+ tookMs: this.deps.now() - held.startedAt
5718
+ }
5719
+ });
5720
+ } catch (err) {
5721
+ this.reportMiss(deviceId, err instanceof Error ? err.message : String(err));
5722
+ }
5723
+ }
5724
+ /**
5725
+ * A branch that drops work says so. This one costs the operator the picture
5726
+ * on a notification they DID receive, so it is a warn and it carries the
5727
+ * camera — the only key the question is ever asked with.
5728
+ */
5729
+ reportMiss(deviceId, reason) {
5730
+ this.deps.logger.warn("the camera did not answer the audio still — this notification ships text-only", {
5731
+ tags: { deviceId },
5732
+ meta: { reason }
5733
+ });
5734
+ }
5735
+ };
5736
+ /**
5737
+ * The photograph, in the three kinds the CLEAN-SCENE ladders ask for.
5738
+ *
5739
+ * `keyFrameSmall` leads because it is the first rung of both `attach: 'best'`
5740
+ * and `attach: 'keyFrame'` on a track owner; `keyFrame` and `fullFrame` answer
5741
+ * `frame: 'full'` and `frame: 'boxed'`'s honest degrade. There is deliberately
5742
+ * no `crop` / `thumbnail`: nothing was boxed, so `frame: 'cropped'` resolves
5743
+ * NOTHING and the notification ships text-only with the ordinary line saying
5744
+ * so. Inventing a centre crop would answer a different question from the one
5745
+ * the operator asked.
5746
+ */
5747
+ function audioStillMedia(data, timestamp) {
5748
+ const base64 = data.toString("base64");
5749
+ const file = (kind) => ({
5750
+ key: `${NC_AUDIO_STILL_PREFIX}${kind}`,
5751
+ kind,
5752
+ base64,
5753
+ sizeBytes: data.byteLength,
5754
+ timestamp
5755
+ });
5756
+ return [
5757
+ file("keyFrameSmall"),
5758
+ file("keyFrame"),
5759
+ file("fullFrame")
5760
+ ];
5761
+ }
5762
+ /** Reject if `promise` does not settle within `ms`. */
5763
+ function withTimeout$2(promise, ms) {
5764
+ return new Promise((resolve, reject) => {
5765
+ const timer = setTimeout(() => {
5766
+ reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
5767
+ }, ms);
5768
+ promise.then((value) => {
5769
+ clearTimeout(timer);
5770
+ resolve(value);
5771
+ }, (err) => {
5772
+ clearTimeout(timer);
5773
+ reject(err instanceof Error ? err : new Error(String(err)));
5774
+ });
5775
+ });
5776
+ }
5414
5777
  //#endregion
5415
5778
  //#region src/shared/llm-vision/prompt-hygiene.ts
5416
5779
  /**
@@ -6021,6 +6384,298 @@ var NcDeviceStateCache = class {
6021
6384
  }
6022
6385
  };
6023
6386
  //#endregion
6387
+ //#region src/shared/frame/box-drawer.ts
6388
+ var DEFAULT_COLOR = require_dist.DEFAULT_EVENT_COLOR;
6389
+ var DEFAULT_QUALITY = 80;
6390
+ var STROKE_WIDTH = 3;
6391
+ function escapeXml$2(s) {
6392
+ return s.replace(/[<>&'"]/g, (ch) => {
6393
+ switch (ch) {
6394
+ case "<": return "&lt;";
6395
+ case ">": return "&gt;";
6396
+ case "&": return "&amp;";
6397
+ case "'": return "&apos;";
6398
+ default: return "&quot;";
6399
+ }
6400
+ });
6401
+ }
6402
+ /** Clamp a pixel box to the frame so the rect always stays inside [0,W]×[0,H]. */
6403
+ function clampBox(b, frameWidth, frameHeight) {
6404
+ const x = Math.max(0, Math.min(Math.round(b.x), frameWidth - 1));
6405
+ const y = Math.max(0, Math.min(Math.round(b.y), frameHeight - 1));
6406
+ return {
6407
+ x,
6408
+ y,
6409
+ w: Math.max(1, Math.min(Math.round(b.w), frameWidth - x)),
6410
+ h: Math.max(1, Math.min(Math.round(b.h), frameHeight - y))
6411
+ };
6412
+ }
6413
+ /**
6414
+ * Draw bounding boxes over a raw RGB frame and JPEG-encode it.
6415
+ *
6416
+ * The frame is composited with an SVG overlay (one `<rect>` per box, plus an
6417
+ * optional caption) at full resolution, then optionally downscaled — so the
6418
+ * box stays crisp relative to the scene. Boxes are clamped to frame bounds to
6419
+ * avoid sharp `extract`/region errors on slightly-out-of-range detections.
6420
+ */
6421
+ async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts = {}) {
6422
+ const quality = opts.quality ?? DEFAULT_QUALITY;
6423
+ let base = frameData;
6424
+ let baseIsRaw = true;
6425
+ if (boxes.length > 0) {
6426
+ const fontSize = Math.max(12, Math.round(frameHeight / 30));
6427
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">${boxes.map((b) => {
6428
+ const c = clampBox(b, frameWidth, frameHeight);
6429
+ const color = b.color ?? DEFAULT_COLOR;
6430
+ const rect = `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" fill="none" stroke="${color}" stroke-width="${STROKE_WIDTH}"/>`;
6431
+ if (!b.label) return rect;
6432
+ const ty = Math.max(fontSize, c.y - 4);
6433
+ return rect + `<text x="${c.x}" y="${ty}" font-family="sans-serif" font-size="${fontSize}" fill="${color}" stroke="black" stroke-width="0.5">${escapeXml$2(b.label)}</text>`;
6434
+ }).join("")}</svg>`;
6435
+ base = await (0, sharp.default)(frameData, { raw: {
6436
+ width: frameWidth,
6437
+ height: frameHeight,
6438
+ channels: 3
6439
+ } }).composite([{
6440
+ input: Buffer.from(svg),
6441
+ top: 0,
6442
+ left: 0
6443
+ }]).png().toBuffer();
6444
+ baseIsRaw = false;
6445
+ }
6446
+ let pipeline = baseIsRaw ? (0, sharp.default)(base, { raw: {
6447
+ width: frameWidth,
6448
+ height: frameHeight,
6449
+ channels: 3
6450
+ } }) : (0, sharp.default)(base);
6451
+ if (opts.maxWidth !== void 0) pipeline = pipeline.resize({
6452
+ width: opts.maxWidth,
6453
+ withoutEnlargement: true
6454
+ });
6455
+ return pipeline.jpeg({ quality }).toBuffer();
6456
+ }
6457
+ /** JPEG quality of the composed artefact — the mosaic's number, same reasons. */
6458
+ var GROUP_FRAME_QUALITY = 82;
6459
+ /**
6460
+ * Draw the members onto the frame. Returns `null` when there is nothing to
6461
+ * draw, or when anything at all goes wrong — the caller then ships the
6462
+ * unannotated frame unchanged.
6463
+ */
6464
+ async function composeGroupFrame(input) {
6465
+ const freshnessMs = input.freshnessMs ?? 3e3;
6466
+ const fresh = input.boxes.filter((b) => Math.abs(input.at - b.observedAt) <= freshnessMs);
6467
+ const withheld = input.boxes.length - fresh.length;
6468
+ if (fresh.length < 2) {
6469
+ if (withheld > 0) input.logger.info("group frame not drawn — too few members with fresh geometry", {
6470
+ tags: { deviceId: input.deviceId },
6471
+ meta: {
6472
+ groupKey: input.groupKey,
6473
+ members: input.boxes.length,
6474
+ fresh: fresh.length,
6475
+ withheld
6476
+ }
6477
+ });
6478
+ return null;
6479
+ }
6480
+ try {
6481
+ const decoded = await (0, sharp.default)(input.jpeg).raw().toBuffer({ resolveWithObject: true });
6482
+ const { width, height, channels } = decoded.info;
6483
+ if (!(width > 0 && height > 0) || channels !== 3) {
6484
+ input.logger.warn("group frame not drawn — unexpected raster shape", {
6485
+ tags: { deviceId: input.deviceId },
6486
+ meta: {
6487
+ groupKey: input.groupKey,
6488
+ width,
6489
+ height,
6490
+ channels
6491
+ }
6492
+ });
6493
+ return null;
6494
+ }
6495
+ const boxes = fresh.map((b) => ({
6496
+ x: b.bbox.x * width,
6497
+ y: b.bbox.y * height,
6498
+ w: b.bbox.w * width,
6499
+ h: b.bbox.h * height,
6500
+ ...b.label !== void 0 && b.label.length > 0 ? { label: b.label } : {}
6501
+ }));
6502
+ const bytes = await drawBoxedFrame(decoded.data, width, height, boxes, { quality: GROUP_FRAME_QUALITY });
6503
+ input.logger.debug("group frame composed", {
6504
+ tags: { deviceId: input.deviceId },
6505
+ meta: {
6506
+ groupKey: input.groupKey,
6507
+ drawn: boxes.length,
6508
+ withheld,
6509
+ width,
6510
+ height
6511
+ }
6512
+ });
6513
+ return {
6514
+ bytes,
6515
+ drawn: boxes.length,
6516
+ withheld
6517
+ };
6518
+ } catch (err) {
6519
+ input.logger.warn("group frame compose failed — shipping the unannotated frame", {
6520
+ tags: { deviceId: input.deviceId },
6521
+ meta: {
6522
+ groupKey: input.groupKey,
6523
+ error: err instanceof Error ? err.message : String(err)
6524
+ }
6525
+ });
6526
+ return null;
6527
+ }
6528
+ }
6529
+ //#endregion
6530
+ //#region src/notification-center/text-compose.ts
6531
+ /** One normalisation, used on both sides of every class lookup — a pipeline
6532
+ * that emitted `Person` must find the key written `class.person`. */
6533
+ function normaliseClass(className) {
6534
+ return className.trim().toLowerCase();
6535
+ }
6536
+ /**
6537
+ * The noun for a class, in the count's plural category.
6538
+ *
6539
+ * A class no locale names falls through to `class.other`, which prints the RAW
6540
+ * name: a custom model's class must appear in the sentence rather than vanish
6541
+ * out of it, and a missing translation is a smaller defect than a missing
6542
+ * detection.
6543
+ */
6544
+ function ncClassNoun(texts, className, count) {
6545
+ const key = `class.${normaliseClass(className)}`;
6546
+ if (texts.has(key)) return texts.text({
6547
+ key,
6548
+ count,
6549
+ vars: { name: className }
6550
+ });
6551
+ return texts.text({
6552
+ key: "class.other",
6553
+ count,
6554
+ vars: { name: className }
6555
+ });
6556
+ }
6557
+ /** `person (John)` / `persona (John)` — the thing the notification is about. */
6558
+ function ncSubjectText(texts, input) {
6559
+ const classNoun = ncClassNoun(texts, input.className, 1);
6560
+ if (input.label === void 0) return texts.text({
6561
+ key: "detection.subject",
6562
+ vars: { classNoun }
6563
+ });
6564
+ return texts.text({
6565
+ key: subjectLabelKey(input.labelKind),
6566
+ vars: {
6567
+ classNoun,
6568
+ label: input.label
6569
+ }
6570
+ });
6571
+ }
6572
+ /**
6573
+ * Which labelled-subject key a recognition gets. A kind the catalog does not
6574
+ * name falls back to the generic key rather than printing nothing — the same
6575
+ * direction `ncClassNoun` degrades in, and for the same reason.
6576
+ */
6577
+ function subjectLabelKey(kind) {
6578
+ if (kind === "identity") return "detection.subject.identity";
6579
+ if (kind === "plate") return "detection.subject.plate";
6580
+ return "detection.subject.labelled";
6581
+ }
6582
+ /**
6583
+ * ` in gate, drive` — INCLUDING its leading space, and empty when there are no
6584
+ * zones.
6585
+ *
6586
+ * The space belongs to the fragment rather than to the body key because the
6587
+ * alternative is a body that ends in a trailing space on every zone-less
6588
+ * notification. A translator moving the clause moves the space with it.
6589
+ */
6590
+ function ncInZonesText(texts, zoneLabels) {
6591
+ if (zoneLabels.length === 0) return "";
6592
+ return texts.text({
6593
+ key: "detection.inZones",
6594
+ vars: { zones: zoneLabels.join(", ") }
6595
+ });
6596
+ }
6597
+ /** The human-readable edge polarity behind `{{op}}` and the occupancy body. */
6598
+ function ncOccupancyOp(texts, occupied) {
6599
+ return texts.text({ key: occupied ? "occupancy.op.occupied" : "occupancy.op.free" });
6600
+ }
6601
+ /**
6602
+ * `1 persona, 2 veicoli e 3 animali` — the language's own conjunction, its own
6603
+ * plural rules, and its own nouns.
6604
+ *
6605
+ * Zero-count classes are dropped: a selected class the window never contained
6606
+ * is a legitimate `0` in `{{count_vehicle}}`, but "0 veicoli" inside a summary
6607
+ * sentence is a line nobody reads. Order is the caller's (busiest first) and is
6608
+ * preserved, so the same night always produces the same sentence.
6609
+ */
6610
+ function ncDetectionSummary(texts, counts) {
6611
+ const parts = counts.filter((entry) => entry.count > 0).map((entry) => `${entry.count} ${ncClassNoun(texts, entry.className, entry.count)}`);
6612
+ return texts.join(parts);
6613
+ }
6614
+ //#endregion
6615
+ //#region src/notification-center/group/nc-group-text.ts
6616
+ var DEFAULT_MAX_NAMES = 2;
6617
+ /**
6618
+ * `N altre persone` — the remainder phrase for one class, in its own gender.
6619
+ *
6620
+ * Mirrors {@link ncClassNoun} byte for byte, including the fallback: a class no
6621
+ * locale names falls through to `group.others.other`, which prints the raw class
6622
+ * name rather than vanishing. A custom model's class must appear in the sentence.
6623
+ */
6624
+ function ncGroupOthers(texts, className, count) {
6625
+ const classNoun = ncClassNoun(texts, className, count);
6626
+ const key = `group.others.${className.trim().toLowerCase()}`;
6627
+ if (texts.has(key)) return texts.text({
6628
+ key,
6629
+ count,
6630
+ vars: {
6631
+ count: `${count}`,
6632
+ classNoun
6633
+ }
6634
+ });
6635
+ return texts.text({
6636
+ key: "group.others.other",
6637
+ count,
6638
+ vars: {
6639
+ count: `${count}`,
6640
+ classNoun
6641
+ }
6642
+ });
6643
+ }
6644
+ /** Counts per class, in first-seen order so the same burst always reads the same. */
6645
+ function countByClass(members) {
6646
+ const counts = /* @__PURE__ */ new Map();
6647
+ for (const m of members) counts.set(m.className, (counts.get(m.className) ?? 0) + 1);
6648
+ return [...counts].map(([className, count]) => ({
6649
+ className,
6650
+ count
6651
+ }));
6652
+ }
6653
+ /**
6654
+ * The finished `{{subject}}` phrase for a group.
6655
+ *
6656
+ * Pure: a catalog in, a string out. No clock, no store, no I/O — like every
6657
+ * other composer in `text-compose.ts`, and for the same reason.
6658
+ */
6659
+ function ncGroupSubjectText(texts, input) {
6660
+ const members = input.members;
6661
+ const only = members.length === 1 ? members[0] : void 0;
6662
+ if (only !== void 0) return ncSubjectText(texts, {
6663
+ className: only.className,
6664
+ ...only.label !== void 0 ? { label: only.label } : {},
6665
+ ...only.labelKind !== void 0 ? { labelKind: only.labelKind } : {}
6666
+ });
6667
+ if (members.length === 0) return "";
6668
+ const maxNames = input.maxNames !== void 0 && input.maxNames >= 0 ? input.maxNames : DEFAULT_MAX_NAMES;
6669
+ const named = members.filter((m) => m.label !== void 0 && m.label.length > 0);
6670
+ const names = named.slice(0, maxNames).map((m) => m.label ?? "");
6671
+ if (names.length === 0) return ncDetectionSummary(texts, countByClass(members));
6672
+ const spoken = new Set(named.slice(0, maxNames));
6673
+ const remainder = members.filter((m) => !spoken.has(m));
6674
+ if (remainder.length === 0) return texts.join(names);
6675
+ const others = countByClass(remainder).map((c) => ncGroupOthers(texts, c.className, c.count));
6676
+ return texts.join([...names, ...others]);
6677
+ }
6678
+ //#endregion
6024
6679
  //#region src/notification-center/render-template.ts
6025
6680
  /**
6026
6681
  * The ONE `{{var}}` renderer of the notification centre.
@@ -6485,6 +7140,110 @@ function maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, polygon, _frameWi
6485
7140
  if (totalMaskPixels === 0) return 0;
6486
7141
  return insidePolygon / totalMaskPixels;
6487
7142
  }
7143
+ var NcSceneStateCache = class {
7144
+ deps;
7145
+ entries = /* @__PURE__ */ new Map();
7146
+ noted = /* @__PURE__ */ new Map();
7147
+ ids = [];
7148
+ now;
7149
+ constructor(deps) {
7150
+ this.deps = deps;
7151
+ this.now = deps.now ?? (() => Date.now());
7152
+ }
7153
+ /**
7154
+ * The scene ids worth reading — recomputed from the rule set, so a cluster
7155
+ * with no scene-gated rule does no work at all and logs nothing.
7156
+ */
7157
+ setWatched(ids) {
7158
+ this.ids = [...new Set(ids)].sort();
7159
+ }
7160
+ watched() {
7161
+ return this.ids;
7162
+ }
7163
+ /**
7164
+ * "This scene, in this process, is in this state, NOW."
7165
+ *
7166
+ * Synchronous by contract — the caller is the scene provider's own edge tap
7167
+ * and the next rule evaluation must already see it.
7168
+ */
7169
+ note(observation) {
7170
+ this.noted.set(observation.sceneId, {
7171
+ observation,
7172
+ at: this.now()
7173
+ });
7174
+ }
7175
+ /** Hot-path read. No I/O, no await — this is called per event. */
7176
+ get(sceneId) {
7177
+ const now = this.now();
7178
+ const noted = this.noted.get(sceneId);
7179
+ if (noted !== void 0) {
7180
+ if (now - noted.at <= 3e5) return noted.observation;
7181
+ this.noted.delete(sceneId);
7182
+ }
7183
+ const entry = this.entries.get(sceneId);
7184
+ if (entry === void 0) return void 0;
7185
+ if (now - entry.at > 3e5) return void 0;
7186
+ return entry.observation;
7187
+ }
7188
+ /**
7189
+ * Re-read every watched scene. Never throws: a gate that could fail the
7190
+ * evaluation tick would take the notification pipeline with it.
7191
+ *
7192
+ * On failure the previous entries are KEPT — their timestamps are not
7193
+ * touched, so they age out on their own rather than vanishing at once.
7194
+ */
7195
+ refresh() {
7196
+ if (this.ids.length === 0) {
7197
+ this.entries = /* @__PURE__ */ new Map();
7198
+ this.noted.clear();
7199
+ return;
7200
+ }
7201
+ let observations;
7202
+ try {
7203
+ observations = this.deps.readScenes();
7204
+ } catch (err) {
7205
+ this.deps.logger.warn("scene-state refresh failed — keeping the previous states until they age out", { meta: {
7206
+ watched: this.ids.length,
7207
+ error: String(err)
7208
+ } });
7209
+ return;
7210
+ }
7211
+ const at = this.now();
7212
+ const wanted = new Set(this.ids);
7213
+ const next = /* @__PURE__ */ new Map();
7214
+ for (const observation of observations) {
7215
+ if (!wanted.has(observation.sceneId)) continue;
7216
+ next.set(observation.sceneId, {
7217
+ observation,
7218
+ at
7219
+ });
7220
+ }
7221
+ this.entries = next;
7222
+ for (const sceneId of this.ids) {
7223
+ if (next.has(sceneId)) continue;
7224
+ this.deps.logger.info("scene gate: no state for a watched scene", { meta: { sceneId } });
7225
+ }
7226
+ }
7227
+ };
7228
+ /**
7229
+ * Does this observation satisfy the rule's scene condition?
7230
+ *
7231
+ * Exported and pure so the semantics can be tested without a cache, a provider
7232
+ * or a rule — and so there is exactly ONE place that decides what `latched`
7233
+ * absent means. See `NcSceneConditionSchema` for the reasoning behind each
7234
+ * branch.
7235
+ */
7236
+ function sceneConditionSatisfied(condition, observation) {
7237
+ if (condition.latched ?? observation.emit === "latched") return observation.latched === (condition.requiredState === "diverged");
7238
+ return observation.verdict === condition.requiredState;
7239
+ }
7240
+ /** One-line "why", for the suppression log. Never a bare boolean: a drop the
7241
+ * operator cannot explain is the failure this whole feature is one misstep
7242
+ * away from. */
7243
+ function sceneGateReason(condition, observation) {
7244
+ if (observation === void 0) return "no mirrored state for this scene";
7245
+ return condition.latched ?? observation.emit === "latched" ? `latched=${observation.latched} (wanted ${condition.requiredState})` : `verdict=${observation.verdict} (wanted ${condition.requiredState})`;
7246
+ }
6488
7247
  //#endregion
6489
7248
  //#region src/notification-center/zone-ownership.ts
6490
7249
  var UNCHANGED = { kind: "unchanged" };
@@ -6640,10 +7399,17 @@ function subjectFromSensorEvent(ev, markerTrackId) {
6640
7399
  * `minConfidence` condition composes naturally). A level-path audio event (no
6641
7400
  * `classification`) yields NO class ⇒ it can never satisfy the audio opt-in
6642
7401
  * gate, so only classified audio ever notifies (documented boundary). Audio has
6643
- * no zones / bbox / label / track, so the object/track-specific conditions all
6644
- * fail closed (see the {@link evaluateRule} audio gate + the matchers below).
7402
+ * no zones / bbox / label, so the object-specific conditions all fail closed
7403
+ * (see the {@link evaluateRule} audio gate + the matchers below).
7404
+ *
7405
+ * `stillOwnerId` is the audio still shelf's owner id — the photograph of the
7406
+ * camera taken at the match (`audio-still.ts`). It rides `trackId` because that
7407
+ * is the field the attachment ladder reads, exactly as a doorbell press rides
7408
+ * its marker track's id; nothing else about the subject is track-scoped, and
7409
+ * `buildEntries` keys an audio row on its record id, never on this. Absent when
7410
+ * no rule that could fire wants a still.
6645
7411
  */
6646
- function subjectFromAudioEvent(ev) {
7412
+ function subjectFromAudioEvent(ev, stillOwnerId) {
6647
7413
  const macro = ev.classification?.className;
6648
7414
  return {
6649
7415
  kind: "audio-event",
@@ -6653,6 +7419,7 @@ function subjectFromAudioEvent(ev) {
6653
7419
  classNames: macro !== void 0 ? [`audio-${macro}`] : [],
6654
7420
  ...ev.classification?.score !== void 0 ? { confidence: ev.classification.score } : {},
6655
7421
  zones: [],
7422
+ ...stillOwnerId !== void 0 ? { trackId: stillOwnerId } : {},
6656
7423
  source: "pipeline"
6657
7424
  };
6658
7425
  }
@@ -6673,8 +7440,12 @@ function subjectFromAudioEvent(ev) {
6673
7440
  * neither is a score, and lending one to `minConfidence` would let a detection
6674
7441
  * condition silently re-judge an audio rule on a number that means something
6675
7442
  * else.
7443
+ *
7444
+ * `stillOwnerId` — see {@link subjectFromAudioEvent}. A sound owns no frame, so
7445
+ * the only picture it can carry is a photograph of its camera taken at the
7446
+ * confirmation, held on the still shelf under this id.
6676
7447
  */
6677
- function subjectFromAudioWindow(hit) {
7448
+ function subjectFromAudioWindow(hit, stillOwnerId) {
6678
7449
  const spec = hit.spec;
6679
7450
  return {
6680
7451
  kind: "audio-window",
@@ -6683,6 +7454,7 @@ function subjectFromAudioWindow(hit) {
6683
7454
  timestamp: hit.timestamp,
6684
7455
  classNames: hit.labels.map((l) => `audio-${l}`),
6685
7456
  zones: [],
7457
+ ...stillOwnerId !== void 0 ? { trackId: stillOwnerId } : {},
6686
7458
  source: "audio",
6687
7459
  audioWindow: audioSubjectFor(hit, spec)
6688
7460
  };
@@ -6821,6 +7593,7 @@ function presentConditionIds(c) {
6821
7593
  if (c.packagePhase !== void 0 && c.packagePhase !== "both") ids.push("packagePhase");
6822
7594
  if (c.customZones !== void 0 && c.customZones.length > 0) ids.push("customZones");
6823
7595
  if (c.occupancy !== void 0) ids.push("occupancy");
7596
+ if (c.scene !== void 0) ids.push("scene");
6824
7597
  return ids;
6825
7598
  }
6826
7599
  /**
@@ -7000,7 +7773,7 @@ function matchesSystemEvent(condition, subject) {
7000
7773
  if (condition.packageNames !== void 0 && (subject.packageName === void 0 || !condition.packageNames.includes(subject.packageName))) return false;
7001
7774
  return true;
7002
7775
  }
7003
- function evaluateRule(rule, subject, deviceState, zoneOwners) {
7776
+ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
7004
7777
  if (subject.kind === "system-event") {
7005
7778
  if (rule.delivery !== "system-event") return fail("delivery");
7006
7779
  const condition = rule.conditions.systemEvent;
@@ -7041,6 +7814,11 @@ function evaluateRule(rule, subject, deviceState, zoneOwners) {
7041
7814
  if (current === void 0) return fail("deviceState");
7042
7815
  if (!toLowerSet(c.deviceState.states).has(current.trim().toLowerCase())) return fail("deviceState");
7043
7816
  }
7817
+ if (c.scene !== void 0) {
7818
+ const observation = sceneStates?.(c.scene.sceneId);
7819
+ if (observation === void 0) return fail("scene");
7820
+ if (!sceneConditionSatisfied(c.scene, observation)) return fail("scene");
7821
+ }
7044
7822
  if (c.devices !== void 0 && c.devices.length > 0 && !matchesDeviceScope(c.devices, subject)) return fail("devices");
7045
7823
  if (c.source !== void 0 && c.source !== "any") {
7046
7824
  if ((subject.source ?? "pipeline") !== c.source) return fail("source");
@@ -7899,298 +8677,6 @@ var NcTextCatalog = class {
7899
8677
  }
7900
8678
  };
7901
8679
  //#endregion
7902
- //#region src/shared/frame/box-drawer.ts
7903
- var DEFAULT_COLOR = require_dist.DEFAULT_EVENT_COLOR;
7904
- var DEFAULT_QUALITY = 80;
7905
- var STROKE_WIDTH = 3;
7906
- function escapeXml$2(s) {
7907
- return s.replace(/[<>&'"]/g, (ch) => {
7908
- switch (ch) {
7909
- case "<": return "&lt;";
7910
- case ">": return "&gt;";
7911
- case "&": return "&amp;";
7912
- case "'": return "&apos;";
7913
- default: return "&quot;";
7914
- }
7915
- });
7916
- }
7917
- /** Clamp a pixel box to the frame so the rect always stays inside [0,W]×[0,H]. */
7918
- function clampBox(b, frameWidth, frameHeight) {
7919
- const x = Math.max(0, Math.min(Math.round(b.x), frameWidth - 1));
7920
- const y = Math.max(0, Math.min(Math.round(b.y), frameHeight - 1));
7921
- return {
7922
- x,
7923
- y,
7924
- w: Math.max(1, Math.min(Math.round(b.w), frameWidth - x)),
7925
- h: Math.max(1, Math.min(Math.round(b.h), frameHeight - y))
7926
- };
7927
- }
7928
- /**
7929
- * Draw bounding boxes over a raw RGB frame and JPEG-encode it.
7930
- *
7931
- * The frame is composited with an SVG overlay (one `<rect>` per box, plus an
7932
- * optional caption) at full resolution, then optionally downscaled — so the
7933
- * box stays crisp relative to the scene. Boxes are clamped to frame bounds to
7934
- * avoid sharp `extract`/region errors on slightly-out-of-range detections.
7935
- */
7936
- async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts = {}) {
7937
- const quality = opts.quality ?? DEFAULT_QUALITY;
7938
- let base = frameData;
7939
- let baseIsRaw = true;
7940
- if (boxes.length > 0) {
7941
- const fontSize = Math.max(12, Math.round(frameHeight / 30));
7942
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">${boxes.map((b) => {
7943
- const c = clampBox(b, frameWidth, frameHeight);
7944
- const color = b.color ?? DEFAULT_COLOR;
7945
- const rect = `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" fill="none" stroke="${color}" stroke-width="${STROKE_WIDTH}"/>`;
7946
- if (!b.label) return rect;
7947
- const ty = Math.max(fontSize, c.y - 4);
7948
- return rect + `<text x="${c.x}" y="${ty}" font-family="sans-serif" font-size="${fontSize}" fill="${color}" stroke="black" stroke-width="0.5">${escapeXml$2(b.label)}</text>`;
7949
- }).join("")}</svg>`;
7950
- base = await (0, sharp.default)(frameData, { raw: {
7951
- width: frameWidth,
7952
- height: frameHeight,
7953
- channels: 3
7954
- } }).composite([{
7955
- input: Buffer.from(svg),
7956
- top: 0,
7957
- left: 0
7958
- }]).png().toBuffer();
7959
- baseIsRaw = false;
7960
- }
7961
- let pipeline = baseIsRaw ? (0, sharp.default)(base, { raw: {
7962
- width: frameWidth,
7963
- height: frameHeight,
7964
- channels: 3
7965
- } }) : (0, sharp.default)(base);
7966
- if (opts.maxWidth !== void 0) pipeline = pipeline.resize({
7967
- width: opts.maxWidth,
7968
- withoutEnlargement: true
7969
- });
7970
- return pipeline.jpeg({ quality }).toBuffer();
7971
- }
7972
- /** JPEG quality of the composed artefact — the mosaic's number, same reasons. */
7973
- var GROUP_FRAME_QUALITY = 82;
7974
- /**
7975
- * Draw the members onto the frame. Returns `null` when there is nothing to
7976
- * draw, or when anything at all goes wrong — the caller then ships the
7977
- * unannotated frame unchanged.
7978
- */
7979
- async function composeGroupFrame(input) {
7980
- const freshnessMs = input.freshnessMs ?? 3e3;
7981
- const fresh = input.boxes.filter((b) => Math.abs(input.at - b.observedAt) <= freshnessMs);
7982
- const withheld = input.boxes.length - fresh.length;
7983
- if (fresh.length < 2) {
7984
- if (withheld > 0) input.logger.info("group frame not drawn — too few members with fresh geometry", {
7985
- tags: { deviceId: input.deviceId },
7986
- meta: {
7987
- groupKey: input.groupKey,
7988
- members: input.boxes.length,
7989
- fresh: fresh.length,
7990
- withheld
7991
- }
7992
- });
7993
- return null;
7994
- }
7995
- try {
7996
- const decoded = await (0, sharp.default)(input.jpeg).raw().toBuffer({ resolveWithObject: true });
7997
- const { width, height, channels } = decoded.info;
7998
- if (!(width > 0 && height > 0) || channels !== 3) {
7999
- input.logger.warn("group frame not drawn — unexpected raster shape", {
8000
- tags: { deviceId: input.deviceId },
8001
- meta: {
8002
- groupKey: input.groupKey,
8003
- width,
8004
- height,
8005
- channels
8006
- }
8007
- });
8008
- return null;
8009
- }
8010
- const boxes = fresh.map((b) => ({
8011
- x: b.bbox.x * width,
8012
- y: b.bbox.y * height,
8013
- w: b.bbox.w * width,
8014
- h: b.bbox.h * height,
8015
- ...b.label !== void 0 && b.label.length > 0 ? { label: b.label } : {}
8016
- }));
8017
- const bytes = await drawBoxedFrame(decoded.data, width, height, boxes, { quality: GROUP_FRAME_QUALITY });
8018
- input.logger.debug("group frame composed", {
8019
- tags: { deviceId: input.deviceId },
8020
- meta: {
8021
- groupKey: input.groupKey,
8022
- drawn: boxes.length,
8023
- withheld,
8024
- width,
8025
- height
8026
- }
8027
- });
8028
- return {
8029
- bytes,
8030
- drawn: boxes.length,
8031
- withheld
8032
- };
8033
- } catch (err) {
8034
- input.logger.warn("group frame compose failed — shipping the unannotated frame", {
8035
- tags: { deviceId: input.deviceId },
8036
- meta: {
8037
- groupKey: input.groupKey,
8038
- error: err instanceof Error ? err.message : String(err)
8039
- }
8040
- });
8041
- return null;
8042
- }
8043
- }
8044
- //#endregion
8045
- //#region src/notification-center/text-compose.ts
8046
- /** One normalisation, used on both sides of every class lookup — a pipeline
8047
- * that emitted `Person` must find the key written `class.person`. */
8048
- function normaliseClass(className) {
8049
- return className.trim().toLowerCase();
8050
- }
8051
- /**
8052
- * The noun for a class, in the count's plural category.
8053
- *
8054
- * A class no locale names falls through to `class.other`, which prints the RAW
8055
- * name: a custom model's class must appear in the sentence rather than vanish
8056
- * out of it, and a missing translation is a smaller defect than a missing
8057
- * detection.
8058
- */
8059
- function ncClassNoun(texts, className, count) {
8060
- const key = `class.${normaliseClass(className)}`;
8061
- if (texts.has(key)) return texts.text({
8062
- key,
8063
- count,
8064
- vars: { name: className }
8065
- });
8066
- return texts.text({
8067
- key: "class.other",
8068
- count,
8069
- vars: { name: className }
8070
- });
8071
- }
8072
- /** `person (John)` / `persona (John)` — the thing the notification is about. */
8073
- function ncSubjectText(texts, input) {
8074
- const classNoun = ncClassNoun(texts, input.className, 1);
8075
- if (input.label === void 0) return texts.text({
8076
- key: "detection.subject",
8077
- vars: { classNoun }
8078
- });
8079
- return texts.text({
8080
- key: subjectLabelKey(input.labelKind),
8081
- vars: {
8082
- classNoun,
8083
- label: input.label
8084
- }
8085
- });
8086
- }
8087
- /**
8088
- * Which labelled-subject key a recognition gets. A kind the catalog does not
8089
- * name falls back to the generic key rather than printing nothing — the same
8090
- * direction `ncClassNoun` degrades in, and for the same reason.
8091
- */
8092
- function subjectLabelKey(kind) {
8093
- if (kind === "identity") return "detection.subject.identity";
8094
- if (kind === "plate") return "detection.subject.plate";
8095
- return "detection.subject.labelled";
8096
- }
8097
- /**
8098
- * ` in gate, drive` — INCLUDING its leading space, and empty when there are no
8099
- * zones.
8100
- *
8101
- * The space belongs to the fragment rather than to the body key because the
8102
- * alternative is a body that ends in a trailing space on every zone-less
8103
- * notification. A translator moving the clause moves the space with it.
8104
- */
8105
- function ncInZonesText(texts, zoneLabels) {
8106
- if (zoneLabels.length === 0) return "";
8107
- return texts.text({
8108
- key: "detection.inZones",
8109
- vars: { zones: zoneLabels.join(", ") }
8110
- });
8111
- }
8112
- /** The human-readable edge polarity behind `{{op}}` and the occupancy body. */
8113
- function ncOccupancyOp(texts, occupied) {
8114
- return texts.text({ key: occupied ? "occupancy.op.occupied" : "occupancy.op.free" });
8115
- }
8116
- /**
8117
- * `1 persona, 2 veicoli e 3 animali` — the language's own conjunction, its own
8118
- * plural rules, and its own nouns.
8119
- *
8120
- * Zero-count classes are dropped: a selected class the window never contained
8121
- * is a legitimate `0` in `{{count_vehicle}}`, but "0 veicoli" inside a summary
8122
- * sentence is a line nobody reads. Order is the caller's (busiest first) and is
8123
- * preserved, so the same night always produces the same sentence.
8124
- */
8125
- function ncDetectionSummary(texts, counts) {
8126
- const parts = counts.filter((entry) => entry.count > 0).map((entry) => `${entry.count} ${ncClassNoun(texts, entry.className, entry.count)}`);
8127
- return texts.join(parts);
8128
- }
8129
- //#endregion
8130
- //#region src/notification-center/group/nc-group-text.ts
8131
- var DEFAULT_MAX_NAMES = 2;
8132
- /**
8133
- * `N altre persone` — the remainder phrase for one class, in its own gender.
8134
- *
8135
- * Mirrors {@link ncClassNoun} byte for byte, including the fallback: a class no
8136
- * locale names falls through to `group.others.other`, which prints the raw class
8137
- * name rather than vanishing. A custom model's class must appear in the sentence.
8138
- */
8139
- function ncGroupOthers(texts, className, count) {
8140
- const classNoun = ncClassNoun(texts, className, count);
8141
- const key = `group.others.${className.trim().toLowerCase()}`;
8142
- if (texts.has(key)) return texts.text({
8143
- key,
8144
- count,
8145
- vars: {
8146
- count: `${count}`,
8147
- classNoun
8148
- }
8149
- });
8150
- return texts.text({
8151
- key: "group.others.other",
8152
- count,
8153
- vars: {
8154
- count: `${count}`,
8155
- classNoun
8156
- }
8157
- });
8158
- }
8159
- /** Counts per class, in first-seen order so the same burst always reads the same. */
8160
- function countByClass(members) {
8161
- const counts = /* @__PURE__ */ new Map();
8162
- for (const m of members) counts.set(m.className, (counts.get(m.className) ?? 0) + 1);
8163
- return [...counts].map(([className, count]) => ({
8164
- className,
8165
- count
8166
- }));
8167
- }
8168
- /**
8169
- * The finished `{{subject}}` phrase for a group.
8170
- *
8171
- * Pure: a catalog in, a string out. No clock, no store, no I/O — like every
8172
- * other composer in `text-compose.ts`, and for the same reason.
8173
- */
8174
- function ncGroupSubjectText(texts, input) {
8175
- const members = input.members;
8176
- const only = members.length === 1 ? members[0] : void 0;
8177
- if (only !== void 0) return ncSubjectText(texts, {
8178
- className: only.className,
8179
- ...only.label !== void 0 ? { label: only.label } : {},
8180
- ...only.labelKind !== void 0 ? { labelKind: only.labelKind } : {}
8181
- });
8182
- if (members.length === 0) return "";
8183
- const maxNames = input.maxNames !== void 0 && input.maxNames >= 0 ? input.maxNames : DEFAULT_MAX_NAMES;
8184
- const named = members.filter((m) => m.label !== void 0 && m.label.length > 0);
8185
- const names = named.slice(0, maxNames).map((m) => m.label ?? "");
8186
- if (names.length === 0) return ncDetectionSummary(texts, countByClass(members));
8187
- const spoken = new Set(named.slice(0, maxNames));
8188
- const remainder = members.filter((m) => !spoken.has(m));
8189
- if (remainder.length === 0) return texts.join(names);
8190
- const others = countByClass(remainder).map((c) => ncGroupOthers(texts, c.className, c.count));
8191
- return texts.join([...names, ...others]);
8192
- }
8193
- //#endregion
8194
8680
  //#region src/notification-center/dispatcher.ts
8195
8681
  var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
8196
8682
  /**
@@ -8753,7 +9239,7 @@ var NcDispatcher = class {
8753
9239
  const stillOverride = zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0;
8754
9240
  const firstLook = await this.resolveAttachment(entry, stillOverride);
8755
9241
  const footage = await this.renderFootage(entry);
8756
- const still = firstLook !== null || entry.payload.media === "none" ? firstLook : footage.attempted ? await this.resolveAttachment(entry, stillOverride) : await this.waitForStill(entry, stillOverride);
9242
+ const still = firstLook !== null || entry.payload.media === "none" ? firstLook : footage.attempted ? await this.resolveAttachment(entry, stillOverride) ?? await this.waitForStill(entry, stillOverride) : await this.waitForStill(entry, stillOverride);
8757
9243
  if (still !== null) {
8758
9244
  const zoneIds = zoneIdsWanted;
8759
9245
  if (zoneIds !== void 0 && zoneIds.length > 0) {
@@ -9241,17 +9727,24 @@ function incomingFromSensorEvent(event, markerTrackId) {
9241
9727
  })
9242
9728
  };
9243
9729
  }
9244
- /** Audio-event persist (classified episode). */
9245
- function incomingFromAudioEvent(event) {
9730
+ /**
9731
+ * Audio-event persist (classified episode).
9732
+ *
9733
+ * `stillOwnerId` is the audio still shelf's owner id for the photograph taken
9734
+ * at this episode (`audio-still.ts`). Absent when no rule that could fire asked
9735
+ * for a picture — the same shape `incomingFromSensorEvent` carries its marker.
9736
+ */
9737
+ function incomingFromAudioEvent(event, stillOwnerId) {
9246
9738
  return {
9247
- subject: subjectFromAudioEvent(event),
9739
+ subject: subjectFromAudioEvent(event, stillOwnerId),
9248
9740
  kind: "audio-event",
9249
9741
  origin: "pipeline",
9250
9742
  log: () => ({
9251
9743
  tags: { deviceId: event.deviceId },
9252
9744
  meta: {
9253
9745
  audioEventId: event.id,
9254
- class: event.classification?.className
9746
+ class: event.classification?.className,
9747
+ ...stillOwnerId !== void 0 ? { stillOwner: stillOwnerId } : {}
9255
9748
  }
9256
9749
  })
9257
9750
  };
@@ -9281,15 +9774,20 @@ function incomingFromPackageEvent(event, phase) {
9281
9774
  * and a crash in the confirm→outbox window drops the notification rather than
9282
9775
  * replaying it. Deliberate: a stale match is a claim about a noise that has
9283
9776
  * already stopped.
9777
+ *
9778
+ * `stillOwnerId` — see {@link incomingFromAudioEvent}.
9284
9779
  */
9285
- function incomingFromAudioWindow(hit) {
9780
+ function incomingFromAudioWindow(hit, stillOwnerId) {
9286
9781
  return {
9287
- subject: subjectFromAudioWindow(hit),
9782
+ subject: subjectFromAudioWindow(hit, stillOwnerId),
9288
9783
  kind: "audio-window",
9289
9784
  origin: "pipeline",
9290
9785
  log: () => ({
9291
9786
  tags: { deviceId: hit.deviceId },
9292
- meta: audioHitMeta(hit)
9787
+ meta: {
9788
+ ...audioHitMeta(hit),
9789
+ ...stillOwnerId !== void 0 ? { stillOwner: stillOwnerId } : {}
9790
+ }
9293
9791
  })
9294
9792
  };
9295
9793
  }
@@ -13230,6 +13728,29 @@ function escapeXml$1(s) {
13230
13728
  }
13231
13729
  });
13232
13730
  }
13731
+ /**
13732
+ * The single decision about drawn text — and the home of the DEFAULT.
13733
+ *
13734
+ * `undefined` means `none`, not `all`: a rule stored before this option existed
13735
+ * has no `captions` key, and the operator's request is that such a rule deliver
13736
+ * pictures. The schema materialises the same default on read
13737
+ * (`NcSummaryRuleInputSchema`), so this arm only fires for a caller that never
13738
+ * had a rule — the test bridge and the spec. Both agreeing is the point.
13739
+ */
13740
+ function mosaicTextPlan(captions) {
13741
+ if (captions === "all") return {
13742
+ tileLabels: true,
13743
+ titleBand: true
13744
+ };
13745
+ if (captions === "tiles") return {
13746
+ tileLabels: true,
13747
+ titleBand: false
13748
+ };
13749
+ return {
13750
+ tileLabels: false,
13751
+ titleBand: false
13752
+ };
13753
+ }
13233
13754
  /** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
13234
13755
  * by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip. */
13235
13756
  function mosaicGrid(n) {
@@ -13336,22 +13857,25 @@ function titleSvg(width, height, caption) {
13336
13857
  * treats that as "no mosaic" and still delivers the counts.
13337
13858
  */
13338
13859
  async function renderMosaic(request, logger) {
13860
+ const plan = mosaicTextPlan(request.captions);
13339
13861
  const decoded = [];
13340
13862
  const probeLayout = mosaicLayout({
13341
13863
  tileCount: Math.max(1, request.tiles.length),
13342
13864
  ...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {}
13343
13865
  });
13344
13866
  for (const tile of request.tiles) try {
13345
- const resized = await (0, sharp.default)(tile.jpeg).resize({
13867
+ const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
13868
+ const fitted = (0, sharp.default)(tile.jpeg).resize({
13346
13869
  width: probeLayout.tileWidth,
13347
13870
  height: probeLayout.tileHeight,
13348
13871
  fit: "contain",
13349
13872
  background: MOSAIC_BACKGROUND
13350
- }).composite([{
13351
- input: tileLabelSvg(probeLayout.tileWidth, probeLayout.tileHeight, tile.label),
13873
+ });
13874
+ const resized = await (label.length === 0 ? fitted : fitted.composite([{
13875
+ input: tileLabelSvg(probeLayout.tileWidth, probeLayout.tileHeight, label),
13352
13876
  top: 0,
13353
13877
  left: 0
13354
- }]).jpeg({ quality: 82 }).toBuffer();
13878
+ }])).jpeg({ quality: 82 }).toBuffer();
13355
13879
  decoded.push({
13356
13880
  tile,
13357
13881
  resized
@@ -13366,7 +13890,7 @@ async function renderMosaic(request, logger) {
13366
13890
  });
13367
13891
  }
13368
13892
  const droppedTiles = request.tiles.length - decoded.length;
13369
- const caption = request.caption?.trim() ?? "";
13893
+ const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
13370
13894
  const layout = mosaicLayout({
13371
13895
  tileCount: Math.max(1, decoded.length),
13372
13896
  ...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
@@ -14235,18 +14759,20 @@ var NcSummaryProducer = class {
14235
14759
  };
14236
14760
  let rendered;
14237
14761
  try {
14762
+ const plan = mosaicTextPlan(rule.captions);
14238
14763
  rendered = await renderMosaic({
14239
14764
  tiles: tiles.map(({ candidate, jpeg }) => ({
14240
14765
  jpeg,
14241
- label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$2(candidate.firstSeen)} · ${candidate.className}`,
14766
+ ...plan.tileLabels ? { label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$2(candidate.firstSeen)} · ${candidate.className}` } : {},
14242
14767
  deviceId: candidate.deviceId,
14243
14768
  trackId: candidate.trackId
14244
14769
  })),
14245
- caption: summaryCaptionText({
14770
+ captions: rule.captions,
14771
+ ...plan.titleBand ? { caption: summaryCaptionText({
14246
14772
  ...rule.captionText !== void 0 ? { captionText: rule.captionText } : {},
14247
14773
  ruleName: rule.name,
14248
14774
  window
14249
- })
14775
+ }) } : {}
14250
14776
  }, this.deps.logger);
14251
14777
  } catch (err) {
14252
14778
  this.deps.logger.warn("summary mosaic could not be composed — delivering the counts alone", { meta: {
@@ -14510,6 +15036,34 @@ var NcSummaryRankSchema = require_dist._enum([
14510
15036
  "dwell"
14511
15037
  ]);
14512
15038
  /**
15039
+ * How much TEXT is burnt into the mosaic — and the answer is NONE by default.
15040
+ *
15041
+ * `none` is the shipped default and, crucially, the reading of an ABSENT field:
15042
+ * `.default()` on the Input schema materialises it, and the persisted schema
15043
+ * extends Input, so a rule authored before this option existed parses as `none`
15044
+ * on the next read. No migration, no backfill, no rewrite of a stored rule.
15045
+ *
15046
+ * The operator asked for "solo immagini". Three reasons it is the right
15047
+ * default rather than a preference:
15048
+ *
15049
+ * - a caption is PERMANENT in the artefact, exactly like a detection box, and
15050
+ * a mosaic is re-read days later;
15051
+ * - the per-tile strip covers 14% of the tile from the bottom edge up, which
15052
+ * is where feet, wheels and parcels are;
15053
+ * - nothing downstream needs it. The AI pass is given the RAW per-track JPEGs
15054
+ * and gets camera, class and time as PROMPT TEXT — it never reads the
15055
+ * mosaic, so the pixels carry no fact the judge would lose.
15056
+ *
15057
+ * `tiles` and `all` keep the old behaviour reachable for an operator who wants
15058
+ * it; `all` is byte-for-byte what every rule produced before this field.
15059
+ */
15060
+ var NcSummaryCaptionsSchema = require_dist._enum([
15061
+ "none",
15062
+ "tiles",
15063
+ "all"
15064
+ ]);
15065
+ var NC_SUMMARY_DEFAULT_CAPTIONS = "none";
15066
+ /**
14513
15067
  * The AI section — DECLARED IN P1, WIRED IN P2.
14514
15068
  *
14515
15069
  * Nothing in this addon reads it today. It is here so the rule shape does not
@@ -14543,6 +15097,8 @@ var NcSummaryRuleInputSchema = require_dist.object({
14543
15097
  barrierSeconds: BarrierSecondsField.default(120),
14544
15098
  targets: TargetsField,
14545
15099
  template: NcSummaryTemplateSchema.optional(),
15100
+ /** Burned onto the mosaic's title band — and ONLY when `captions` is `all`. */
15101
+ captions: NcSummaryCaptionsSchema.default(NC_SUMMARY_DEFAULT_CAPTIONS),
14546
15102
  /** Burned onto the mosaic's title band. `''` is reachable and means "no
14547
15103
  * caption" — which is why this is not `.min(1)`. */
14548
15104
  captionText: require_dist.string().max(200).optional(),
@@ -14572,6 +15128,7 @@ var NcSummaryRulePatchSchema = require_dist.object({
14572
15128
  barrierSeconds: BarrierSecondsField.optional(),
14573
15129
  targets: TargetsField.optional(),
14574
15130
  template: NcSummaryTemplateSchema.nullable().optional(),
15131
+ captions: NcSummaryCaptionsSchema.optional(),
14575
15132
  captionText: require_dist.string().max(200).optional(),
14576
15133
  deliverEmpty: require_dist.boolean().optional(),
14577
15134
  priority: PriorityField.optional(),
@@ -15106,14 +15663,19 @@ function isSyntheticId(id) {
15106
15663
  * snapshot that produced it lists the members being counted and the edge
15107
15664
  * names one (`chooseOccupancyMediaOwner`), so the tester stands in for that
15108
15665
  * member exactly as it stands in for the marker.
15666
+ * - and, since 2026-08-15, a `trackId` for both AUDIO kinds: a sound owns no
15667
+ * frame, so production photographs the camera at the match and holds the
15668
+ * bytes on the still shelf under an owner id the subject names
15669
+ * (`audio-still.ts`). The tester's RAM-held frame stands in for exactly that
15670
+ * photograph, and — unlike every other kind here — it is taken the same way
15671
+ * production takes it, at the same moment, from the same cap.
15109
15672
  *
15110
- * An audio episode still freezes neither. It HAS a marker projection, but its
15111
- * id does not reach the notification subject yet, so it remains honestly
15112
- * unattachable — a tester that invented a picture for it would be advertising a
15113
- * feature that does not exist.
15673
+ * Only `system-event` remains: an infrastructure event is about no camera at
15674
+ * all, so there is nothing to photograph and a tester that invented a picture
15675
+ * for it would be advertising a feature that does not exist.
15114
15676
  */
15115
15677
  function triggerCanCarryStill(kind) {
15116
- return kind === "object-event" || kind === "package-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event";
15678
+ return kind !== "system-event";
15117
15679
  }
15118
15680
  /** The detection provenance production stamps on a subject of this kind. */
15119
15681
  function syntheticSource(kind) {
@@ -15151,7 +15713,7 @@ function audioSubjectOf(input) {
15151
15713
  function buildSyntheticEvent(input, recordId, now) {
15152
15714
  const kind = input.trigger;
15153
15715
  const timestamp = input.timestamp ?? now;
15154
- const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event";
15716
+ const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event" || kind === "audio-event" || kind === "audio-window";
15155
15717
  return {
15156
15718
  subject: {
15157
15719
  kind,
@@ -17644,6 +18206,22 @@ var NotificationCenter = class NotificationCenter {
17644
18206
  /** States the `deviceState` rule gate reads. Refreshed on the reload tick;
17645
18207
  * read with no I/O on the persist hook. */
17646
18208
  deviceStates;
18209
+ /** Scene verdicts + latches the `scene` rule gate reads. Same contract as
18210
+ * {@link deviceStates}: refreshed on the reload tick and on the provider's
18211
+ * in-process edge tap, read with no I/O on the persist hook (D49). */
18212
+ sceneStates;
18213
+ /**
18214
+ * The last scene-gate answer REPORTED per rule, so the suppression line is
18215
+ * written on CHANGE rather than on every event.
18216
+ *
18217
+ * A branch that drops work silently must log it, and a gate that logged on
18218
+ * every detection on a busy camera would be a line nobody reads — which is
18219
+ * the same silence by a different route.
18220
+ */
18221
+ sceneGateReported = /* @__PURE__ */ new Map();
18222
+ /** The watched scene id set, as last REPORTED — so the line is written when it
18223
+ * changes and not on every reload tick. `null` = never reported. */
18224
+ sceneWatchSignature = null;
17647
18225
  /** WHO each device id is (name + type). The system-event intake reads it with
17648
18226
  * no I/O; the reload tick refreshes it — see `device-directory.ts`. */
17649
18227
  deviceDirectory;
@@ -17715,6 +18293,12 @@ var NotificationCenter = class NotificationCenter {
17715
18293
  * is the one thing the synthetic producer promises not to do.
17716
18294
  */
17717
18295
  syntheticMedia = /* @__PURE__ */ new Map();
18296
+ /**
18297
+ * The photograph a SOUND rule's notification carries — see `audio-still.ts`.
18298
+ * Null when this wiring has no snapshot cap, which leaves every audio
18299
+ * notification text-only rather than broken.
18300
+ */
18301
+ audioStills;
17718
18302
  /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
17719
18303
  lastNoMatchReportAt = /* @__PURE__ */ new Map();
17720
18304
  noMatchSuppressed = /* @__PURE__ */ new Map();
@@ -18006,6 +18590,12 @@ var NotificationCenter = class NotificationCenter {
18006
18590
  this.deps = deps;
18007
18591
  this.logger = deps.logger;
18008
18592
  this.now = deps.now ?? (() => Date.now());
18593
+ const getSnapshot = deps.getSnapshot;
18594
+ this.audioStills = getSnapshot !== void 0 ? new NcAudioStillShelf({
18595
+ getSnapshot,
18596
+ logger: this.logger.child("audio-still"),
18597
+ now: this.now
18598
+ }) : null;
18009
18599
  this.rules = new NcRuleStore({
18010
18600
  store: deps.store,
18011
18601
  logger: this.logger.child("rules"),
@@ -18043,6 +18633,11 @@ var NotificationCenter = class NotificationCenter {
18043
18633
  readStates: deps.readDeviceStates ?? (async () => /* @__PURE__ */ new Map()),
18044
18634
  ...deps.now !== void 0 ? { now: deps.now } : {}
18045
18635
  });
18636
+ this.sceneStates = new NcSceneStateCache({
18637
+ logger: this.logger.child("scene-state"),
18638
+ readScenes: deps.readSceneStates ?? (() => []),
18639
+ ...deps.now !== void 0 ? { now: deps.now } : {}
18640
+ });
18046
18641
  this.deviceDirectory = new NcDeviceDirectory({
18047
18642
  logger: this.logger.child("device-directory"),
18048
18643
  listDevices: deps.listDeviceIdentities ?? (async () => {
@@ -18078,6 +18673,8 @@ var NotificationCenter = class NotificationCenter {
18078
18673
  getMediaForOwner: async (ownerKind, ownerId) => {
18079
18674
  const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
18080
18675
  if (held !== void 0) return held.files;
18676
+ const audio = isAudioStillId(ownerId) ? this.audioStills?.get(ownerId) : void 0;
18677
+ if (audio !== void 0) return audio;
18081
18678
  return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
18082
18679
  },
18083
18680
  ...deps.now !== void 0 ? { now: deps.now } : {},
@@ -18242,6 +18839,7 @@ var NotificationCenter = class NotificationCenter {
18242
18839
  await this.deviceDirectory.refresh();
18243
18840
  await this.hydrateLiveness();
18244
18841
  await this.zoneOwners.refresh();
18842
+ this.sceneStates.refresh();
18245
18843
  this.evaluationActive = true;
18246
18844
  await this.outbox.load();
18247
18845
  await this.seedCooldowns();
@@ -18281,6 +18879,7 @@ var NotificationCenter = class NotificationCenter {
18281
18879
  }
18282
18880
  this.timelapseScheduler?.stop();
18283
18881
  this.summaryProducer?.stop();
18882
+ this.audioStills?.clear();
18284
18883
  this.evaluationActive = false;
18285
18884
  }
18286
18885
  /**
@@ -18610,7 +19209,35 @@ var NotificationCenter = class NotificationCenter {
18610
19209
  * durable guarantee begins at this hook, matching the device-event boundary.
18611
19210
  */
18612
19211
  onAudioEventPersisted(event) {
18613
- this.consumeEvent(incomingFromAudioEvent(event));
19212
+ this.consumeEvent(incomingFromAudioEvent(event, this.audioStillOwner(event.deviceId, event.timestamp, "legacy")));
19213
+ }
19214
+ /**
19215
+ * Start photographing `deviceId` NOW and return the owner id the audio
19216
+ * subject should name — or `undefined` when nothing would use it.
19217
+ *
19218
+ * **The capture is not awaited.** A sound is transient and a notification
19219
+ * about a scream must not wait on a camera; the bytes land while the outbox
19220
+ * row queues, and the dispatcher's ordinary bounded still-wait picks them up.
19221
+ * That is also why this runs BEFORE the cooldown and the mute are consulted:
19222
+ * by the time a rule's window has been judged the moment is gone.
19223
+ *
19224
+ * **The gate is the RULE's media policy, not the trigger.** Photographing a
19225
+ * camera on every confirmed window for rules that ship text anyway would be a
19226
+ * snapshot at whatever rate the sound happens to occur, for nothing. The rule
19227
+ * cache is an in-memory mirror, so this is not a fallible read (D49) — a
19228
+ * failed one would look exactly like "no rule wants a picture", which is the
19229
+ * direction that DESTROYS the media.
19230
+ */
19231
+ audioStillOwner(deviceId, atMs, mode) {
19232
+ const shelf = this.audioStills;
19233
+ if (shelf === null || !this.evaluationActive) return void 0;
19234
+ for (const rule of this.rules.listEnabled("immediate")) {
19235
+ if (rule.media.attach === "none") continue;
19236
+ if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
19237
+ const devices = rule.conditions.devices;
19238
+ if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
19239
+ return shelf.capture(deviceId, atMs);
19240
+ }
18614
19241
  }
18615
19242
  /**
18616
19243
  * Called at the package object-event persist site (`PackageDropDetector` —
@@ -18708,7 +19335,7 @@ var NotificationCenter = class NotificationCenter {
18708
19335
  tags: { deviceId },
18709
19336
  meta: audioHitMeta(hit)
18710
19337
  });
18711
- this.consumeEvent(incomingFromAudioWindow(hit));
19338
+ this.consumeEvent(incomingFromAudioWindow(hit, this.audioStillOwner(deviceId, hit.timestamp, "condition")));
18712
19339
  }
18713
19340
  }
18714
19341
  /**
@@ -19006,6 +19633,73 @@ var NotificationCenter = class NotificationCenter {
19006
19633
  if (identity.isCamera && identity.detectionActive === false) return "detection-off";
19007
19634
  }
19008
19635
  /**
19636
+ * SAY that the scene gate suppressed this rule.
19637
+ *
19638
+ * A branch that drops work silently logs it — the three-hour media blackout
19639
+ * produced not one line, and a gate is the same shape of failure: from the
19640
+ * operator's side "the rule stopped notifying" and "the pipeline stopped
19641
+ * working" look identical. `rule did not match` covers it at DEBUG, which is
19642
+ * not a level this installation runs at.
19643
+ *
19644
+ * On CHANGE only, per rule: the alternative is one line per detection on a
19645
+ * busy camera, which is silence by a different route. The `deviceId` tag is
19646
+ * the SUBJECT's — that is the camera whose notification went missing, and the
19647
+ * question is always asked per-camera. The SCENE's own device is named in the
19648
+ * meta beside its id, because the two are frequently different cameras.
19649
+ */
19650
+ reportSceneSuppression(rule, subject) {
19651
+ const condition = rule.conditions.scene;
19652
+ if (condition === void 0) return;
19653
+ const observation = this.sceneStates.get(condition.sceneId);
19654
+ const reason = sceneGateReason(condition, observation);
19655
+ if (this.sceneGateReported.get(rule.id) === reason) return;
19656
+ this.sceneGateReported.set(rule.id, reason);
19657
+ this.logger.info("rule suppressed by its scene condition", {
19658
+ tags: { deviceId: subject.deviceId },
19659
+ meta: {
19660
+ ruleId: rule.id,
19661
+ rule: rule.name,
19662
+ sceneId: condition.sceneId,
19663
+ sceneLabel: observation?.label,
19664
+ sceneDeviceId: observation?.deviceId ?? condition.deviceId,
19665
+ requiredState: condition.requiredState,
19666
+ reason
19667
+ }
19668
+ });
19669
+ }
19670
+ /**
19671
+ * A confirmed scene transition, straight from the provider in this process.
19672
+ *
19673
+ * Synchronous on purpose and for the same reason {@link onAlarmPublish} is:
19674
+ * the reload tick would otherwise be the only writer, and a rule gated on
19675
+ * "while the bin is out" would keep its old answer for up to thirty seconds
19676
+ * after the bin came back. The tick stays as reconciliation.
19677
+ *
19678
+ * A DIRECT CALL, not a bus subscription. `EnrichmentSceneStateChanged` is
19679
+ * still emitted for the badge and for anyone else, but D8 events are
19680
+ * droppable telemetry and a gate fed by droppable telemetry is a gate that is
19681
+ * sometimes wrong and never says so.
19682
+ *
19683
+ * This never OPENS a gate that the mirror had shut for lack of an entry: it
19684
+ * writes an observation the caller actually made. It is `refresh` that
19685
+ * removes entries, and it never empties on a failed read (D49).
19686
+ */
19687
+ onSceneEdge(observation) {
19688
+ this.sceneStates.note(observation);
19689
+ }
19690
+ /**
19691
+ * Reconcile the scene mirror now.
19692
+ *
19693
+ * The scene provider is constructed AFTER this centre starts (it needs the
19694
+ * media store and the embedding encoder), so the boot refresh reads an empty
19695
+ * provider. The addon calls this once the provider has hydrated, rather than
19696
+ * leaving every scene-gated rule shut for a whole reload tick after every
19697
+ * respawn.
19698
+ */
19699
+ refreshSceneStates() {
19700
+ this.sceneStates.refresh();
19701
+ }
19702
+ /**
19009
19703
  * Everything the panel reports, on every publish.
19010
19704
  *
19011
19705
  * Synchronous on purpose. The state half refreshes the `deviceState` gate's
@@ -19304,8 +19998,9 @@ var NotificationCenter = class NotificationCenter {
19304
19998
  let noOwnerReported = false;
19305
19999
  const rejections = [];
19306
20000
  for (const rule of candidates) {
19307
- const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id), (id) => this.zoneOwners.get(id));
20001
+ const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id), (id) => this.zoneOwners.get(id), (sceneId) => this.sceneStates.get(sceneId));
19308
20002
  if (!evaluation.matched) {
20003
+ if (evaluation.failedCondition === "scene") this.reportSceneSuppression(rule, subject);
19309
20004
  rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
19310
20005
  outcomes.push({
19311
20006
  ruleId: rule.id,
@@ -19330,6 +20025,7 @@ var NotificationCenter = class NotificationCenter {
19330
20025
  });
19331
20026
  continue;
19332
20027
  }
20028
+ this.sceneGateReported.delete(rule.id);
19333
20029
  this.runRuleActions(rule, subject, kind, controls);
19334
20030
  const admission = this.admitToGroup(rule, subject, now);
19335
20031
  if (admission.outcome === "unchanged") {
@@ -19456,6 +20152,19 @@ var NotificationCenter = class NotificationCenter {
19456
20152
  }
19457
20153
  });
19458
20154
  }
20155
+ if (!noOwnerReported && (kind === "audio-window" || kind === "audio-event") && subject.trackId === void 0 && rule.media.attach !== "none") {
20156
+ noOwnerReported = true;
20157
+ this.logger.warn("audio match has no still owner — this notification ships text-only", {
20158
+ tags: { deviceId: subject.deviceId },
20159
+ meta: {
20160
+ recordId: subject.recordId,
20161
+ kind,
20162
+ ruleId: rule.id,
20163
+ rule: rule.name,
20164
+ snapshotWired: this.audioStills !== null
20165
+ }
20166
+ });
20167
+ }
19459
20168
  const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
19460
20169
  const entries = this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets, origin, {
19461
20170
  key,
@@ -19612,6 +20321,7 @@ var NotificationCenter = class NotificationCenter {
19612
20321
  admitToGroup(rule, subject, now) {
19613
20322
  const idleSec = rule.groupIdleSec;
19614
20323
  if (idleSec === void 0 || idleSec <= 0) return { outcome: "disabled" };
20324
+ if (subject.kind === "audio-window" || subject.kind === "audio-event") return { outcome: "disabled" };
19615
20325
  const trackId = subject.trackId;
19616
20326
  if (trackId === void 0) return { outcome: "disabled" };
19617
20327
  const member = {
@@ -19961,10 +20671,13 @@ var NotificationCenter = class NotificationCenter {
19961
20671
  this.reportAudioWatch(audioWatch);
19962
20672
  const gated = [];
19963
20673
  const zoneScoped = [];
20674
+ const sceneIds = [];
19964
20675
  for (const rule of this.rules.list()) {
19965
20676
  if (!rule.enabled) continue;
19966
20677
  const ds = rule.conditions.deviceState;
19967
20678
  if (ds !== void 0) gated.push(ds.deviceId);
20679
+ const scene = rule.conditions.scene;
20680
+ if (scene !== void 0) sceneIds.push(scene.sceneId);
19968
20681
  const zones = rule.conditions.zones;
19969
20682
  const devices = rule.conditions.devices;
19970
20683
  if (zones !== void 0 && zones.ids.length > 0 && devices !== void 0) zoneScoped.push(...devices);
@@ -19973,6 +20686,15 @@ var NotificationCenter = class NotificationCenter {
19973
20686
  if (panel !== null) gated.push(...alarmContactWatchList(this.rules.list(), panel.deviceId, panel.availableModes()));
19974
20687
  this.deviceStates.setWatched(gated);
19975
20688
  this.zoneOwners.setWatched(zoneScoped);
20689
+ this.sceneStates.setWatched(sceneIds);
20690
+ const signature = [...new Set(sceneIds)].sort().join(",");
20691
+ if (signature !== this.sceneWatchSignature) {
20692
+ this.sceneWatchSignature = signature;
20693
+ this.logger.info("scene gate watched set", { meta: {
20694
+ scenes: this.sceneStates.watched(),
20695
+ gatedRules: sceneIds.length
20696
+ } });
20697
+ }
19976
20698
  }
19977
20699
  /**
19978
20700
  * Feed every excluded sensor's mirrored state back to the panel.
@@ -20109,6 +20831,7 @@ var NotificationCenter = class NotificationCenter {
20109
20831
  await this.deviceStates.refresh();
20110
20832
  await this.deviceDirectory.refresh();
20111
20833
  await this.zoneOwners.refresh();
20834
+ this.sceneStates.refresh();
20112
20835
  this.observeAlarmContacts();
20113
20836
  await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
20114
20837
  }
@@ -20406,12 +21129,17 @@ var NotificationCenter = class NotificationCenter {
20406
21129
  const fresh = await this.deps.readDeviceZoneIds(zoneScope);
20407
21130
  zoneLookup = (id) => fresh.get(id) ?? this.zoneOwners.get(id);
20408
21131
  } catch {}
21132
+ let sceneLookup = (id) => this.sceneStates.get(id);
21133
+ if (rule.conditions.scene !== void 0 && this.deps.readSceneStates !== void 0) try {
21134
+ const fresh = new Map(this.deps.readSceneStates().map((o) => [o.sceneId, o]));
21135
+ sceneLookup = (id) => fresh.get(id) ?? this.sceneStates.get(id);
21136
+ } catch {}
20409
21137
  const occupancy = rule.conditions.occupancy;
20410
- if (rule.delivery === "device-event" && occupancy !== void 0) return this.occupancyDryRun(rule, occupancy, lookup, zoneLookup);
21138
+ if (rule.delivery === "device-event" && occupancy !== void 0) return this.occupancyDryRun(rule, occupancy, lookup, zoneLookup, sceneLookup);
20411
21139
  const results = [];
20412
21140
  for (const subject of subjects.sort((a, b) => b.timestamp - a.timestamp)) {
20413
21141
  if (results.length >= TEST_RULE_MAX_RESULTS) break;
20414
- const evaluation = evaluateRule(rule, subject, lookup, zoneLookup);
21142
+ const evaluation = evaluateRule(rule, subject, lookup, zoneLookup, sceneLookup);
20415
21143
  results.push({
20416
21144
  recordId: subject.recordId,
20417
21145
  recordKind: subject.kind === "track-end" ? "track" : subject.kind === "audio-event" || subject.kind === "audio-window" ? "object-event" : subject.kind === "occupancy-event" ? "device-event" : subject.kind,
@@ -20435,7 +21163,7 @@ var NotificationCenter = class NotificationCenter {
20435
21163
  * from the ordinary `evaluateRule` on the ordinary subject — this method
20436
21164
  * chooses the cameras and writes the sentence, nothing else.
20437
21165
  */
20438
- async occupancyDryRun(rule, occupancy, lookup, zoneLookup) {
21166
+ async occupancyDryRun(rule, occupancy, lookup, zoneLookup, sceneLookup) {
20439
21167
  const read = this.deps.readOccupancySnapshots;
20440
21168
  if (read === void 0) return [];
20441
21169
  const now = this.now();
@@ -20478,7 +21206,7 @@ var NotificationCenter = class NotificationCenter {
20478
21206
  });
20479
21207
  continue;
20480
21208
  }
20481
- const evaluation = evaluateRule(rule, probe.subject, lookup, zoneLookup);
21209
+ const evaluation = evaluateRule(rule, probe.subject, lookup, zoneLookup, sceneLookup);
20482
21210
  results.push({
20483
21211
  ...common,
20484
21212
  recordId: probe.subject.recordId,
@@ -20733,6 +21461,10 @@ function planRuleTest(input) {
20733
21461
  conditionId: "deviceState",
20734
21462
  note: `This rule only fires while device #${c.deviceState.deviceId} is in one of its chosen states. That gate reads the REAL device and cannot be simulated — put the device in that state first, or the test fails closed exactly as production would.`
20735
21463
  });
21464
+ if (c.scene !== void 0) notes.push({
21465
+ conditionId: "scene",
21466
+ note: `This rule only fires while scene ${c.scene.sceneId} is ${c.scene.requiredState}. That gate reads the scene's REAL state and cannot be simulated — the test answers with whatever the scene is right now, and fails closed while it cannot judge or has not been read yet, exactly as production would.`
21467
+ });
20736
21468
  if (c.customZones !== void 0 && c.customZones.length > 0) notes.push({
20737
21469
  conditionId: "customZones",
20738
21470
  note: "The subject is placed over the first drawn polygon so the condition passes. A rule that needs a specific polygon should be checked in the simulator, where the box can be moved."
@@ -25978,6 +26710,72 @@ function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG)
25978
26710
  }
25979
26711
  //#endregion
25980
26712
  //#region src/pipeline-analytics/pipeline/delete-track-cascade.ts
26713
+ var EMPTY_CASCADE_TALLY = {
26714
+ tracks: 0,
26715
+ events: 0,
26716
+ media: 0,
26717
+ faces: 0,
26718
+ plates: 0,
26719
+ embeddings: 0,
26720
+ bytesReclaimed: 0
26721
+ };
26722
+ /** Fold a leaf store's receipts into a running tally. */
26723
+ function addLeafCounts(tally, counts) {
26724
+ let next = tally;
26725
+ for (const c of counts) {
26726
+ const bytesReclaimed = next.bytesReclaimed + c.bytes;
26727
+ switch (c.family) {
26728
+ case "events":
26729
+ next = {
26730
+ ...next,
26731
+ events: next.events + c.rows,
26732
+ bytesReclaimed
26733
+ };
26734
+ break;
26735
+ case "media":
26736
+ next = {
26737
+ ...next,
26738
+ media: next.media + c.rows,
26739
+ bytesReclaimed
26740
+ };
26741
+ break;
26742
+ case "faces":
26743
+ next = {
26744
+ ...next,
26745
+ faces: next.faces + c.rows,
26746
+ bytesReclaimed
26747
+ };
26748
+ break;
26749
+ case "plates":
26750
+ next = {
26751
+ ...next,
26752
+ plates: next.plates + c.rows,
26753
+ bytesReclaimed
26754
+ };
26755
+ break;
26756
+ case "embeddings":
26757
+ next = {
26758
+ ...next,
26759
+ embeddings: next.embeddings + c.rows,
26760
+ bytesReclaimed
26761
+ };
26762
+ break;
26763
+ }
26764
+ }
26765
+ return next;
26766
+ }
26767
+ /** Sum two tallies (per-track results into a per-device one). */
26768
+ function mergeCascadeTallies(a, b) {
26769
+ return {
26770
+ tracks: a.tracks + b.tracks,
26771
+ events: a.events + b.events,
26772
+ media: a.media + b.media,
26773
+ faces: a.faces + b.faces,
26774
+ plates: a.plates + b.plates,
26775
+ embeddings: a.embeddings + b.embeddings,
26776
+ bytesReclaimed: a.bytesReclaimed + b.bytesReclaimed
26777
+ };
26778
+ }
25981
26779
  /**
25982
26780
  * Extensible whole-track deletion cascade. Runs every registered
25983
26781
  * `TrackScopedStore` (each removes its ephemeral rows + owned media for the
@@ -25994,8 +26792,16 @@ function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG)
25994
26792
  * ]
25995
26793
  */
25996
26794
  async function cascadeDeleteTracks(registry, trackStore, trackIds) {
25997
- for (const store of registry) await store.deleteByTracks(trackIds);
25998
- for (const trackId of trackIds) await trackStore.deletePersisted(trackId);
26795
+ let tally = EMPTY_CASCADE_TALLY;
26796
+ for (const store of registry) tally = addLeafCounts(tally, await store.deleteByTracks(trackIds));
26797
+ for (const trackId of trackIds) {
26798
+ await trackStore.deletePersisted(trackId);
26799
+ tally = {
26800
+ ...tally,
26801
+ tracks: tally.tracks + 1
26802
+ };
26803
+ }
26804
+ return tally;
25999
26805
  }
26000
26806
  /**
26001
26807
  * The one registry, in the one order — leaves first, root deleted internally
@@ -26026,14 +26832,28 @@ function buildAnalyticsCascadeRegistry(stores) {
26026
26832
  * embeddings) is opted in by the CALLER's registry, not by this legacy shim.
26027
26833
  */
26028
26834
  async function cascadeDeleteTrack(stores, trackId) {
26029
- await cascadeDeleteTracks([{ deleteByTracks: async (ids) => {
26835
+ return cascadeDeleteTracks([{ deleteByTracks: async (ids) => {
26836
+ let events = 0;
26837
+ let media = 0;
26030
26838
  for (const id of ids) {
26031
26839
  const eventIds = await stores.eventStore.deleteByTrack(id);
26032
- if (eventIds.length > 0) await stores.mediaStore.deleteForEvents([...eventIds]);
26840
+ events += eventIds.length;
26841
+ if (eventIds.length > 0) media += await stores.mediaStore.deleteForEvents([...eventIds]);
26033
26842
  }
26034
- } }, { deleteByTracks: async (ids) => {
26035
- await stores.mediaStore.deleteForTracks([...ids]);
26036
- } }], stores.trackStore, [trackId]);
26843
+ return [{
26844
+ family: "events",
26845
+ rows: events,
26846
+ bytes: 0
26847
+ }, {
26848
+ family: "media",
26849
+ rows: media,
26850
+ bytes: 0
26851
+ }];
26852
+ } }, { deleteByTracks: async (ids) => [{
26853
+ family: "media",
26854
+ rows: await stores.mediaStore.deleteForTracks([...ids]),
26855
+ bytes: 0
26856
+ }] }], stores.trackStore, [trackId]);
26037
26857
  }
26038
26858
  /**
26039
26859
  * Widened batch whole-track deletion — the ONE engine behind the three §5 entry
@@ -26044,9 +26864,10 @@ async function cascadeDeleteTrack(stores, trackId) {
26044
26864
  */
26045
26865
  async function runTrackCascadeBatch(deps, trackIds) {
26046
26866
  let deleted = 0;
26867
+ let tally = EMPTY_CASCADE_TALLY;
26047
26868
  const failed = [];
26048
26869
  for (const trackId of trackIds) try {
26049
- await cascadeDeleteTracks(deps.registry, deps.trackStore, [trackId]);
26870
+ tally = mergeCascadeTallies(tally, await cascadeDeleteTracks(deps.registry, deps.trackStore, [trackId]));
26050
26871
  deps.onTrackCleanup(deps.deviceId, trackId);
26051
26872
  deleted += 1;
26052
26873
  } catch (err) {
@@ -26055,7 +26876,8 @@ async function runTrackCascadeBatch(deps, trackIds) {
26055
26876
  }
26056
26877
  return {
26057
26878
  deleted,
26058
- failed
26879
+ failed,
26880
+ tally
26059
26881
  };
26060
26882
  }
26061
26883
  //#endregion
@@ -27468,6 +28290,88 @@ var PlateGalleryProvider = class {
27468
28290
  }
27469
28291
  };
27470
28292
  //#endregion
28293
+ //#region src/pipeline-analytics/retention/orphan-audit-actions.ts
28294
+ /**
28295
+ * The operator button for the orphan audit.
28296
+ *
28297
+ * The audit engine, its five bounds and its resumable cursor already existed
28298
+ * and had run in `report` mode for weeks; what it had no way to be was
28299
+ * *asked*. Its only trigger was a 6-hourly timer reading a durable setting, so
28300
+ * draining a standing backlog meant flipping a persistent mode, waiting up to
28301
+ * six hours, and remembering to flip it back — with nothing returned to look
28302
+ * at. On 2026-08-14 the backlog was 74,700 media rows / 18.9 GB plus 23,296
28303
+ * object events and 4,543 CLIP vectors (D161), and that is not a thing to
28304
+ * drain by leaving a switch on.
28305
+ *
28306
+ * Three properties this action does NOT leave to the caller's care:
28307
+ *
28308
+ * 1. **Dry run is the default.** `mode` omitted means `report`: it counts,
28309
+ * logs and returns, and deletes nothing — regardless of what the durable
28310
+ * `orphanAuditMode` setting says. `reclaim` is a word that has to be
28311
+ * typed. The engine's own default is the same for the same reason.
28312
+ * 2. **It is paced.** `pageSize` bounds one read, `maxRowsPerScope` bounds
28313
+ * one pass, `maxReclaimPerScope` bounds the damage, and the engine yields
28314
+ * the thread between pages — `better-sqlite3` is synchronous and shares
28315
+ * hub-main with every live frame.
28316
+ * 3. **It answers.** The report comes back per collection with rows AND
28317
+ * bytes, so "did it do anything" is not a question anyone has to infer
28318
+ * from a log line.
28319
+ */
28320
+ /**
28321
+ * `report` counts; `reclaim` deletes.
28322
+ *
28323
+ * `.default('report')` is load-bearing, not decoration: the settings form
28324
+ * sends `{}` for a button with no fields, and the safe interpretation of an
28325
+ * unstated intent here is the one that destroys nothing.
28326
+ */
28327
+ var OrphanAuditActionModeSchema = require_dist._enum(["report", "reclaim"]).default("report");
28328
+ var OrphanAuditActionInputSchema = require_dist.object({
28329
+ mode: OrphanAuditActionModeSchema,
28330
+ /**
28331
+ * Start every scope from the top instead of resuming its persisted cursor.
28332
+ *
28333
+ * What draining a backlog wants, and never the default — the cursor exists
28334
+ * so a 6-hourly pass makes progress instead of re-walking the same head.
28335
+ */
28336
+ restart: require_dist.boolean().optional(),
28337
+ /** Rows read per statement. Smaller = more yields = a gentler pass. */
28338
+ pageSize: require_dist.number().int().min(50).max(5e3).optional(),
28339
+ /** Rows EXAMINED per collection in this pass — the pacing bound. */
28340
+ maxRowsPerScope: require_dist.number().int().min(1).max(2e5).optional(),
28341
+ /** Rows DELETED per collection in this pass — the blast-radius bound. */
28342
+ maxReclaimPerScope: require_dist.number().int().min(1).max(1e5).optional(),
28343
+ /**
28344
+ * Minutes of immunity for a recent row. Protective ONLY — raising it can
28345
+ * exclusively save rows, never select them, which is what keeps this off
28346
+ * the one-age-keyed-sweep guard's books.
28347
+ */
28348
+ graceMinutes: require_dist.number().int().min(1).max(10080).optional()
28349
+ });
28350
+ var OrphanAuditScopeReportSchema = require_dist.object({
28351
+ collection: require_dist.string(),
28352
+ examined: require_dist.number(),
28353
+ orphans: require_dist.number(),
28354
+ reclaimed: require_dist.number(),
28355
+ bytesReclaimed: require_dist.number(),
28356
+ exempt: require_dist.number(),
28357
+ withinGrace: require_dist.number(),
28358
+ unresolvable: require_dist.number(),
28359
+ stoppedBy: require_dist.string(),
28360
+ /** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
28361
+ nextCursor: require_dist.number()
28362
+ });
28363
+ var OrphanAuditActionResultSchema = require_dist.object({
28364
+ /** `false` when a pass was already in flight — nothing was done. */
28365
+ ran: require_dist.boolean(),
28366
+ mode: require_dist._enum(["report", "reclaim"]),
28367
+ scopes: require_dist.array(OrphanAuditScopeReportSchema),
28368
+ totalOrphans: require_dist.number(),
28369
+ totalReclaimed: require_dist.number(),
28370
+ totalBytesReclaimed: require_dist.number(),
28371
+ durationMs: require_dist.number()
28372
+ });
28373
+ var orphanAuditActions = require_dist.defineCustomActions({ "retention.orphanAudit": require_dist.customAction(OrphanAuditActionInputSchema, OrphanAuditActionResultSchema, { auth: "admin" }) });
28374
+ //#endregion
27471
28375
  //#region src/pipeline-analytics/videoclips-provider.ts
27472
28376
  var SOURCE = "analytics";
27473
28377
  function clipIdFor(eventId, startMs, endMs) {
@@ -33211,13 +34115,25 @@ var StoragePressureTracker = class {
33211
34115
  };
33212
34116
  //#endregion
33213
34117
  //#region src/pipeline-analytics/store/media-store.ts
34118
+ /**
34119
+ * `scene` is its own owner kind rather than borrowing `event`.
34120
+ *
34121
+ * Scene reference thumbnails were written as `ownerKind: 'event'` with an
34122
+ * `ownerId` of `scene-<monitorId>-…`, which resolves through
34123
+ * `resolveMediaOwner` to `{ kind: 'event', eventId: 'scene-…' }` — an event row
34124
+ * that has never existed. The orphan audit therefore classified every
34125
+ * operator-captured reference picture as collectable. It is the same class of
34126
+ * mistake `identity` and `vehicle` are exempt for: a blob the OPERATOR curated,
34127
+ * which nothing else can regenerate and no cascade owns.
34128
+ */
33214
34129
  var OWNER_KINDS = [
33215
34130
  "event",
33216
34131
  "track",
33217
34132
  "face",
33218
34133
  "identity",
33219
34134
  "plate",
33220
- "vehicle"
34135
+ "vehicle",
34136
+ "scene"
33221
34137
  ];
33222
34138
  /**
33223
34139
  * Narrow an arbitrary string to an `OwnerKind`.
@@ -33678,21 +34594,33 @@ var MediaStore = class {
33678
34594
  * Best-effort per row; returns the number of index rows removed.
33679
34595
  */
33680
34596
  async deleteForOwner(ownerKind, ownerIds) {
34597
+ return (await this.gated(() => this.deleteForOwnerUnchecked(ownerKind, ownerIds))).rows;
34598
+ }
34599
+ /**
34600
+ * The same delete, reporting the BYTES it released as well as the row count.
34601
+ *
34602
+ * `sizeBytes` is already on the index row, so the number costs nothing — it
34603
+ * was simply never returned, which is why every retention ops-log row this
34604
+ * addon has ever written says `bytesReclaimed: 0` (D161).
34605
+ */
34606
+ async deleteForOwnerCounted(ownerKind, ownerIds) {
33681
34607
  return this.gated(() => this.deleteForOwnerUnchecked(ownerKind, ownerIds));
33682
34608
  }
33683
34609
  async deleteForOwnerUnchecked(ownerKind, ownerIds) {
33684
- let removed = 0;
34610
+ let rows = 0;
34611
+ let bytes = 0;
33685
34612
  for (const ownerId of ownerIds) {
33686
- const rows = await this.store.query.query({
34613
+ const found = await this.store.query.query({
33687
34614
  collection: MEDIA_COLLECTION,
33688
34615
  filter: { where: {
33689
34616
  ownerKind,
33690
34617
  ownerId
33691
34618
  } }
33692
34619
  });
33693
- for (const row of rows) {
34620
+ for (const row of found) {
33694
34621
  const rowData = row.data;
33695
34622
  const path = String(rowData["path"] ?? "");
34623
+ const sizeBytes = Number(rowData["sizeBytes"]);
33696
34624
  try {
33697
34625
  if (path) await this.storage.delete({
33698
34626
  location: mediaRowLocation(rowData),
@@ -33704,7 +34632,8 @@ var MediaStore = class {
33704
34632
  collection: MEDIA_COLLECTION,
33705
34633
  key: row.id
33706
34634
  });
33707
- removed++;
34635
+ rows++;
34636
+ if (Number.isFinite(sizeBytes)) bytes += sizeBytes;
33708
34637
  } catch (err) {
33709
34638
  this.logger.debug("media delete-for-owner failed", { meta: {
33710
34639
  key: row.id,
@@ -33713,7 +34642,10 @@ var MediaStore = class {
33713
34642
  }
33714
34643
  }
33715
34644
  }
33716
- return removed;
34645
+ return {
34646
+ rows,
34647
+ bytes
34648
+ };
33717
34649
  }
33718
34650
  /**
33719
34651
  * On-demand footprint aggregation for the events-management UI: sum
@@ -33852,11 +34784,20 @@ var MediaStore = class {
33852
34784
  * batch.
33853
34785
  */
33854
34786
  async deleteByTracks(trackIds) {
33855
- await this.gated(async () => {
33856
- await this.deleteForOwnerUnchecked("track", trackIds);
33857
- await this.deleteForOwnerUnchecked("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
34787
+ const own = await this.gated(async () => {
34788
+ const tracks = await this.deleteForOwnerUnchecked("track", trackIds);
34789
+ const faces = await this.deleteForOwnerUnchecked("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
34790
+ return {
34791
+ rows: tracks.rows + faces.rows,
34792
+ bytes: tracks.bytes + faces.bytes
34793
+ };
33858
34794
  });
33859
- await this.deleteForOwner("plate", trackIds.map((id) => PLATE_MEDIA_OWNER_PREFIX + id));
34795
+ const plates = await this.deleteForOwnerCounted("plate", trackIds.map((id) => PLATE_MEDIA_OWNER_PREFIX + id));
34796
+ return [{
34797
+ family: "media",
34798
+ rows: own.rows + plates.rows,
34799
+ bytes: own.bytes + plates.bytes
34800
+ }];
33860
34801
  }
33861
34802
  /**
33862
34803
  * A stable page of media INDEX rows for the orphan audit — no blobs, no
@@ -38294,11 +39235,21 @@ var EventStore = class {
38294
39235
  error: String(err)
38295
39236
  } });
38296
39237
  }
39238
+ let mediaRows = 0;
38297
39239
  if (eventIds.length > 0 && this.media !== void 0) try {
38298
- await this.media.deleteForEvents(eventIds);
39240
+ mediaRows = await this.media.deleteForEvents(eventIds);
38299
39241
  } catch (err) {
38300
39242
  this.logger.warn("EventStore.deleteByTracks: event media delete failed", { meta: { error: String(err) } });
38301
39243
  }
39244
+ return [{
39245
+ family: "events",
39246
+ rows: eventIds.length,
39247
+ bytes: 0
39248
+ }, {
39249
+ family: "media",
39250
+ rows: mediaRows,
39251
+ bytes: 0
39252
+ }];
38302
39253
  }
38303
39254
  };
38304
39255
  function slimMotion(id, data) {
@@ -38689,6 +39640,7 @@ var FaceStore = class {
38689
39640
  * aborts the batch. (Crop media of these rows is removed by `MediaStore`.)
38690
39641
  */
38691
39642
  async deleteByTracks(trackIds) {
39643
+ let removed = 0;
38692
39644
  for (const trackId of trackIds) try {
38693
39645
  const eligible = (await this.store.query.query({
38694
39646
  collection: FACES_COLLECTION,
@@ -38699,6 +39651,7 @@ var FaceStore = class {
38699
39651
  collection: FACES_COLLECTION,
38700
39652
  key: row.id
38701
39653
  });
39654
+ removed++;
38702
39655
  } catch (err) {
38703
39656
  this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
38704
39657
  faceId: row.id,
@@ -38711,6 +39664,11 @@ var FaceStore = class {
38711
39664
  error: String(err)
38712
39665
  } });
38713
39666
  }
39667
+ return [{
39668
+ family: "faces",
39669
+ rows: removed,
39670
+ bytes: 0
39671
+ }];
38714
39672
  }
38715
39673
  /** Delete a single buffered face row by id (its crop media is removed by the
38716
39674
  * caller — the FaceStore owns rows, not blobs). Best-effort. */
@@ -39646,6 +40604,7 @@ var PlateStore = class {
39646
40604
  * the batch. (Crop media of these rows is removed by `MediaStore`.)
39647
40605
  */
39648
40606
  async deleteByTracks(trackIds) {
40607
+ let removed = 0;
39649
40608
  for (const trackId of trackIds) try {
39650
40609
  const eligible = (await this.store.query.query({
39651
40610
  collection: PLATES_COLLECTION,
@@ -39656,6 +40615,7 @@ var PlateStore = class {
39656
40615
  collection: PLATES_COLLECTION,
39657
40616
  key: row.id
39658
40617
  });
40618
+ removed++;
39659
40619
  } catch (err) {
39660
40620
  this.logger.warn("PlateStore.deleteByTracks delete failed", { meta: {
39661
40621
  plateId: row.id,
@@ -39668,6 +40628,11 @@ var PlateStore = class {
39668
40628
  error: String(err)
39669
40629
  } });
39670
40630
  }
40631
+ return [{
40632
+ family: "plates",
40633
+ rows: removed,
40634
+ bytes: 0
40635
+ }];
39671
40636
  }
39672
40637
  /**
39673
40638
  * Hold the unassigned plate buffer to `maxPerDevice` newest reads per camera.
@@ -40789,6 +41754,36 @@ var TrackStore = class {
40789
41754
  dropActive(trackId) {
40790
41755
  this.active.delete(trackId);
40791
41756
  }
41757
+ /**
41758
+ * Close a still-ACTIVE track NOW: persist its row, then drop the in-RAM
41759
+ * entry. The early-close counterpart of {@link expireStale}, for a caller
41760
+ * that knows the track is over before its TTL says so.
41761
+ *
41762
+ * **This exists because {@link dropActive} orphans everything the track
41763
+ * owns.** A track's media, object events and CLIP vector are keyed by trackId
41764
+ * and written throughout its life; the ROW is what retention cascades from,
41765
+ * and it is written only here or at expiry. Forgetting a lived-in track
41766
+ * therefore leaves a full media set — key frame, thumbnail, first/last frame,
41767
+ * the snapshot filmstrip — with no owner any sweep can ever reach. Measured on
41768
+ * the live hub 2026-08-14: 74,700 such media rows, 18.9 GB, growing
41769
+ * ~1.15 GB/day, plus 23,296 object events and 4,543 CLIP vectors (D161).
41770
+ *
41771
+ * `dropActive` is still correct for a birth RETRACTED before it lived —
41772
+ * nothing has been written under that id yet. It is never correct for a track
41773
+ * that ran.
41774
+ *
41775
+ * Returns whether an active entry was found. THROWS if the persist fails: the
41776
+ * caller must log it, because a swallowed failure here IS the leak.
41777
+ */
41778
+ async closeActive(trackId) {
41779
+ const t = this.active.get(trackId);
41780
+ if (t === void 0) return false;
41781
+ t.active = false;
41782
+ const record = cloneTrack(t);
41783
+ await this.persistCompleted(record);
41784
+ this.active.delete(trackId);
41785
+ return true;
41786
+ }
40792
41787
  /** Delete the persisted track row (keyed by trackId) and drop the in-RAM
40793
41788
  * active entry if present. Used by the whole-track deletion cascade. */
40794
41789
  async deletePersisted(trackId) {
@@ -41920,7 +42915,11 @@ function auditableCollections() {
41920
42915
  * exemption lives here now, and the orphan collector — the only sweep that
41921
42916
  * walks media it was not handed by name — reads it.
41922
42917
  */
41923
- var RETENTION_EXEMPT_OWNER_KINDS = new Set(["identity", "vehicle"]);
42918
+ var RETENTION_EXEMPT_OWNER_KINDS = new Set([
42919
+ "identity",
42920
+ "vehicle",
42921
+ "scene"
42922
+ ]);
41924
42923
  function isRetentionExemptOwnerKind(kind) {
41925
42924
  return RETENTION_EXEMPT_OWNER_KINDS.has(kind);
41926
42925
  }
@@ -42139,6 +43138,7 @@ async function auditScope(scope, deps, limits, cursor) {
42139
43138
  } });
42140
43139
  }
42141
43140
  }
43141
+ if (deps.breathe) await deps.breathe();
42142
43142
  if (ceilingHit) {
42143
43143
  stoppedBy = "reclaim-ceiling";
42144
43144
  offset += rows.length - reclaimedInPage;
@@ -44858,22 +45858,6 @@ async function projectSensorMarkers(deps, data, timestamp) {
44858
45858
  }
44859
45859
  return landed;
44860
45860
  }
44861
- /** JPEG quality for the downscaled full frame — matches the crop path. */
44862
- var FULL_FRAME_QUALITY = 80;
44863
- /**
44864
- * Downscale an already-encoded JPEG full frame to FIT WITHIN
44865
- * {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
44866
- * ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
44867
- * box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
44868
- * track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
44869
- * at night) is never stored or served — the privacy fix moved to CAPTURE time.
44870
- */
44871
- async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
44872
- return (0, sharp.default)(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
44873
- fit: "inside",
44874
- withoutEnlargement: true
44875
- }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
44876
- }
44877
45861
  //#endregion
44878
45862
  //#region src/pipeline-analytics/services/synthetic-track.ts
44879
45863
  /**
@@ -46914,7 +47898,11 @@ var ObjectEmbeddingStore = class {
46914
47898
  * breaks recognition (design §2/§4).
46915
47899
  */
46916
47900
  async deleteByTracks(trackIds) {
46917
- if (trackIds.length === 0) return;
47901
+ if (trackIds.length === 0) return [{
47902
+ family: "embeddings",
47903
+ rows: 0,
47904
+ bytes: 0
47905
+ }];
46918
47906
  try {
46919
47907
  await this.vectorStore.deleteByIds.mutate({
46920
47908
  index: OBJECT_CLIP_INDEX,
@@ -46925,7 +47913,17 @@ var ObjectEmbeddingStore = class {
46925
47913
  count: trackIds.length,
46926
47914
  error: String(err)
46927
47915
  } });
47916
+ return [{
47917
+ family: "embeddings",
47918
+ rows: 0,
47919
+ bytes: 0
47920
+ }];
46928
47921
  }
47922
+ return [{
47923
+ family: "embeddings",
47924
+ rows: trackIds.length,
47925
+ bytes: 0
47926
+ }];
46929
47927
  }
46930
47928
  };
46931
47929
  /** Build the vector-store metadata for one embedding. */
@@ -47799,7 +48797,8 @@ var NO_PUBLICATION = {
47799
48797
  var customActions = {
47800
48798
  ...ncActions,
47801
48799
  ...embeddingActions,
47802
- ...debugActions
48800
+ ...debugActions,
48801
+ ...orphanAuditActions
47803
48802
  };
47804
48803
  /**
47805
48804
  * Assist threshold when the caller does not pin one. Packages have their own
@@ -48777,7 +49776,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
48777
49776
  customActions: {
48778
49777
  ...ncActions,
48779
49778
  ...embeddingActions,
48780
- ...debugActions
49779
+ ...debugActions,
49780
+ ...orphanAuditActions
48781
49781
  },
48782
49782
  actionHandlers: {
48783
49783
  ...ncHandlers,
@@ -49402,6 +50402,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
49402
50402
  addonId: device.addonId
49403
50403
  };
49404
50404
  },
50405
+ getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
49405
50406
  identityIdsByName: async () => {
49406
50407
  const byName = /* @__PURE__ */ new Map();
49407
50408
  const identities = await stores.identityStore.listIdentities();
@@ -49495,6 +50496,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
49495
50496
  }
49496
50497
  return out;
49497
50498
  },
50499
+ readSceneStates: () => (this.sceneProvider?.listAllEnabled() ?? []).map(({ deviceId, monitor }) => ({
50500
+ sceneId: monitor.id,
50501
+ deviceId,
50502
+ label: monitor.label,
50503
+ verdict: monitor.verdict,
50504
+ latched: monitor.latched,
50505
+ emit: monitor.emit
50506
+ })),
49498
50507
  readOccupancySnapshots: async (deviceIds) => {
49499
50508
  const za = this.zoneAnalytics;
49500
50509
  if (za === null) return /* @__PURE__ */ new Map();
@@ -50270,13 +51279,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
50270
51279
  if (media === null) throw new Error("media store not ready");
50271
51280
  return media.putReplacing({
50272
51281
  deviceId,
50273
- ownerKind: "event",
51282
+ ownerKind: "scene",
50274
51283
  ownerId,
50275
51284
  kind: "thumbnailSmall",
50276
51285
  timestamp,
50277
51286
  data
50278
51287
  });
50279
51288
  },
51289
+ dropMedia: async (mediaId) => {
51290
+ await this.mediaStore?.deleteByKey(mediaId);
51291
+ },
50280
51292
  mirrorSlice: (deviceId, status) => {
50281
51293
  this.mirrorSceneSlice(deviceId, status);
50282
51294
  },
@@ -50293,6 +51305,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
50293
51305
  data: payload
50294
51306
  });
50295
51307
  },
51308
+ onSceneEdge: (edge) => {
51309
+ this.notificationCenter?.onSceneEdge({
51310
+ sceneId: edge.monitorId,
51311
+ deviceId: edge.deviceId,
51312
+ label: edge.monitorLabel,
51313
+ verdict: edge.verdict,
51314
+ latched: edge.latched,
51315
+ emit: edge.emit
51316
+ });
51317
+ },
50296
51318
  currentCondition: async (deviceId) => resolveCondition({
50297
51319
  ...await this.sceneConditionContext(deviceId),
50298
51320
  now: /* @__PURE__ */ new Date()
@@ -50301,6 +51323,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
50301
51323
  });
50302
51324
  provider.hydrate();
50303
51325
  this.sceneProvider = provider;
51326
+ this.notificationCenter?.refreshSceneStates();
50304
51327
  if (!this.isPostProcessingNode) {
50305
51328
  log.info("scene evaluation INERT on this node — CRUD only");
50306
51329
  return;
@@ -52762,7 +53785,17 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
52762
53785
  };
52763
53786
  if (plausible) this.stationaryRegistry?.promote(entry);
52764
53787
  processor.dropTrack(track.trackId);
52765
- this.trackStore?.dropActive(track.trackId);
53788
+ this.trackStore?.closeActive(track.trackId).catch((err) => {
53789
+ this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
53790
+ tags: { deviceId },
53791
+ meta: {
53792
+ deviceId,
53793
+ trackId: track.trackId,
53794
+ entryId: entry.id,
53795
+ error: String(err)
53796
+ }
53797
+ });
53798
+ });
52766
53799
  const faceEnd = this.faceRecognizer?.onTrackEnd(deviceId, track.trackId);
52767
53800
  const dropKeyFrame = () => {
52768
53801
  this.residents.dropKeyFrameKey(track.trackId);
@@ -53352,19 +54385,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53352
54385
  * stops at a per-scope row ceiling, RESUMES from a persisted cursor next run,
53353
54386
  * and refuses to start if the previous run has not finished.
53354
54387
  */
53355
- async runOrphanAuditPass() {
53356
- if (this.shuttingDown) return;
54388
+ async runOrphanAuditPass(overrides) {
54389
+ if (this.shuttingDown) return null;
53357
54390
  if (this.orphanAuditRunning) {
53358
54391
  this.ctx.logger.info("analytics orphan audit skipped — previous run still in flight");
53359
- return;
54392
+ return null;
53360
54393
  }
53361
54394
  const trackStore = this.trackStore;
53362
54395
  const eventStore = this.eventStore;
53363
54396
  const mediaStore = this.mediaStore;
53364
- if (!trackStore || !eventStore || !mediaStore) return;
54397
+ if (!trackStore || !eventStore || !mediaStore) return null;
53365
54398
  this.orphanAuditRunning = true;
53366
54399
  try {
53367
- const limits = await this.orphanAuditLimits();
54400
+ const limits = await this.orphanAuditLimits(overrides);
53368
54401
  const deps = {
53369
54402
  page: async (collection, offset, limit) => {
53370
54403
  if (collection === "pipeline-analytics:media") return mediaStore.pageForAudit(offset, limit);
@@ -53396,13 +54429,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53396
54429
  });
53397
54430
  },
53398
54431
  now: () => Date.now(),
53399
- logger: this.ctx.logger
54432
+ logger: this.ctx.logger,
54433
+ breathe: () => new Promise((resolve) => setImmediate(resolve))
53400
54434
  };
53401
54435
  const { report, cursors } = await runOrphanAudit({
53402
54436
  scopes: buildOrphanScopes(),
53403
54437
  deps,
53404
54438
  limits,
53405
- cursors: this.orphanAuditCursors
54439
+ cursors: overrides?.restart === true ? /* @__PURE__ */ new Map() : this.orphanAuditCursors
53406
54440
  });
53407
54441
  this.orphanAuditCursors = cursors;
53408
54442
  for (const scope of report.scopes) {
@@ -53437,8 +54471,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53437
54471
  bytesReclaimed: report.totalBytesReclaimed,
53438
54472
  detail: `orphan audit (${report.mode}): ${report.totalOrphans} orphaned rows across ${report.scopes.length} collections, ${report.totalReclaimed} reclaimed in ${report.durationMs}ms`
53439
54473
  });
54474
+ return report;
53440
54475
  } catch (err) {
53441
54476
  this.ctx.logger.warn("analytics orphan audit failed", { meta: { error: String(err) } });
54477
+ return null;
53442
54478
  } finally {
53443
54479
  this.orphanAuditRunning = false;
53444
54480
  }
@@ -53501,15 +54537,65 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53501
54537
  * raising them is raising the blast radius of a bug, and that should take a
53502
54538
  * release.
53503
54539
  */
53504
- async orphanAuditLimits() {
54540
+ async orphanAuditLimits(overrides) {
53505
54541
  const mode = await this.orphanAuditModeState.get();
53506
54542
  const pageSize = await this.orphanAuditPageSizeState.get();
54543
+ const graceMinutes = overrides?.graceMinutes;
54544
+ return {
54545
+ mode: overrides !== void 0 ? overrides.mode ?? "report" : mode === "reclaim" ? "reclaim" : "report",
54546
+ graceMs: graceMinutes === void 0 ? DEFAULT_ORPHAN_LIMITS.graceMs : graceMinutes * 6e4,
54547
+ pageSize: overrides?.pageSize ?? pageSize,
54548
+ maxRowsPerScope: overrides?.maxRowsPerScope ?? DEFAULT_ORPHAN_LIMITS.maxRowsPerScope,
54549
+ maxReclaimPerScope: overrides?.maxReclaimPerScope ?? DEFAULT_ORPHAN_LIMITS.maxReclaimPerScope
54550
+ };
54551
+ }
54552
+ /**
54553
+ * One operator-driven orphan-audit pass — the entry point behind
54554
+ * `retention.orphanAudit`.
54555
+ *
54556
+ * DRY RUN BY DEFAULT: an input with no `mode` reports and deletes nothing,
54557
+ * whatever `orphanAuditMode` is set to. Reclaiming is a word the caller has to
54558
+ * type. Draining the standing 18.9 GB means running this with
54559
+ * `mode: 'reclaim'` repeatedly until `totalOrphans` reaches 0 (D161).
54560
+ */
54561
+ async runOrphanAuditNow(overrides) {
54562
+ const report = await this.runOrphanAuditPass(overrides);
54563
+ if (report === null) return {
54564
+ ran: false,
54565
+ mode: overrides.mode ?? "report",
54566
+ scopes: [],
54567
+ totalOrphans: 0,
54568
+ totalReclaimed: 0,
54569
+ totalBytesReclaimed: 0,
54570
+ durationMs: 0
54571
+ };
54572
+ this.ctx.logger.info("analytics orphan audit (operator)", { meta: {
54573
+ mode: report.mode,
54574
+ orphans: report.totalOrphans,
54575
+ reclaimed: report.totalReclaimed,
54576
+ bytesReclaimed: report.totalBytesReclaimed,
54577
+ durationMs: report.durationMs,
54578
+ restart: overrides.restart === true
54579
+ } });
53507
54580
  return {
53508
- mode: mode === "reclaim" ? "reclaim" : "report",
53509
- graceMs: DEFAULT_ORPHAN_LIMITS.graceMs,
53510
- pageSize,
53511
- maxRowsPerScope: DEFAULT_ORPHAN_LIMITS.maxRowsPerScope,
53512
- maxReclaimPerScope: DEFAULT_ORPHAN_LIMITS.maxReclaimPerScope
54581
+ ran: true,
54582
+ mode: report.mode,
54583
+ scopes: report.scopes.map((scope) => ({
54584
+ collection: scope.collection,
54585
+ examined: scope.examined,
54586
+ orphans: scope.orphans,
54587
+ reclaimed: scope.reclaimed,
54588
+ bytesReclaimed: scope.bytesReclaimed,
54589
+ exempt: scope.exempt,
54590
+ withinGrace: scope.withinGrace,
54591
+ unresolvable: scope.unresolvable,
54592
+ stoppedBy: scope.stoppedBy,
54593
+ nextCursor: scope.nextCursor
54594
+ })),
54595
+ totalOrphans: report.totalOrphans,
54596
+ totalReclaimed: report.totalReclaimed,
54597
+ totalBytesReclaimed: report.totalBytesReclaimed,
54598
+ durationMs: report.durationMs
53513
54599
  };
53514
54600
  }
53515
54601
  /**
@@ -53709,7 +54795,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53709
54795
  indexed
53710
54796
  };
53711
54797
  },
53712
- "embedding.wipe": async () => this.wipeObjectEmbeddings()
54798
+ "embedding.wipe": async () => this.wipeObjectEmbeddings(),
54799
+ "retention.orphanAudit": async (input) => this.runOrphanAuditNow(input)
53713
54800
  };
53714
54801
  }
53715
54802
  /** Progress of the detached rebuild — the only way to observe a long pass. */
@@ -53968,8 +55055,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53968
55055
  * Track-centric time-based retention (design §5.1). Drains every persisted
53969
55056
  * track for the device whose `lastSeen < cutoffMs`, page by page, through the
53970
55057
  * widened cascade — enrolled faces/plates + identity media are exempt (design
53971
- * §4). `tracks` is the authoritative count; leaf families are best-effort 0
53972
- * under the void `TrackScopedStore` contract (see cap doc).
55058
+ * §4).
55059
+ *
55060
+ * **Every family count here is REAL.** It used to return `tracks` and a
55061
+ * literal 0 for every leaf, because `TrackScopedStore.deleteByTracks` returned
55062
+ * `void` — so `bytesReclaimed: 0` went onto all 3,732 ops-log rows this addon
55063
+ * had ever written and 18.9 GB of leaked media read as "nothing to reclaim"
55064
+ * (D161). The bytes ride {@link lastPruneBytes} rather than
55065
+ * `TrackCascadeCounts`, which is a cap output shape.
53973
55066
  */
53974
55067
  async pruneTracksBefore(input) {
53975
55068
  const empty = {
@@ -53980,26 +55073,32 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53980
55073
  plates: 0,
53981
55074
  embeddings: 0
53982
55075
  };
55076
+ this.lastPruneBytes = 0;
53983
55077
  const cascade = this.buildTrackCascadeRegistry();
53984
55078
  if (!cascade) return empty;
55079
+ let tally = EMPTY_CASCADE_TALLY;
53985
55080
  const { deleted: totalDeleted, skipped } = await drainTracksBefore({
53986
55081
  stopped: () => this.shuttingDown,
53987
55082
  listIdsBefore: (offset, limit) => cascade.trackStore.listIdsBefore(input.deviceId, input.cutoffMs, limit, offset),
53988
- cascade: (ids) => runTrackCascadeBatch({
53989
- registry: cascade.registry,
53990
- trackStore: cascade.trackStore,
53991
- deviceId: input.deviceId,
53992
- onTrackCleanup: (deviceId, trackId) => this.clearLiveTrackState(deviceId, trackId),
53993
- onFailure: (trackId, err) => {
53994
- this.ctx.logger.debug("pruneTracksBefore track failed", {
53995
- tags: { deviceId: input.deviceId },
53996
- meta: {
53997
- trackId,
53998
- error: String(err)
53999
- }
54000
- });
54001
- }
54002
- }, ids)
55083
+ cascade: async (ids) => {
55084
+ const result = await runTrackCascadeBatch({
55085
+ registry: cascade.registry,
55086
+ trackStore: cascade.trackStore,
55087
+ deviceId: input.deviceId,
55088
+ onTrackCleanup: (deviceId, trackId) => this.clearLiveTrackState(deviceId, trackId),
55089
+ onFailure: (trackId, err) => {
55090
+ this.ctx.logger.debug("pruneTracksBefore track failed", {
55091
+ tags: { deviceId: input.deviceId },
55092
+ meta: {
55093
+ trackId,
55094
+ error: String(err)
55095
+ }
55096
+ });
55097
+ }
55098
+ }, ids);
55099
+ tally = mergeCascadeTallies(tally, result.tally);
55100
+ return { deleted: result.deleted };
55101
+ }
54003
55102
  });
54004
55103
  if (skipped > 0) this.ctx.logger.warn("analytics track retention skipped undeletable rows", {
54005
55104
  tags: { deviceId: input.deviceId },
@@ -54010,20 +55109,41 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
54010
55109
  pruned: totalDeleted
54011
55110
  }
54012
55111
  });
55112
+ this.lastPruneBytes = tally.bytesReclaimed;
54013
55113
  if (totalDeleted > 0) this.ctx.logger.info("analytics track retention prune", {
54014
55114
  tags: { deviceId: input.deviceId },
54015
55115
  meta: {
54016
55116
  deviceId: input.deviceId,
54017
55117
  cutoffMs: input.cutoffMs,
54018
- tracks: totalDeleted
55118
+ tracks: totalDeleted,
55119
+ events: tally.events,
55120
+ media: tally.media,
55121
+ faces: tally.faces,
55122
+ plates: tally.plates,
55123
+ embeddings: tally.embeddings,
55124
+ bytesReclaimed: tally.bytesReclaimed
54019
55125
  }
54020
55126
  });
54021
55127
  return {
54022
55128
  ...empty,
54023
- tracks: totalDeleted
55129
+ tracks: totalDeleted,
55130
+ events: tally.events,
55131
+ media: tally.media,
55132
+ faces: tally.faces,
55133
+ plates: tally.plates,
55134
+ embeddings: tally.embeddings
54024
55135
  };
54025
55136
  }
54026
55137
  /**
55138
+ * Bytes released by the most recent {@link pruneTracksBefore}.
55139
+ *
55140
+ * A field rather than a return value because `TrackCascadeCounts` is a cap
55141
+ * OUTPUT shape and widening it is a codegen change; the ops-log row already
55142
+ * has a `bytesReclaimed` column and this is what fills it. Reset to 0 at the
55143
+ * top of every prune, so a stale value can never be reported as a fresh one.
55144
+ */
55145
+ lastPruneBytes = 0;
55146
+ /**
54027
55147
  * Operator "clean slate" for a device (design §5.3): prune EVERY persisted
54028
55148
  * track via the same cascade as `pruneTracksBefore` with `cutoffMs = now`.
54029
55149
  * Enrolled gallery + identity media are exempt.
@@ -54077,8 +55197,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
54077
55197
  reason: "retention",
54078
55198
  deviceId,
54079
55199
  itemsAffected: counts.tracks,
54080
- bytesReclaimed: 0,
54081
- detail: `track retention cascade: ${counts.tracks} tracks, ${counts.events} events, ${counts.media} media`
55200
+ bytesReclaimed: this.lastPruneBytes,
55201
+ detail: `track retention cascade: ${counts.tracks} tracks, ${counts.events} events, ${counts.media} media, ${counts.faces} faces, ${counts.plates} plates, ${counts.embeddings} vectors`
54082
55202
  });
54083
55203
  return counts;
54084
55204
  },