@camstack/addon-post-analysis 1.2.208 → 1.2.210

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.
@@ -1,4 +1,4 @@
1
- import { $ as audioModeOf, $t as EventCategory, A as NcSnoozeSchema, At as vectorDimFromBase64, B as SCENE_DEFAULT_UNCOVERED_POLICY, Bt as nodePin, C as NcConditionDescriptorSchema, Ct as readTimelapseGeneratedAt, D as NcRuleTargetSchema, Dt as storageOccupancyCapability, E as NcRuleSchema, Et as sceneMonitorCapability, Ft as CamProfileSchema, G as TimelapseRulePatchSchema, Gt as discriminatedUnion, H as SceneMonitorSchema, Ht as _enum, I as RECORDING_EXPORT_MAX_READ_BYTES, It as DeviceType, J as VISIT_MERGE_GAP_MS, Jt as object, K as TimelapseRuleSchema, Kt as literal, L as RetrainStatusSchema, Lt as createEvent, M as NcSystemEventKindSchema, Mt as zoneAnalyticsCapability, N as NcTaxonomySchema, Nt as errMsg$1, O as NcScheduleSchema, Ot as subKindsOf, P as OpsLogEntrySchema, Pt as BaseAddon, Q as audioMetricsCapability, Qt as unknown, Rt as hydrateSchema, S as NC_TAXONOMY, St as readDeviceStateFrom, T as NcRulePatchSchema, U as TIMELAPSE_DENSE_FLOOR_SEC, Ut as array, V as SCENE_DIVERGED, Vt as sleep$1, W as TimelapseRuleInputSchema, Wt as boolean, X as alarmPanelCapability, Xt as record, Y as addonWidgetsSourceCapability, Yt as partialRecord, Z as assertTimelapseCadences, Zt as string, _ as MediaFileKindEnum, _t as notificationRulesCapability, a as DEFAULT_EVENT_COLOR, b as NC_DEFAULT_SNOOZE_MINUTES, bt as pipelineAnalyticsCapability, c as DETECTION_MACRO_CLASSES, ct as evictionPolicyOfLocation, d as EVENT_KIND_BY_CAP, et as buildEventKindDescriptor, f as EVENT_PAD_MS, ft as isDetectionMacroClass, g as MACRO_LABELS, gt as mayWriteToLocation, h as LabelAttributionSchema, ht as kebabToCamel, i as COCO_TO_MACRO, it as deriveRecordingMode, j as NcSnoozeSuppressedSchema, jt as videoclipsCapability, k as NcSnoozeInputSchema, kt as systemEventFilterApplies, lt as faceGalleryCapability, m as FailureCounters, mt as isSourceCap, n as BaseDevice, nt as customAction, ot as encodeVectorBase64, p as FULL_IMAGE_BBOX, pt as isScheduleActive, q as TrackSourceSchema, qt as number, r as CLUSTER_MODEL_SCOPED_STEPS, rt as defineCustomActions, st as evaluateSensorEdge, t as AUDIO_MACRO_LABELS, tt as cosineSimilarity$1, u as DeclaredDevices, ut as failureContributionCapability, v as NC_ALARM_SYSTEM_EVENT_KINDS, w as NcRuleInputSchema, wt as resolveLocationMode, xt as plateGalleryCapability, y as NC_CONDITION_CATALOG, yt as pickClusterStepModels, z as SCENE_DEFAULT_ANCHOR_THRESHOLD, zt as isDeviceScopedCap } from "../dist-WxrHfFRz.mjs";
1
+ import { $ as assertTimelapseCadences, $t as string, A as NcScheduleSchema, At as subKindsOf, Bt as hydrateSchema, Ct as plateGalleryCapability, D as NcRulePatchSchema, E as NcRuleInputSchema, Et as resolveLocationMode, F as NcTaxonomySchema, Ft as errMsg$1, G as TIMELAPSE_DENSE_FLOOR_SEC, Gt as array, H as SCENE_DEFAULT_UNCOVERED_POLICY, Ht as nodePin, I as OpsLogEntrySchema, It as BaseAddon, J as TimelapseRuleSchema, Jt as literal, K as TimelapseRuleInputSchema, Kt as boolean, Lt as CamProfileSchema, M as NcSnoozeSchema, Mt as vectorDimFromBase64, N as NcSnoozeSuppressedSchema, Nt as videoclipsCapability, O as NcRuleSchema, Ot as sceneMonitorCapability, P as NcSystemEventKindSchema, Pt as zoneAnalyticsCapability, Q as alarmPanelCapability, Qt as record, R as RECORDING_EXPORT_MAX_READ_BYTES, Rt as DeviceType, S as NC_DEFAULT_SNOOZE_MINUTES, St as pipelineAnalyticsCapability, T as NcConditionDescriptorSchema, Tt as readTimelapseGeneratedAt, U as SCENE_DIVERGED, Ut as sleep$1, V as SCENE_DEFAULT_ANCHOR_THRESHOLD, Vt as isDeviceScopedCap, W as SceneMonitorSchema, Wt as _enum, X as VISIT_MERGE_GAP_MS, Xt as object, Y as TrackSourceSchema, Yt as number, Z as addonWidgetsSourceCapability, Zt as partialRecord, _ as MACRO_LABELS, _t as kebabToCamel, a as COCO_TO_MACRO, at as defineCustomActions, b as NC_ALARM_SYSTEM_EVENT_KINDS, ct as encodeVectorBase64, d as DeclaredDevices, dt as faceGalleryCapability, en as unknown, et as audioMetricsCapability, f as EVENT_KIND_BY_CAP, ft as failureContributionCapability, g as LabelAttributionSchema, gt as isSourceCap, h as FailureCounters, ht as isScheduleActive, i as CLUSTER_MODEL_SCOPED_STEPS, it as customAction, j as NcSnoozeInputSchema, jt as systemEventFilterApplies, k as NcRuleTargetSchema, kt as storageOccupancyCapability, l as DETECTION_MACRO_CLASSES, lt as evaluateSensorEdge, m as FULL_IMAGE_BBOX, mt as isDetectionMacroClass, n as ArchivedDebugNoteSchema, nt as buildEventKindDescriptor, o as DEFAULT_EVENT_COLOR, ot as deriveRecordingMode, p as EVENT_PAD_MS, q as TimelapseRulePatchSchema, qt as discriminatedUnion, r as BaseDevice, rt as cosineSimilarity$1, t as AUDIO_MACRO_LABELS, tn as EventCategory, tt as audioModeOf, ut as evictionPolicyOfLocation, v as MAX_ARCHIVED_DEBUG_NOTES, vt as mayWriteToLocation, w as NC_TAXONOMY, wt as readDeviceStateFrom, x as NC_CONDITION_CATALOG, xt as pickClusterStepModels, y as MediaFileKindEnum, yt as notificationRulesCapability, z as RetrainStatusSchema, zt as createEvent } from "../dist-D2rFaMex.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -3445,7 +3445,7 @@ var SceneConfirmGate = class {
3445
3445
  };
3446
3446
  }
3447
3447
  };
3448
- function clamp$1(v, lo, hi) {
3448
+ function clamp$2(v, lo, hi) {
3449
3449
  return Math.max(lo, Math.min(v, hi));
3450
3450
  }
3451
3451
  /**
@@ -3460,10 +3460,10 @@ async function cropSnapshotRoi(encoded, roi) {
3460
3460
  const frameW = meta.width ?? 0;
3461
3461
  const frameH = meta.height ?? 0;
3462
3462
  if (frameW <= 0 || frameH <= 0) throw new Error("snapshot has no decodable dimensions");
3463
- const left = clamp$1(Math.round(roi.x * frameW), 0, frameW - 1);
3464
- const top = clamp$1(Math.round(roi.y * frameH), 0, frameH - 1);
3465
- const width = clamp$1(Math.round(roi.width * frameW), 1, frameW - left);
3466
- const height = clamp$1(Math.round(roi.height * frameH), 1, frameH - top);
3463
+ const left = clamp$2(Math.round(roi.x * frameW), 0, frameW - 1);
3464
+ const top = clamp$2(Math.round(roi.y * frameH), 0, frameH - 1);
3465
+ const width = clamp$2(Math.round(roi.width * frameW), 1, frameW - left);
3466
+ const height = clamp$2(Math.round(roi.height * frameH), 1, frameH - top);
3467
3467
  return {
3468
3468
  crop: await sharp(encoded).extract({
3469
3469
  left,
@@ -7513,7 +7513,8 @@ function cooldownKey(rule, subject) {
7513
7513
  const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
7514
7514
  const scopeRef = subject.systemEvent !== void 0 ? systemEventCooldownScope(subject.systemEvent) : `d:${subject.deviceId}`;
7515
7515
  const kindKey = subject.systemEvent !== void 0 ? `:k:${subject.systemEvent.kind}` : "";
7516
- return rule.throttle.scope === "rule" ? `r:${rule.id}${kindKey}${classKey}` : `r:${rule.id}:${scopeRef}${classKey}`;
7516
+ const phaseKey = subject.packagePhase !== void 0 ? `:p:${subject.packagePhase}` : "";
7517
+ return rule.throttle.scope === "rule" ? `r:${rule.id}${kindKey}${phaseKey}${classKey}` : `r:${rule.id}:${scopeRef}${phaseKey}${classKey}`;
7517
7518
  }
7518
7519
  /** True when the rule fired within its cooldown window before `now`. */
7519
7520
  function isCoolingDown(rule, lastFiredAt, now) {
@@ -9950,6 +9951,10 @@ var en_default = {
9950
9951
  "one": "{{count}} other {{classNoun}}",
9951
9952
  "other": "{{count}} other {{classNoun}}"
9952
9953
  },
9954
+ "package.delivered.title": "Parcel delivered",
9955
+ "package.delivered.body": "Delivered at {{camera}}{{inZones}}",
9956
+ "package.picked-up.title": "Parcel collected",
9957
+ "package.picked-up.body": "Collected from {{camera}}{{inZones}}",
9953
9958
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
9954
9959
  "occupancy.op.occupied": "occupied",
9955
9960
  "occupancy.op.free": "free",
@@ -10201,6 +10206,10 @@ var it_default = {
10201
10206
  "one": "{{count}} altro {{classNoun}}",
10202
10207
  "other": "{{count}} altri {{classNoun}}"
10203
10208
  },
10209
+ "package.delivered.title": "Pacco consegnato",
10210
+ "package.delivered.body": "Consegnato su {{camera}}{{inZones}}",
10211
+ "package.picked-up.title": "Pacco ritirato",
10212
+ "package.picked-up.body": "Ritirato da {{camera}}{{inZones}}",
10204
10213
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
10205
10214
  "occupancy.op.occupied": "occupata",
10206
10215
  "occupancy.op.free": "libera",
@@ -12043,6 +12052,7 @@ function bodyKeyFor(entry) {
12043
12052
  const subject = entry.payload.subject;
12044
12053
  if (subject.systemEvent !== void 0) return subject.systemEvent.bodyKey ?? "";
12045
12054
  if (subject.occupancy !== void 0) return "occupancy.body";
12055
+ if (subject.packagePhase !== void 0) return `package.${subject.packagePhase}.body`;
12046
12056
  if (entry.payload.enhancement === true) return "detection.body.enhanced";
12047
12057
  return entry.recordKind === "track-end" ? "detection.body.trackEnd" : "detection.body";
12048
12058
  }
@@ -12050,6 +12060,8 @@ function bodyKeyFor(entry) {
12050
12060
  function titleKeyFor(entry) {
12051
12061
  const systemEvent = entry.payload.subject.systemEvent;
12052
12062
  if (systemEvent !== void 0) return systemEvent.titleKey ?? "";
12063
+ const packagePhase = entry.payload.subject.packagePhase;
12064
+ if (packagePhase !== void 0) return `package.${packagePhase}.title`;
12053
12065
  return "detection.title";
12054
12066
  }
12055
12067
  /**
@@ -24834,6 +24846,7 @@ var NotificationCenter = class NotificationCenter {
24834
24846
  zones: renderedZones,
24835
24847
  timestamp: subject.timestamp,
24836
24848
  ...subject.occupancy !== void 0 ? { occupancy: frozenOccupancy(rule, subject.occupancy) } : {},
24849
+ ...subject.packagePhase !== void 0 ? { packagePhase: subject.packagePhase } : {},
24837
24850
  ...subject.systemEvent !== void 0 ? { systemEvent: subject.systemEvent } : {}
24838
24851
  }
24839
24852
  };
@@ -28598,6 +28611,37 @@ var MediaStore = class {
28598
28611
  return newKey;
28599
28612
  }
28600
28613
  /**
28614
+ * Exchange the bytes+instant held in the `firstFrame` and `lastFrame` slots
28615
+ * (close-time ordering repair, see `pipeline/first-last-frame-order.ts`).
28616
+ *
28617
+ * Both are single-instance kinds, so each `putReplacing` overwrites the slot
28618
+ * it names — the two rows end up holding each other's blob and timestamp.
28619
+ * Nothing is deleted and nothing is re-encoded: both blobs are already the
28620
+ * track's own stored pixels and the only thing wrong with them is which slot
28621
+ * they sit in. The `lastFrame` write lands first so the "ultimo" tile is
28622
+ * never momentarily the older view a viewer just refuted.
28623
+ */
28624
+ async swapFirstAndLastFrame(input) {
28625
+ await this.gated(async () => {
28626
+ await this.putReplacingUnchecked({
28627
+ deviceId: input.deviceId,
28628
+ ownerKind: "track",
28629
+ ownerId: input.trackId,
28630
+ kind: "lastFrame",
28631
+ timestamp: input.firstFrame.timestamp,
28632
+ data: Buffer.from(input.firstFrame.base64, "base64")
28633
+ });
28634
+ await this.putReplacingUnchecked({
28635
+ deviceId: input.deviceId,
28636
+ ownerKind: "track",
28637
+ ownerId: input.trackId,
28638
+ kind: "firstFrame",
28639
+ timestamp: input.lastFrame.timestamp,
28640
+ data: Buffer.from(input.lastFrame.base64, "base64")
28641
+ });
28642
+ });
28643
+ }
28644
+ /**
28601
28645
  * Fetch one media entry by its key (id). Returns null if the key is not
28602
28646
  * found in the index or if the blob is missing from storage.
28603
28647
  */
@@ -30469,6 +30513,17 @@ function trackDurationMs(facts) {
30469
30513
  function hasKind(media, kind) {
30470
30514
  return media.some((m) => m.kind === kind);
30471
30515
  }
30516
+ /** Newest instant stamped on a row of `kind`, or `null` when the track owns
30517
+ * none. Single-instance kinds hold at most one row; the max guards a legacy
30518
+ * duplicate, mirroring `decideFirstLastFrameOrder`. */
30519
+ function newestOfKind(media, kind) {
30520
+ let newest = null;
30521
+ for (const m of media) {
30522
+ if (m.kind !== kind) continue;
30523
+ if (newest === null || m.timestamp > newest) newest = m.timestamp;
30524
+ }
30525
+ return newest;
30526
+ }
30472
30527
  function latestMediaAt(media) {
30473
30528
  let latest = null;
30474
30529
  for (const m of media) if (latest === null || m.timestamp > latest) latest = m.timestamp;
@@ -30516,6 +30571,12 @@ function analyseDebugTrack(inventory) {
30516
30571
  const lag = facts.lastSeen - lastFrameAt;
30517
30572
  if (lag > 2e3) out.push(finding("LASTFRAME_STALE", "warn", `lastFrame is ${String(lag)} ms older than the track's end.`));
30518
30573
  }
30574
+ const firstFrameAt = newestOfKind(media, "firstFrame");
30575
+ if (firstFrameAt !== null) {
30576
+ const lastFrameAt = newestOfKind(media, "lastFrame");
30577
+ if (lastFrameAt !== null && firstFrameAt > lastFrameAt) out.push(finding("FIRSTFRAME_AFTER_LASTFRAME", "error", `firstFrame is ${String(firstFrameAt - lastFrameAt)} ms NEWER than lastFrame — the "primo" tile post-dates the "ultimo" one. The first view is captured at the CONFIRMED birth, which the confirmation gate can defer by up to its maxDeferralMs, while keyFrame/thumbnail/lastFrame are stamped at the earlier frame they were cut from.`));
30578
+ if (closed && firstFrameAt > facts.lastSeen) out.push(finding("FIRSTFRAME_AFTER_TRACK_END", "error", `firstFrame was captured ${String(firstFrameAt - facts.lastSeen)} ms AFTER the track's last observation — the tile shows a scene the subject had already left (a birth confirmed on a coasted frame).`));
30579
+ }
30519
30580
  if (closed && durationMs >= 1e4 && media.length > 0) {
30520
30581
  const latest = latestMediaAt(media);
30521
30582
  if (latest !== null) {
@@ -32746,9 +32807,14 @@ var FaceStore = class {
32746
32807
  * with that person named. High means "this looks like someone we can name",
32747
32808
  * which is what a mis-enrolment actually looks like.
32748
32809
  *
32749
- * `suspicion` combines them so a sample that looks like somebody else outranks
32750
- * one that merely looks like nobody: the first is a fixable error, the second
32751
- * is often just a difficult photograph.
32810
+ * `suspicion` is the EXCESS `bestForeign selfMean` because that is the
32811
+ * only one of the two that discriminates on real data. Measured on Roberta's
32812
+ * 106 samples, 2026-09-08: `selfMean` has a median of 0.188 and a p90 of 0.235,
32813
+ * so ANY absolute reading of "does not look like itself" fires on the whole
32814
+ * gallery. The first version of this module scored `(1 − selfMean) / 2` and
32815
+ * flagged 56 of 106 — a finding that fires on healthy samples means nothing.
32816
+ * `bestForeign` on the same gallery: median 0.283, p95 0.376, and one single
32817
+ * outlier at 0.612, which is the mis-enrolment.
32752
32818
  *
32753
32819
  * ## What it refuses to do
32754
32820
  *
@@ -32763,8 +32829,20 @@ var FaceStore = class {
32763
32829
  *
32764
32830
  * Pure — the caller loads the vectors and performs the removal.
32765
32831
  */
32766
- /** Above this a row is worth the operator's attention. */
32767
- var SUSPICION_FLAG = .5;
32832
+ /**
32833
+ * A row is FLAGGED when its best foreign match would auto-assign the face to
32834
+ * that other person — i.e. it reaches the recogniser's own
32835
+ * `similarityThreshold`.
32836
+ *
32837
+ * Tied to the live threshold rather than to a constant of its own, because
32838
+ * that is what the flag MEANS: this sample can steal a match. A second number
32839
+ * invented here would drift from the one the recogniser actually uses, and the
32840
+ * panel would accuse samples that cannot hurt anyone while staying quiet about
32841
+ * ones that can. On the live gallery (threshold 0.40) it flags 2 of 106.
32842
+ */
32843
+ function flaggedCount(rows, autoAssignThreshold) {
32844
+ return rows.filter((row) => (row.bestForeign?.score ?? 0) >= autoAssignThreshold).length;
32845
+ }
32768
32846
  /**
32769
32847
  * How far a foreign match has to stand OUT to count as looking like someone
32770
32848
  * else: `bestForeign - selfMean`. A sample that scores 0.6 against a stranger
@@ -32819,8 +32897,7 @@ function auditIdentitySamples(input) {
32819
32897
  };
32820
32898
  const selfMean = meanCosine(sample, comparable);
32821
32899
  const foreign = bestForeignMatch(sample, input.others, input.modelId);
32822
- const excess = foreignExcess(selfMean, foreign);
32823
- const suspicion = selfMean === null ? 0 : (1 - selfMean) / 2 + excess;
32900
+ const suspicion = foreignExcess(selfMean, foreign);
32824
32901
  return {
32825
32902
  sampleId: sample.sampleId,
32826
32903
  enrolledAt: sample.enrolledAt,
@@ -32831,12 +32908,15 @@ function auditIdentitySamples(input) {
32831
32908
  suspicion
32832
32909
  };
32833
32910
  });
32834
- rows.sort((a, b) => b.suspicion - a.suspicion);
32911
+ rows.sort((a, b) => {
32912
+ if (b.suspicion !== a.suspicion) return b.suspicion - a.suspicion;
32913
+ return (a.selfMean ?? Number.POSITIVE_INFINITY) - (b.selfMean ?? Number.POSITIVE_INFINITY);
32914
+ });
32835
32915
  return {
32836
32916
  identityId: input.identityId,
32837
32917
  totalSamples: input.samples.length,
32838
32918
  comparableSamples: comparable.length,
32839
- flagged: rows.filter((r) => r.suspicion >= SUSPICION_FLAG).length,
32919
+ flagged: flaggedCount(rows, input.autoAssignThreshold),
32840
32920
  rows
32841
32921
  };
32842
32922
  }
@@ -32893,6 +32973,7 @@ var FaceGalleryProvider = class {
32893
32973
  logger;
32894
32974
  refreshGallery;
32895
32975
  resolveClusterModelId;
32976
+ resolveAutoAssignThreshold;
32896
32977
  eventMediaBaseUrl;
32897
32978
  eventMediaPathPrefix;
32898
32979
  emitFaceGalleryChanged;
@@ -32905,6 +32986,7 @@ var FaceGalleryProvider = class {
32905
32986
  this.logger = deps.logger;
32906
32987
  this.refreshGallery = deps.refreshGallery;
32907
32988
  this.resolveClusterModelId = deps.resolveClusterModelId;
32989
+ this.resolveAutoAssignThreshold = deps.resolveAutoAssignThreshold;
32908
32990
  this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
32909
32991
  this.eventMediaPathPrefix = deps.eventMediaPathPrefix;
32910
32992
  this.emitFaceGalleryChanged = deps.emitFaceGalleryChanged;
@@ -32996,6 +33078,7 @@ var FaceGalleryProvider = class {
32996
33078
  async auditIdentitySamples(input) {
32997
33079
  const modelId = await this.resolveClusterModelId();
32998
33080
  if (modelId === null) throw new Error("auditIdentitySamples: the cluster face-embedding model could not be resolved");
33081
+ const autoAssignThreshold = await this.resolveAutoAssignThreshold();
32999
33082
  const identities = await this.identityStore.listIdentities();
33000
33083
  const target = identities.find((i) => i.id === input.identityId);
33001
33084
  if (target === void 0) throw new Error(`auditIdentitySamples: no identity ${input.identityId}`);
@@ -33021,7 +33104,8 @@ var FaceGalleryProvider = class {
33021
33104
  name: identity.name,
33022
33105
  samples: inputsFor(identity.id)
33023
33106
  })),
33024
- modelId
33107
+ modelId,
33108
+ autoAssignThreshold
33025
33109
  });
33026
33110
  const meta = new Map((samplesByIdentity.get(input.identityId) ?? []).map((sample) => [sample.id, sample]));
33027
33111
  return {
@@ -33031,7 +33115,7 @@ var FaceGalleryProvider = class {
33031
33115
  totalSamples: report.totalSamples,
33032
33116
  comparableSamples: report.comparableSamples,
33033
33117
  flagged: report.flagged,
33034
- flagThreshold: SUSPICION_FLAG,
33118
+ flagThreshold: autoAssignThreshold,
33035
33119
  rows: report.rows.map((row) => {
33036
33120
  const sample = meta.get(row.sampleId);
33037
33121
  return {
@@ -34090,6 +34174,29 @@ function firstFrameRetryBbox(current, birth) {
34090
34174
  return { ...current };
34091
34175
  }
34092
34176
  //#endregion
34177
+ //#region src/pipeline-analytics/pipeline/birth-first-frame-gate.ts
34178
+ /**
34179
+ * Decide whether the confirmed-birth frame may be dispatched as this track's
34180
+ * `firstFrame` capture. Pure; the caller logs the refusal and keeps the
34181
+ * `firstFramePending` mark so a later frame retries.
34182
+ *
34183
+ * `coasted-frame` is reported ahead of `no-frame-handle` when both hold: a
34184
+ * handle-less coasted frame would have been refused for BEING coasted whatever
34185
+ * the decode plane did, and naming the decode is what would send the next
34186
+ * investigation at the wrong subsystem.
34187
+ */
34188
+ function decideBirthFirstFrameCapture(input) {
34189
+ if (input.matchedThisFrame === false) return {
34190
+ capture: false,
34191
+ refusedBecause: "coasted-frame"
34192
+ };
34193
+ if (!input.hasFrameHandle) return {
34194
+ capture: false,
34195
+ refusedBecause: "no-frame-handle"
34196
+ };
34197
+ return { capture: true };
34198
+ }
34199
+ //#endregion
34093
34200
  //#region src/pipeline-analytics/pipeline/events/event-filter.ts
34094
34201
  var DEFAULT_EVENT_EMITTER_CONFIG = {
34095
34202
  minTrackAge: 3,
@@ -34698,7 +34805,7 @@ var DEFAULT_TRACKER_CONFIG = {
34698
34805
  var PERSON_CLASS = "person";
34699
34806
  var ANIMAL_CLASS = "animal";
34700
34807
  var MAX_PATH_LENGTH = 300;
34701
- function clamp(value, min, max) {
34808
+ function clamp$1(value, min, max) {
34702
34809
  return Math.max(min, Math.min(max, value));
34703
34810
  }
34704
34811
  function iou$3(a, b) {
@@ -35138,8 +35245,8 @@ var SortTracker = class SortTracker {
35138
35245
  const perFrameX = t.velocity.dx * t.emitIntervalMs;
35139
35246
  const perFrameY = t.velocity.dy * t.emitIntervalMs;
35140
35247
  return {
35141
- x: t.bbox.x + clamp(perFrameX * f, -t.bbox.w, t.bbox.w),
35142
- y: t.bbox.y + clamp(perFrameY * f, -t.bbox.h, t.bbox.h),
35248
+ x: t.bbox.x + clamp$1(perFrameX * f, -t.bbox.w, t.bbox.w),
35249
+ y: t.bbox.y + clamp$1(perFrameY * f, -t.bbox.h, t.bbox.h),
35143
35250
  w: t.bbox.w,
35144
35251
  h: t.bbox.h
35145
35252
  };
@@ -35148,8 +35255,8 @@ var SortTracker = class SortTracker {
35148
35255
  const allowX = driftAllowancePx(t.bbox.w, elapsedMs);
35149
35256
  const allowY = driftAllowancePx(t.bbox.h, elapsedMs);
35150
35257
  return {
35151
- x: t.bbox.x + clamp(t.velocity.dx * elapsedMs, -allowX, allowX),
35152
- y: t.bbox.y + clamp(t.velocity.dy * elapsedMs, -allowY, allowY),
35258
+ x: t.bbox.x + clamp$1(t.velocity.dx * elapsedMs, -allowX, allowX),
35259
+ y: t.bbox.y + clamp$1(t.velocity.dy * elapsedMs, -allowY, allowY),
35153
35260
  w: t.bbox.w,
35154
35261
  h: t.bbox.h
35155
35262
  };
@@ -37226,6 +37333,44 @@ function decideLastFramePromotion(media) {
37226
37333
  };
37227
37334
  }
37228
37335
  //#endregion
37336
+ //#region src/pipeline-analytics/pipeline/first-last-frame-order.ts
37337
+ /**
37338
+ * Decide whether a closing track's `firstFrame` and `lastFrame` slots hold each
37339
+ * other's bytes. Pure — see the module header for the contract.
37340
+ *
37341
+ * Swap when BOTH slots are filled and the `firstFrame` is STRICTLY newer than
37342
+ * the `lastFrame`. Equal timestamps are not an inversion.
37343
+ */
37344
+ function decideFirstLastFrameOrder(media) {
37345
+ let firstFrame;
37346
+ let lastFrame;
37347
+ for (const m of media) if (m.kind === "firstFrame") {
37348
+ if (firstFrame === void 0 || m.timestamp > firstFrame.timestamp) firstFrame = m;
37349
+ } else if (m.kind === "lastFrame") {
37350
+ if (lastFrame === void 0 || m.timestamp > lastFrame.timestamp) lastFrame = m;
37351
+ }
37352
+ if (firstFrame === void 0) return {
37353
+ swap: false,
37354
+ skippedBecause: "no-first-frame"
37355
+ };
37356
+ if (lastFrame === void 0) return {
37357
+ swap: false,
37358
+ skippedBecause: "no-last-frame"
37359
+ };
37360
+ if (firstFrame.timestamp <= lastFrame.timestamp) return {
37361
+ swap: false,
37362
+ skippedBecause: "ordered"
37363
+ };
37364
+ return {
37365
+ swap: true,
37366
+ firstFrameKey: firstFrame.key,
37367
+ lastFrameKey: lastFrame.key,
37368
+ firstFrameTimestamp: firstFrame.timestamp,
37369
+ lastFrameTimestamp: lastFrame.timestamp,
37370
+ inversionMs: firstFrame.timestamp - lastFrame.timestamp
37371
+ };
37372
+ }
37373
+ //#endregion
37229
37374
  //#region src/pipeline-analytics/pipeline/static-track-gate.ts
37230
37375
  /**
37231
37376
  * Net displacement + path span for a track's centroid path, normalized to
@@ -37368,6 +37513,7 @@ var TrackCloser = class {
37368
37513
  tile = "raster";
37369
37514
  }
37370
37515
  await this.maybePromoteLastFrame(t, ownedMedia);
37516
+ await this.repairFirstLastFrameOrder(t);
37371
37517
  const hasRasterFallback = closure?.rasterFallback !== void 0;
37372
37518
  let deriveMiss;
37373
37519
  if (!thumbnailLanded) {
@@ -37711,6 +37857,56 @@ var TrackCloser = class {
37711
37857
  }
37712
37858
  }
37713
37859
  /**
37860
+ * A track's "primo" tile is never newer than its "ultimo" tile
37861
+ * (`first-last-frame-order.ts`). Filed by hand three times on device 3829,
37862
+ * 2026-09-08 — `d035f251`, `448678bb`, `7628b19d`.
37863
+ *
37864
+ * The media list is RE-READ rather than reusing the caller's `ownedMedia`:
37865
+ * `maybePromoteLastFrame` has just run and is precisely what may have put the
37866
+ * older bytes in the `lastFrame` slot, so the caller's copy predates the pair
37867
+ * being ordered here. Best-effort — a failure never blocks the close.
37868
+ */
37869
+ async repairFirstLastFrameOrder(t) {
37870
+ const store = this.deps.mediaStore();
37871
+ if (!store) return;
37872
+ const media = await store.listByOwner("track", t.trackId);
37873
+ const decision = decideFirstLastFrameOrder(media);
37874
+ if (!decision.swap) return;
37875
+ const firstFrame = media.find((m) => m.key === decision.firstFrameKey);
37876
+ const lastFrame = media.find((m) => m.key === decision.lastFrameKey);
37877
+ if (!firstFrame || !lastFrame) return;
37878
+ try {
37879
+ await store.swapFirstAndLastFrame({
37880
+ deviceId: t.deviceId,
37881
+ trackId: t.trackId,
37882
+ firstFrame,
37883
+ lastFrame
37884
+ });
37885
+ this.deps.logger.warn("firstFrame/lastFrame were out of order — slots swapped", {
37886
+ tags: { deviceId: t.deviceId },
37887
+ meta: {
37888
+ trackId: t.trackId,
37889
+ className: t.className,
37890
+ inversionMs: decision.inversionMs,
37891
+ firstFrameAt: decision.firstFrameTimestamp,
37892
+ lastFrameAt: decision.lastFrameTimestamp,
37893
+ /** How far the late capture ran past the track itself. Positive means
37894
+ * it was cut from a frame the subject was no longer observed in. */
37895
+ pastTrackEndMs: decision.firstFrameTimestamp - t.lastSeen,
37896
+ durationMs: t.lastSeen - t.firstSeen
37897
+ }
37898
+ });
37899
+ } catch (err) {
37900
+ this.deps.logger.warn("firstFrame/lastFrame order repair failed", {
37901
+ tags: { deviceId: t.deviceId },
37902
+ meta: {
37903
+ trackId: t.trackId,
37904
+ error: String(err)
37905
+ }
37906
+ });
37907
+ }
37908
+ }
37909
+ /**
37714
37910
  * Key-event importance: score the just-expired track and persist. Peak
37715
37911
  * confidence comes from the shared best-detection tracker; peak bbox area
37716
37912
  * + the winning object-event id from one indexed queryObject(trackId) —
@@ -41526,7 +41722,10 @@ var TrackStore = class {
41526
41722
  retrainStatus: active.retrainStatus ?? "none",
41527
41723
  debug: active.debug === true,
41528
41724
  debugNote: active.debugNote ?? "",
41529
- favourited: active.favourited === true
41725
+ favourited: active.favourited === true,
41726
+ startedAt: active.firstSeen,
41727
+ className: active.className,
41728
+ ...displayLabel(active) !== void 0 ? { label: displayLabel(active) } : {}
41530
41729
  };
41531
41730
  const persisted = await this.getPersistedByTrackId(trackId);
41532
41731
  if (!persisted) return null;
@@ -41534,7 +41733,10 @@ var TrackStore = class {
41534
41733
  retrainStatus: persisted.retrainStatus ?? "none",
41535
41734
  debug: persisted.debug === true,
41536
41735
  debugNote: persisted.debugNote ?? "",
41537
- favourited: persisted.favourited === true
41736
+ favourited: persisted.favourited === true,
41737
+ startedAt: persisted.firstSeen,
41738
+ className: persisted.className,
41739
+ ...displayLabel(persisted) !== void 0 ? { label: displayLabel(persisted) } : {}
41538
41740
  };
41539
41741
  }
41540
41742
  /**
@@ -54913,7 +55115,7 @@ var PackageDropDetector = class {
54913
55115
  async onStationaryChange(change) {
54914
55116
  try {
54915
55117
  if (change.phase === "appeared") await this.onAppeared(change.entry, change.timestamp);
54916
- else await this.onDeparted(change.entry, change.timestamp);
55118
+ else await this.onDeparted(change);
54917
55119
  } catch (err) {
54918
55120
  this.deps.onError?.("onStationaryChange", err);
54919
55121
  this.deps.logger.warn("package-drop detector failed on change", {
@@ -55091,7 +55293,9 @@ var PackageDropDetector = class {
55091
55293
  }
55092
55294
  });
55093
55295
  }
55094
- async onDeparted(entry, timestamp) {
55296
+ async onDeparted(change) {
55297
+ const entry = change.entry;
55298
+ const timestamp = change.timestamp;
55095
55299
  if (!(await this.deps.resolveSettings(entry.deviceId)).packageDropPickupEnabled) {
55096
55300
  this.deps.logger.debug("package pick-up skipped — packageDropPickupEnabled is false", {
55097
55301
  tags: { deviceId: entry.deviceId },
@@ -55126,13 +55330,25 @@ var PackageDropDetector = class {
55126
55330
  });
55127
55331
  return;
55128
55332
  }
55333
+ const collector = change.collector;
55334
+ const packageTrackId = entry.sourceTrackId ?? entry.id;
55335
+ const mediaTrackId = collector?.trackId ?? packageTrackId;
55336
+ if (collector === void 0) this.deps.logger.info("package pick-up has no collector track — the notification carries the parcel frame", {
55337
+ tags: { deviceId: entry.deviceId },
55338
+ meta: {
55339
+ entryId: entry.id,
55340
+ eventId: pickedUpId,
55341
+ packageTrackId,
55342
+ pickupAt: entry.lastConfirmedAt
55343
+ }
55344
+ });
55129
55345
  const ev = {
55130
55346
  id: pickedUpId,
55131
55347
  kind: "object",
55132
55348
  deviceId: entry.deviceId,
55133
55349
  timestamp,
55134
55350
  source: "pipeline",
55135
- trackId: entry.sourceTrackId ?? entry.id,
55351
+ trackId: mediaTrackId,
55136
55352
  className: PACKAGE_EVENT_CLASS,
55137
55353
  ...entry.label !== void 0 ? { label: entry.label } : {},
55138
55354
  confidence: PACKAGE_IMPORTANCE,
@@ -55146,6 +55362,7 @@ var PackageDropDetector = class {
55146
55362
  state: PICKED_UP_STATE,
55147
55363
  frameWidth: entry.frameWidth,
55148
55364
  frameHeight: entry.frameHeight,
55365
+ ...collector?.keyFrameMediaKey !== void 0 ? { mediaKey: collector.keyFrameMediaKey } : {},
55149
55366
  importance: PACKAGE_IMPORTANCE
55150
55367
  };
55151
55368
  await this.deps.events.insertObject(ev);
@@ -55155,6 +55372,7 @@ var PackageDropDetector = class {
55155
55372
  entryId: entry.id,
55156
55373
  deliveredEventId: deliveredId,
55157
55374
  className: entry.className,
55375
+ ...collector !== void 0 ? { collectorTrackId: collector.trackId } : {},
55158
55376
  timestamp
55159
55377
  });
55160
55378
  this.deps.logger.info("package picked up", {
@@ -55162,11 +55380,139 @@ var PackageDropDetector = class {
55162
55380
  meta: {
55163
55381
  entryId: entry.id,
55164
55382
  eventId: pickedUpId,
55165
- deliveredEventId: deliveredId
55383
+ deliveredEventId: deliveredId,
55384
+ mediaTrackId,
55385
+ ...collector !== void 0 ? {
55386
+ collectorTrackId: collector.trackId,
55387
+ collectorClassName: collector.className,
55388
+ collectorSkewMs: collector.skewMs
55389
+ } : { collectorTrackId: null }
55166
55390
  }
55167
55391
  });
55168
55392
  }
55169
55393
  };
55394
+ /**
55395
+ * How long a presence survives in the mirror, in FRAME time.
55396
+ *
55397
+ * It must outlive the whole detection latency: a pick-up at T is concluded no
55398
+ * earlier than T + `PACKAGE_STILLNESS_TTL_MS` (5 min of observed time), and the
55399
+ * pick is made at conclusion time. 10 minutes is double that, so a collector
55400
+ * noted at the true instant is still there to be named — and a mirror that
55401
+ * expires the answer before the question is asked would be the same silent
55402
+ * hole in a different place.
55403
+ */
55404
+ var PACKAGE_COLLECTOR_RETENTION_MS = 10 * 6e4;
55405
+ /** The pick-up instant a stationary entry implies. One derivation, named. */
55406
+ function pickupInstantOf(entry) {
55407
+ return entry.lastConfirmedAt;
55408
+ }
55409
+ var PackageCollectorMirror = class {
55410
+ /** Per device, per track. Every write replaces the record (immutable). */
55411
+ byDevice = /* @__PURE__ */ new Map();
55412
+ /**
55413
+ * Record one sighting. Extends the track's presence span and refreshes its
55414
+ * key-frame handle (a re-shot key frame is a better one, and the newest is
55415
+ * also the nearest to the pick-up — the same trade as above).
55416
+ */
55417
+ note(obs) {
55418
+ const current = this.byDevice.get(obs.deviceId);
55419
+ const next = /* @__PURE__ */ new Map();
55420
+ const horizon = obs.at - PACKAGE_COLLECTOR_RETENTION_MS;
55421
+ if (current !== void 0) for (const [id, p] of current) {
55422
+ if (p.lastAt < horizon) continue;
55423
+ next.set(id, p);
55424
+ }
55425
+ const prev = next.get(obs.trackId);
55426
+ next.set(obs.trackId, {
55427
+ trackId: obs.trackId,
55428
+ className: obs.className,
55429
+ firstAt: prev === void 0 ? obs.at : Math.min(prev.firstAt, obs.at),
55430
+ lastAt: prev === void 0 ? obs.at : Math.max(prev.lastAt, obs.at),
55431
+ ...obs.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: obs.keyFrameMediaKey } : prev?.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: prev.keyFrameMediaKey } : {}
55432
+ });
55433
+ this.byDevice.set(obs.deviceId, evictOldest(next));
55434
+ }
55435
+ /**
55436
+ * The track nearest the pick-up instant, or `null` when nobody qualifies.
55437
+ *
55438
+ * `null` is a real answer and not an error: a parcel can stop being seen
55439
+ * because the light changed or the detector lost it, and naming a person who
55440
+ * was not there would be worse than naming nobody. The caller degrades to the
55441
+ * parcel's own owner and logs it.
55442
+ */
55443
+ pickCollector(input) {
55444
+ const presences = this.byDevice.get(input.deviceId);
55445
+ if (presences === void 0 || presences.size === 0) return null;
55446
+ const maxSkewMs = input.maxSkewMs ?? 3e4;
55447
+ let best = null;
55448
+ let bestLastAt = Number.NEGATIVE_INFINITY;
55449
+ for (const p of presences.values()) {
55450
+ const observedAt = clamp(input.pickupAt, p.firstAt, p.lastAt);
55451
+ const skewMs = Math.abs(observedAt - input.pickupAt);
55452
+ if (skewMs > maxSkewMs) continue;
55453
+ if (best !== null && (skewMs > best.skewMs || skewMs === best.skewMs && p.lastAt <= bestLastAt)) continue;
55454
+ best = {
55455
+ trackId: p.trackId,
55456
+ className: p.className,
55457
+ observedAt,
55458
+ skewMs,
55459
+ ...p.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: p.keyFrameMediaKey } : {}
55460
+ };
55461
+ bestLastAt = p.lastAt;
55462
+ }
55463
+ return best;
55464
+ }
55465
+ /**
55466
+ * Fold one pipeline frame in. Returns how many presences it touched, so the
55467
+ * caller can say "nobody was ever in the drop-off zone" with a number.
55468
+ *
55469
+ * This is the ONLY place the frame-shaped input is interpreted, so the
55470
+ * production path and the test path read the same rules: a non-package class,
55471
+ * inside a configured drop-off zone, on this frame.
55472
+ */
55473
+ noteFrame(input) {
55474
+ if (input.packageZoneIds.size === 0) return 0;
55475
+ let noted = 0;
55476
+ for (const t of input.tracked) {
55477
+ if (input.packageClasses.has(t.className)) continue;
55478
+ if (!isDelivererInPackageZone(t.zones, input.packageZoneIds)) continue;
55479
+ const keyFrameMediaKey = input.keyFrameMediaKeyFor(t.trackId);
55480
+ this.note({
55481
+ deviceId: input.deviceId,
55482
+ trackId: t.trackId,
55483
+ className: t.className,
55484
+ at: input.timestamp,
55485
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
55486
+ });
55487
+ noted += 1;
55488
+ }
55489
+ return noted;
55490
+ }
55491
+ /** Drop a camera's memory (unbind / step disabled / device removal). */
55492
+ forgetDevice(deviceId) {
55493
+ this.byDevice.delete(deviceId);
55494
+ }
55495
+ reset() {
55496
+ this.byDevice.clear();
55497
+ }
55498
+ /** Retained presences for a camera — diagnostics and tests only. */
55499
+ debugPresenceCount(deviceId) {
55500
+ return this.byDevice.get(deviceId)?.size ?? 0;
55501
+ }
55502
+ };
55503
+ function clamp(value, low, high) {
55504
+ if (value < low) return low;
55505
+ if (value > high) return high;
55506
+ return value;
55507
+ }
55508
+ /** Keep the newest {@link PACKAGE_COLLECTOR_MAX_PRESENCES} by `lastAt`. */
55509
+ function evictOldest(presences) {
55510
+ if (presences.size <= 32) return presences;
55511
+ const ordered = [...presences.values()].sort((a, b) => b.lastAt - a.lastAt);
55512
+ const kept = /* @__PURE__ */ new Map();
55513
+ for (const p of ordered.slice(0, 32)) kept.set(p.trackId, p);
55514
+ return kept;
55515
+ }
55170
55516
  //#endregion
55171
55517
  //#region src/pipeline-analytics/pipeline/person-over-vehicle.ts
55172
55518
  /**
@@ -56642,6 +56988,61 @@ function classifyTrackAppearance(input) {
56642
56988
  if (input.inPrevActive) return "continuing";
56643
56989
  return input.positionsCount > 1 ? "resurrection" : "birth";
56644
56990
  }
56991
+ //#endregion
56992
+ //#region src/pipeline-analytics/pipeline/archive-debug-note.ts
56993
+ /**
56994
+ * Archive the note a clear is about to destroy. Never throws.
56995
+ *
56996
+ * **Which way this fails, stated once.** The operator pressed a flag button;
56997
+ * the archive is this system's own bookkeeping. So a failing archive does NOT
56998
+ * block the flag write — the button must do what it says even when SQLite is
56999
+ * busy — and it does NOT retry, because the caller is a synchronous cap method
57000
+ * an operator is waiting on.
57001
+ *
57002
+ * What it must never do is lose the sentence quietly. The failure line carries
57003
+ * the NOTE TEXT verbatim, plus `tags: { deviceId }` and the trackId, so the
57004
+ * words are recoverable from the log by hand and the loss is attributable to
57005
+ * one camera. A branch that drops work silently reads as "never happened", and
57006
+ * the 3-hour media blackout produced not one line.
57007
+ *
57008
+ * An EMPTY note archives nothing: there is nothing to keep, and a row of `''`
57009
+ * would be a permanent entry in a corpus meant to be mined for sentences.
57010
+ */
57011
+ async function archiveNoteBeforeClear(input) {
57012
+ const note = input.current.debugNote;
57013
+ if (note.trim() === "") return;
57014
+ if (input.archive === void 0) {
57015
+ input.logger.warn("debug note dropped — no archive is wired on this runner", {
57016
+ tags: { deviceId: input.deviceId },
57017
+ meta: {
57018
+ deviceId: input.deviceId,
57019
+ trackId: input.trackId,
57020
+ note
57021
+ }
57022
+ });
57023
+ return;
57024
+ }
57025
+ try {
57026
+ await input.archive.append({
57027
+ deviceId: input.deviceId,
57028
+ sourceTrackId: input.trackId,
57029
+ note,
57030
+ ...input.current.startedAt !== void 0 ? { trackStartedAt: input.current.startedAt } : {},
57031
+ ...input.current.className !== void 0 ? { trackClass: input.current.className } : {},
57032
+ ...input.current.label !== void 0 ? { trackLabel: input.current.label } : {}
57033
+ });
57034
+ } catch (err) {
57035
+ input.logger.error("failed to archive a debug note — the text is in this line", {
57036
+ tags: { deviceId: input.deviceId },
57037
+ meta: {
57038
+ deviceId: input.deviceId,
57039
+ trackId: input.trackId,
57040
+ note,
57041
+ error: err instanceof Error ? err.message : String(err)
57042
+ }
57043
+ });
57044
+ }
57045
+ }
56645
57046
  /** Thrown when the device is already holding its full staging budget. Distinct
56646
57047
  * type so a surface can tell "you are out of room" from "that track is gone". */
56647
57048
  var StagingBudgetExceededError = class extends Error {
@@ -56682,7 +57083,10 @@ var TrackAlreadyTrainedError = class extends Error {
56682
57083
  * caller sent with it. A note surviving the flag is a leftover that reads
56683
57084
  * as a live request, and the invariant belongs here rather than in the
56684
57085
  * three surfaces that write this patch — one of which is a browser grid
56685
- * that has no note UI at all and would never think to clear it.
57086
+ * that has no note UI at all and would never think to clear it. Since
57087
+ * D405 the clear is not a DELETE: `applyTrackFlags` ARCHIVES the note
57088
+ * first, into a table that outlives the track. The invariant above is
57089
+ * unchanged — only where the words go.
56686
57090
  * 2. **No note in the patch → nothing is written.** This is what makes a
56687
57091
  * cancelled prompt free: the app turns debug on, the operator declines to
56688
57092
  * type, and the flag still lands. Refusing to write a note must never cost
@@ -56776,6 +57180,13 @@ async function applyTrackFlags(deps, input) {
56776
57180
  ...input.flags.favourited !== void 0 ? { favourited: input.flags.favourited } : {},
56777
57181
  ...resolveNotePatch(deps, input, current)
56778
57182
  };
57183
+ if (patch.debugNote === "" && current.debugNote !== "") await archiveNoteBeforeClear({
57184
+ deviceId: input.deviceId,
57185
+ trackId: input.trackId,
57186
+ logger,
57187
+ archive: deps.noteArchive,
57188
+ current
57189
+ });
56779
57190
  if (Object.keys(patch).length > 0) await store.setFlags(input.trackId, patch);
56780
57191
  const retrainStatus = patch.markForTrain === void 0 ? current.retrainStatus : patch.markForTrain ? "staging" : "none";
56781
57192
  return {
@@ -57858,6 +58269,9 @@ async function reconcileGallerySamples(deps, options) {
57858
58269
  let keyless = 0;
57859
58270
  let skippedGrace = 0;
57860
58271
  let stoppedByCeiling = false;
58272
+ let missed = 0;
58273
+ /** Verdicts held until there are enough of them to tell rot from a move. */
58274
+ const held = [];
57861
58275
  const identities = await deps.listIdentities();
57862
58276
  for (const identity of identities) {
57863
58277
  const samples = await deps.listSamples(identity.id);
@@ -57902,6 +58316,43 @@ async function reconcileGallerySamples(deps, options) {
57902
58316
  }
57903
58317
  checked += 1;
57904
58318
  if (state === "exists") continue;
58319
+ missed += 1;
58320
+ const minProbes = options.massMissMinProbes ?? 8;
58321
+ const bar = options.massMissFraction ?? .5;
58322
+ if (checked < minProbes) {
58323
+ held.push({
58324
+ identityId: identity.id,
58325
+ sampleId: sample.id,
58326
+ mediaKey: sample.mediaKey
58327
+ });
58328
+ continue;
58329
+ }
58330
+ if (missed / checked >= bar) {
58331
+ const reason = `mass miss: ${String(missed)} of ${String(checked)} probes found no blob`;
58332
+ deps.logger.warn("gallery reconcile: nearly every blob is missing — that is the volume, not the samples. Aborting and keeping every row; check where the gallery location points.", { meta: {
58333
+ checked,
58334
+ missed,
58335
+ held: held.length,
58336
+ identity: identity.name
58337
+ } });
58338
+ return {
58339
+ checked,
58340
+ reclaimed,
58341
+ keyless,
58342
+ skippedGrace,
58343
+ stoppedByCeiling,
58344
+ aborted: reason
58345
+ };
58346
+ }
58347
+ for (const pending of held.splice(0, held.length)) {
58348
+ await deps.removeSample(pending.identityId, pending.sampleId);
58349
+ reclaimed += 1;
58350
+ deps.logger.warn("gallery reconcile: sample blob is gone from the filesystem — reference removed", { meta: {
58351
+ sampleId: pending.sampleId,
58352
+ mediaKey: pending.mediaKey,
58353
+ state: "missing-blob"
58354
+ } });
58355
+ }
57905
58356
  await deps.removeSample(identity.id, sample.id);
57906
58357
  reclaimed += 1;
57907
58358
  deps.logger.warn("gallery reconcile: sample blob is gone from the filesystem — reference removed", { meta: {
@@ -57912,6 +58363,14 @@ async function reconcileGallerySamples(deps, options) {
57912
58363
  } });
57913
58364
  }
57914
58365
  }
58366
+ for (const pending of held.splice(0, held.length)) {
58367
+ if (reclaimed >= options.maxReclaimPerRun) {
58368
+ stoppedByCeiling = true;
58369
+ break;
58370
+ }
58371
+ await deps.removeSample(pending.identityId, pending.sampleId);
58372
+ reclaimed += 1;
58373
+ }
57915
58374
  if (reclaimed > 0) deps.logger.info("gallery reconcile: dead references removed", { meta: {
57916
58375
  checked,
57917
58376
  reclaimed,
@@ -59409,6 +59868,261 @@ var IdentityStore = class {
59409
59868
  }
59410
59869
  };
59411
59870
  //#endregion
59871
+ //#region src/pipeline-analytics/store/debug-note-archive-store.ts
59872
+ /**
59873
+ * DebugNoteArchiveStore — the durable corpus of what operators asked to be
59874
+ * checked (D405).
59875
+ *
59876
+ * A declared SQL-backed collection, like the events ops-log next door, and for
59877
+ * the same reason: pipeline-analytics already owns SQLite collections, and an
59878
+ * undeclared collection crash-loops the runner. MUST be declared in
59879
+ * `onInitialize` before the first write.
59880
+ *
59881
+ * Collection name: pipeline-analytics:debug-note-archive
59882
+ *
59883
+ * Two properties separate it from every other table in this addon:
59884
+ *
59885
+ * - **It is not track-owned.** It names a track in `sourceTrackId` and that is
59886
+ * PROVENANCE, never ownership — the whole point is that the row outlives the
59887
+ * track, which `debug` deliberately never pinned (D353). It is classified
59888
+ * `never-orphaned` by hand in `collection-classification.ts` so the
59889
+ * ownership derivation cannot see the column and cascade it away.
59890
+ * - **It is bounded by COUNT, not by age.** An age sweep would delete the
59891
+ * corpus for being old, which is the failure this table exists to fix: a
59892
+ * six-month-old note is the most valuable row in it, because the pattern it
59893
+ * describes has had time to repeat. See {@link MAX_ARCHIVED_DEBUG_NOTES}.
59894
+ */
59895
+ /**
59896
+ * @durable class=ledger owner=pipeline-analytics
59897
+ * write="one row per debug note RETIRED by a review — appended by
59898
+ * `applyTrackFlags` immediately before `debug: false` clears the note off
59899
+ * the track row (D405). Idempotent per (sourceTrackId, note): the row id is
59900
+ * derived from the pair, so reviewing the same track twice cannot double
59901
+ * the corpus. An empty note writes nothing."
59902
+ * retention="NOT track retention and NOT an age clock — deleting a note for
59903
+ * being old is the failure this table fixes. Bounded by ROW COUNT
59904
+ * (MAX_ARCHIVED_DEBUG_NOTES, 5000, fleet-wide), oldest `archivedAt` first,
59905
+ * trimmed on append. ~3 MB of text at the ceiling."
59906
+ */
59907
+ var DEBUG_NOTE_ARCHIVE_COLLECTION = "pipeline-analytics:debug-note-archive";
59908
+ var DEBUG_NOTE_ARCHIVE_COLUMNS = [
59909
+ {
59910
+ name: "id",
59911
+ type: "TEXT",
59912
+ primaryKey: true,
59913
+ notNull: true
59914
+ },
59915
+ {
59916
+ name: "archivedAt",
59917
+ type: "INTEGER",
59918
+ notNull: true
59919
+ },
59920
+ {
59921
+ name: "deviceId",
59922
+ type: "INTEGER",
59923
+ notNull: true
59924
+ },
59925
+ {
59926
+ name: "sourceTrackId",
59927
+ type: "TEXT",
59928
+ notNull: true
59929
+ },
59930
+ {
59931
+ name: "note",
59932
+ type: "TEXT",
59933
+ notNull: true
59934
+ },
59935
+ {
59936
+ name: "trackStartedAt",
59937
+ type: "INTEGER"
59938
+ },
59939
+ {
59940
+ name: "trackClass",
59941
+ type: "TEXT"
59942
+ },
59943
+ {
59944
+ name: "trackLabel",
59945
+ type: "TEXT"
59946
+ }
59947
+ ];
59948
+ var DEBUG_NOTE_ARCHIVE_INDEXES = [{
59949
+ name: "idx_debugnote_archived_at",
59950
+ columns: ["archivedAt"]
59951
+ }, {
59952
+ name: "idx_debugnote_device_at",
59953
+ columns: ["deviceId", "archivedAt"]
59954
+ }];
59955
+ /**
59956
+ * The row id for one `(track, note)` pair.
59957
+ *
59958
+ * Deterministic on purpose: the idempotency the operator needs is "reviewing
59959
+ * twice does not double the row", and a derived primary key gets it from the
59960
+ * database rather than from a read-modify-write that two concurrent reviews
59961
+ * could interleave through. Truncated to 32 hex characters — 128 bits over a
59962
+ * table capped at 5 000 rows.
59963
+ */
59964
+ function archiveRowId(sourceTrackId, note) {
59965
+ return createHash("sha256").update(`${sourceTrackId}${note}`).digest("hex").slice(0, 32);
59966
+ }
59967
+ var DebugNoteArchiveStore = class {
59968
+ store;
59969
+ logger;
59970
+ now;
59971
+ maxRows;
59972
+ constructor(deps) {
59973
+ this.store = deps.store;
59974
+ this.logger = deps.logger;
59975
+ this.now = deps.now ?? (() => Date.now());
59976
+ this.maxRows = deps.maxRows ?? 5e3;
59977
+ }
59978
+ /** The ceiling a store built without an override enforces. */
59979
+ static defaultMaxRows() {
59980
+ return MAX_ARCHIVED_DEBUG_NOTES;
59981
+ }
59982
+ static async declare(store) {
59983
+ await store.declareCollection.mutate({
59984
+ collection: DEBUG_NOTE_ARCHIVE_COLLECTION,
59985
+ columns: [...DEBUG_NOTE_ARCHIVE_COLUMNS],
59986
+ indexes: [...DEBUG_NOTE_ARCHIVE_INDEXES]
59987
+ });
59988
+ }
59989
+ /**
59990
+ * Append one retired note. THROWS if the row cannot be written.
59991
+ *
59992
+ * Deliberately not best-effort, unlike the ops-log's `append`: this is the
59993
+ * only copy of something a person typed, and the caller
59994
+ * (`archiveNoteBeforeClear`) has both the text and the camera and is the
59995
+ * right place to decide what a failure costs — it logs the note verbatim and
59996
+ * lets the operator's flag write proceed. Swallowing here would hide the
59997
+ * failure from the one caller able to preserve the words.
59998
+ *
59999
+ * The TRIM is the exception and is best-effort: the row has already landed,
60000
+ * and failing the append because housekeeping could not run would make the
60001
+ * caller report a note lost that is sitting in the table.
60002
+ */
60003
+ async append(input) {
60004
+ const note = input.note.trim() === "" ? "" : input.note;
60005
+ if (note === "") return {
60006
+ archived: false,
60007
+ id: null
60008
+ };
60009
+ const id = archiveRowId(input.sourceTrackId, note);
60010
+ if ((await this.store.query.query({
60011
+ collection: "pipeline-analytics:debug-note-archive",
60012
+ filter: {
60013
+ where: { id },
60014
+ limit: 1
60015
+ }
60016
+ })).length > 0) return {
60017
+ archived: false,
60018
+ id
60019
+ };
60020
+ const row = ArchivedDebugNoteSchema.parse({
60021
+ id,
60022
+ note,
60023
+ deviceId: input.deviceId,
60024
+ sourceTrackId: input.sourceTrackId,
60025
+ archivedAt: this.now(),
60026
+ trackStartedAt: input.trackStartedAt ?? null,
60027
+ trackClass: input.trackClass ?? null,
60028
+ trackLabel: input.trackLabel ?? null
60029
+ });
60030
+ await this.store.insert.mutate({
60031
+ collection: DEBUG_NOTE_ARCHIVE_COLLECTION,
60032
+ record: {
60033
+ id: row.id,
60034
+ data: {
60035
+ note: row.note,
60036
+ deviceId: row.deviceId,
60037
+ sourceTrackId: row.sourceTrackId,
60038
+ archivedAt: row.archivedAt,
60039
+ trackStartedAt: row.trackStartedAt,
60040
+ trackClass: row.trackClass,
60041
+ trackLabel: row.trackLabel
60042
+ }
60043
+ }
60044
+ });
60045
+ await this.trim();
60046
+ return {
60047
+ archived: true,
60048
+ id: row.id
60049
+ };
60050
+ }
60051
+ /**
60052
+ * Hold the table at its ceiling, oldest first. Best-effort by design — see
60053
+ * {@link append}.
60054
+ *
60055
+ * NOT an age-keyed sweep and must never become one: the boundary below is
60056
+ * computed from the POSITION of the excess rows, never from a clock, so a
60057
+ * quiet fleet keeps its whole corpus for ever and a busy one drops its oldest
60058
+ * questions. Rows sharing the boundary timestamp go together; at a
60059
+ * one-per-review write rate that is a rounding error, and the alternative —
60060
+ * deleting by id, one round trip per row — is the N+1 drain this store layer
60061
+ * spent a release removing.
60062
+ */
60063
+ async trim() {
60064
+ try {
60065
+ const held = await this.store.count.query({ collection: DEBUG_NOTE_ARCHIVE_COLLECTION });
60066
+ const excess = held - this.maxRows;
60067
+ if (excess <= 0) return;
60068
+ const boundary = (await this.store.query.query({
60069
+ collection: DEBUG_NOTE_ARCHIVE_COLLECTION,
60070
+ filter: {
60071
+ orderBy: {
60072
+ field: "archivedAt",
60073
+ direction: "asc"
60074
+ },
60075
+ limit: excess
60076
+ },
60077
+ columns: ["archivedAt"]
60078
+ })).reduce((acc, r) => {
60079
+ const at = r.data["archivedAt"];
60080
+ return typeof at === "number" && at > acc ? at : acc;
60081
+ }, 0);
60082
+ if (boundary === 0) return;
60083
+ const { deleted } = await this.store.deleteWhere.mutate({
60084
+ collection: DEBUG_NOTE_ARCHIVE_COLLECTION,
60085
+ filter: { whereBetween: { archivedAt: [0, boundary] } }
60086
+ });
60087
+ this.logger.info("debug-note archive trimmed to its ceiling", { meta: {
60088
+ held,
60089
+ ceiling: this.maxRows,
60090
+ deleted
60091
+ } });
60092
+ } catch (err) {
60093
+ this.logger.warn("debug-note archive trim failed — the archive will keep growing", { meta: {
60094
+ ceiling: this.maxRows,
60095
+ error: err instanceof Error ? err.message : String(err)
60096
+ } });
60097
+ }
60098
+ }
60099
+ /** Archived notes, newest first, optionally scoped to one camera. */
60100
+ async list(query) {
60101
+ const filter = {
60102
+ orderBy: {
60103
+ field: "archivedAt",
60104
+ direction: "desc"
60105
+ },
60106
+ limit: query.limit ?? 200
60107
+ };
60108
+ if (query.deviceId !== void 0) filter["where"] = { deviceId: query.deviceId };
60109
+ const rows = await this.store.query.query({
60110
+ collection: DEBUG_NOTE_ARCHIVE_COLLECTION,
60111
+ filter
60112
+ });
60113
+ const out = [];
60114
+ for (const r of rows) {
60115
+ const parsed = ArchivedDebugNoteSchema.safeParse({
60116
+ id: r.id,
60117
+ ...r.data
60118
+ });
60119
+ if (parsed.success) out.push(parsed.data);
60120
+ else this.logger.debug("debug-note archive: skipped a malformed row", { meta: { id: r.id } });
60121
+ }
60122
+ return out;
60123
+ }
60124
+ };
60125
+ //#endregion
59412
60126
  //#region src/pipeline-analytics/store/ops-log-store.ts
59413
60127
  /**
59414
60128
  * OpsLogStore — the EVENTS-domain operations audit for pipeline-analytics.
@@ -60772,6 +61486,13 @@ var ANALYTICS_COLLECTIONS = [
60772
61486
  why: "the drained predecessor of the row above, declared only so its rows can be read once and moved (`viewer-settings-legacy-adoption.ts`). Retention must never touch it: its rows go one way only, to the successor, gated on a re-read that confirms they landed. Delete this entry together with that module."
60773
61487
  }
60774
61488
  },
61489
+ {
61490
+ collection: DEBUG_NOTE_ARCHIVE_COLLECTION,
61491
+ classification: {
61492
+ kind: "never-orphaned",
61493
+ why: "the archived operator notes (D405) — the ONE table here whose whole purpose is to outlive the track it names. `debug` never pinned a track against retention and D405 does not change that, so the track a note was written on IS expected to be evicted; deleting the note with it is the failure the archive was built to fix (25–27 notes from one sweep unreadable the same afternoon). Its column is called `sourceTrackId` and not `trackId` precisely so the ownership derivation cannot reach it, and its bound is a fleet-wide ROW COUNT trimmed on append — never an age sweep, which would delete the corpus for the one property that makes it valuable."
61494
+ }
61495
+ },
60775
61496
  {
60776
61497
  collection: STATIONARY_COLLECTION,
60777
61498
  classification: {
@@ -68542,6 +69263,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
68542
69263
  eventStore = null;
68543
69264
  /** Events-domain ops-log (footprint/prune/manual-delete audit). Null until onInitialize. */
68544
69265
  eventsOpsLog = null;
69266
+ /**
69267
+ * The archived operator debug notes (D405). Null until onInitialize — and
69268
+ * `applyTrackFlags` treats null as "no archive wired": the flag write the
69269
+ * operator asked for never depends on this addon's own bookkeeping.
69270
+ */
69271
+ debugNoteArchive = null;
68545
69272
  /** Event-media relocation engine (entity-routing Phase 4). */
68546
69273
  mediaRelocate = null;
68547
69274
  mediaLocationStorage = null;
@@ -69099,6 +69826,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
69099
69826
  * which would let the streetlight wedge vouch for its own delivery.
69100
69827
  */
69101
69828
  lastNonPackageActivityAt = /* @__PURE__ */ new Map();
69829
+ /**
69830
+ * WHO was in the drop-off zone, and when (D404). The pick-up is CONCLUDED
69831
+ * minutes after the fact, so the collector must be remembered while it is
69832
+ * still true — handles only, never pixels.
69833
+ */
69834
+ packageCollectors = new PackageCollectorMirror();
69102
69835
  /** Last time the oversize-sighting drop was logged per device — one line
69103
69836
  * per 5 minutes, not one per governor round (the wedge re-sights all
69104
69837
  * night; ~300 identical lines would bury the log it exists to serve). */
@@ -69524,6 +70257,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
69524
70257
  logger: logger.child("ops-log"),
69525
70258
  nodeId: ownNodeId
69526
70259
  });
70260
+ this.debugNoteArchive = new DebugNoteArchiveStore({
70261
+ store: api.settingsStore,
70262
+ logger: logger.child("debug-note-archive")
70263
+ });
69527
70264
  {
69528
70265
  const designated = await step("postProcessingNodeState", () => this.postProcessingNodeState.get());
69529
70266
  this.isPostProcessingNode = ownNodeId === designated;
@@ -69843,6 +70580,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
69843
70580
  RetrainFrameStore.declare(api.settingsStore),
69844
70581
  RetrainAnnotationStore.declare(api.settingsStore),
69845
70582
  OpsLogStore.declare(api.settingsStore),
70583
+ DebugNoteArchiveStore.declare(api.settingsStore),
69846
70584
  AnalyticsLts.declare(api.settingsStore),
69847
70585
  SceneStore.declare(api.settingsStore),
69848
70586
  NotificationCenter.declare(api.settingsStore),
@@ -71481,6 +72219,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71481
72219
  this.packageStillness.forgetDevice(data.deviceId);
71482
72220
  this.packageDropDetector?.forgetDevice(data.deviceId);
71483
72221
  this.lastNonPackageActivityAt.delete(data.deviceId);
72222
+ this.packageCollectors.forgetDevice(data.deviceId);
71484
72223
  this.packageOversizeLogAt.delete(data.deviceId);
71485
72224
  this.overlayState.clearDevice(data.deviceId);
71486
72225
  this.overlaySynthesisWarnAt.delete(data.deviceId);
@@ -71500,6 +72239,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71500
72239
  this.packageStillness.forgetDevice(deviceId);
71501
72240
  this.packageDropDetector?.forgetDevice(deviceId);
71502
72241
  this.lastNonPackageActivityAt.delete(deviceId);
72242
+ this.packageCollectors.forgetDevice(deviceId);
71503
72243
  this.packageOversizeLogAt.delete(deviceId);
71504
72244
  this.overlayState.clearDevice(deviceId);
71505
72245
  this.overlaySynthesisWarnAt.delete(deviceId);
@@ -71890,6 +72630,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71890
72630
  eventStore: stores.eventStore,
71891
72631
  logger: this.ctx.logger,
71892
72632
  refreshGallery: () => this.faceRecognizer?.refreshGallery(),
72633
+ resolveAutoAssignThreshold: async () => (await this.readDeviceSettings(0, resolveFaceSettings)).similarityThreshold,
71893
72634
  resolveClusterModelId: async () => {
71894
72635
  const api = this.ctx.api;
71895
72636
  if (!api) return null;
@@ -72382,12 +73123,26 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
72382
73123
  this.residents.markFirstFramePending(deviceId, id);
72383
73124
  this.trackStore.seedSnapshotClock(id, result.timestamp, t.bbox);
72384
73125
  this.residents.setLastFrameAt(deviceId, id, result.timestamp);
72385
- if (frameHandle) firstFrameTargets.push({
73126
+ const birthCapture = decideBirthFirstFrameCapture({
73127
+ hasFrameHandle: frameHandle !== void 0,
73128
+ matchedThisFrame: t.matchedThisFrame
73129
+ });
73130
+ if (birthCapture.capture) firstFrameTargets.push({
72386
73131
  trackId: id,
72387
73132
  timestamp: result.timestamp,
72388
73133
  bbox: { ...t.bbox },
72389
73134
  ...patchDisplayLabel(t.labelPatch) !== void 0 ? { label: patchDisplayLabel(t.labelPatch) } : {}
72390
73135
  });
73136
+ else log.info("birth firstFrame not captured — retry armed", {
73137
+ tags: { deviceId },
73138
+ meta: {
73139
+ trackId: id,
73140
+ className: t.className,
73141
+ source,
73142
+ refusedBecause: birthCapture.refusedBecause,
73143
+ deferredMs: result.timestamp - (this.trackStore?.peekActive(id)?.firstSeen ?? result.timestamp)
73144
+ }
73145
+ });
72391
73146
  }
72392
73147
  this.ctx.eventBus.emit({
72393
73148
  id: `pa-${randomUUID()}`,
@@ -75097,6 +75852,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
75097
75852
  this.lastNonPackageActivityAt.set(input.deviceId, input.timestamp);
75098
75853
  break;
75099
75854
  }
75855
+ this.packageCollectors.noteFrame({
75856
+ deviceId: input.deviceId,
75857
+ tracked: input.tracked,
75858
+ packageClasses: input.packageClasses,
75859
+ packageZoneIds: input.packageZoneIds,
75860
+ timestamp: input.timestamp,
75861
+ keyFrameMediaKeyFor: (trackId) => this.residents.keyFrameKey(trackId)
75862
+ });
75100
75863
  const sightings = [];
75101
75864
  for (const t of input.tracked) {
75102
75865
  if (!input.packageClasses.has(t.className)) continue;
@@ -75208,10 +75971,24 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
75208
75971
  meta: { entryId: entry.id }
75209
75972
  });
75210
75973
  }).catch(() => void 0);
75974
+ const collector = this.packageCollectors.pickCollector({
75975
+ deviceId: input.deviceId,
75976
+ pickupAt: pickupInstantOf(entry)
75977
+ });
75978
+ if (collector === null) this.ctx.logger.info("package pick-up has no collector in the drop-off zone", {
75979
+ tags,
75980
+ meta: {
75981
+ entryId: entry.id,
75982
+ pickupAt: entry.lastConfirmedAt,
75983
+ departedAt: input.timestamp,
75984
+ presencesKnown: this.packageCollectors.debugPresenceCount(input.deviceId)
75985
+ }
75986
+ });
75211
75987
  this.packageDropDetector.onStationaryChange({
75212
75988
  phase: "departed",
75213
75989
  entry,
75214
- timestamp: input.timestamp
75990
+ timestamp: input.timestamp,
75991
+ ...collector !== null ? { collector } : {}
75215
75992
  });
75216
75993
  }
75217
75994
  for (const entry of outcome.departedWithoutActivity) {
@@ -77336,7 +78113,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
77336
78113
  setFlags: (trackId, patch) => trackStore.setFlags(trackId, patch),
77337
78114
  countStaging: (query) => trackStore.countStaging(query)
77338
78115
  },
77339
- logger: this.ctx.logger
78116
+ logger: this.ctx.logger,
78117
+ ...this.debugNoteArchive !== null ? { noteArchive: this.debugNoteArchive } : {}
77340
78118
  }, input);
77341
78119
  this.ctx.logger.info("track operator flags set", {
77342
78120
  tags: { deviceId: input.deviceId },
@@ -77467,6 +78245,18 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
77467
78245
  return this.queryFacade.listOpsLog(input);
77468
78246
  }
77469
78247
  /**
78248
+ * The archived debug notes, newest first (D405).
78249
+ *
78250
+ * Answers from the archive alone — never from the track rows. The point of
78251
+ * the table is that the tracks these notes were written on are gone, so a
78252
+ * read that joined them would return exactly the rows the archive exists to
78253
+ * replace. An archive that has not been built yet answers EMPTY, which is
78254
+ * true: nothing has been archived on this runner.
78255
+ */
78256
+ async listArchivedDebugNotes(input) {
78257
+ return await this.debugNoteArchive?.list(input) ?? [];
78258
+ }
78259
+ /**
77470
78260
  * Track-centric time-based retention (design §5.1). Drains every persisted
77471
78261
  * track for the device whose `lastSeen < cutoffMs`, page by page, through the
77472
78262
  * widened cascade — enrolled faces/plates + identity media are exempt (design