@camstack/addon-post-analysis 1.2.293 → 1.2.295

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (15) hide show
  1. package/dist/{clip-model-registry-D2zqXamW.mjs → clip-model-registry-BrHhUIfN.mjs} +276 -156
  2. package/dist/{clip-model-registry-BAuTHS_s.js → clip-model-registry-Dxlc9JV3.js} +275 -155
  3. package/dist/embedding-encoder/index.js +1 -1
  4. package/dist/embedding-encoder/index.mjs +1 -1
  5. package/dist/pipeline-analytics/_stub.js +2 -2
  6. package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DW6-zerK.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-B28yTZRG.mjs} +2 -2
  7. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-vs3KH9py.mjs +26 -0
  8. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-KXwf-qxG.mjs +26 -0
  9. package/dist/pipeline-analytics/{hostInit-CAJh3aX8.mjs → hostInit-CLLZtXTH.mjs} +2 -2
  10. package/dist/pipeline-analytics/index.js +331 -141
  11. package/dist/pipeline-analytics/index.mjs +331 -141
  12. package/dist/pipeline-analytics/remoteEntry.js +1 -1
  13. package/package.json +2 -1
  14. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Boh18P08.mjs +0 -26
  15. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Bs5QFgmh.mjs +0 -26
@@ -1,4 +1,4 @@
1
- import { $ as NcRuleSchema, $n as EventCategory, $t as isSourceCap, A as LabelAttributionSchema, An as DeviceType, At as audioMetricsCapability, B as NC_DEFAULT_SNOOZE_MINUTES, Bt as embeddingRefusalOf, C as EMBEDDING_REQUIREMENTS_FAILED, Cn as systemEventFilterApplies, Ct as TimelapseRuleInputSchema, D as FIRST_LEVEL_MACRO_CLASSES, Dn as vectorDimFromBase64, Dt as addonWidgetsSourceCapability, E as EVENT_OWNER_TYPES, En as templateVarsFor, Et as TrackSourceSchema, F as MOTION_CLOSE_AFTER_MS, Fn as customAction, Ft as deriveRecordingMode, G as NC_TAXONOMY, Gn as discriminatedUnion, Gt as evictionPolicyOfLocation, Hn as _enum, I as MediaFileKindEnum, In as defineCustomActions, It as describeSystemEventDeliveryProblem, J as NcAlarmSettingsPatchSchema, Jn as object, Jt as isDetectionMacroClass, K as NC_TEMPLATE_VARS, Kn as literal, Kt as faceGalleryCapability, L as NC_ALARM_SYSTEM_EVENT_KINDS, Ln as hydrateSchema, Lt as describeSystemEventFamilyProblem, M as MAX_ARCHIVED_DEBUG_NOTES, Mn as BaseAddon, Mt as buildEventKindDescriptor, N as MAX_BIRTH_DECISION_RECORDS, Nn as CamProfileSchema, Nt as consumableLowPercentOf, O as FULL_IMAGE_BBOX, On as zoneAnalyticsCapability, Ot as alarmPanelCapability, Pn as createEvent, Pt as cosineSimilarity$1, Q as NcRulePatchSchema, Qn as unknown, Qt as isScheduleActive, R as NC_CONDITION_CATALOG, Rn as isDeviceScopedCap, S as EMBEDDING_MODEL_FAILED, Sn as systemEventFamilyProblems, St as TIMELAPSE_DENSE_FLOOR_SEC, T as EVENT_KIND_BY_CAP, Tn as templateCarriesSummary, Tt as TimelapseRuleSchema, U as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, Un as array, Ut as encodeVectorBase64, V as NC_SENSOR_TRANSITION_KINDS, Vn as sleep$1, W as NC_TANK_SYSTEM_EVENT_KINDS, Wn as boolean, Wt as evaluateSensorEdge, X as NcConditionsSchema, Xn as record, Xt as isKnownTemplateVar, Y as NcConditionDescriptorSchema, Yn as partialRecord, Yt as isEventOwnerType, Z as NcRuleInputSchema, Zn as string, Zt as isMediaOwnerType, _n as sensorRaisedOf, a as CLIP_EMBEDDING_STEP_ID, an as notificationRulesCapability, at as NcSnoozeSuppressedSchema, bn as subKindsOf, bt as SCENE_DIVERGED, c as resolveClusterModelPin, cn as pipelineAnalyticsCapability, ct as NcTemplatePreviewInputSchema, d as ArchivedDebugNoteSchema, dn as readTimelapseGeneratedAt, dt as OpsLogEntrySchema, en as isSystemDelivery, et as NcRuleTargetSchema, f as BaseDevice, fn as resolveClipSearchMinScore, g as DEFAULT_EVENT_COLOR, gn as sceneMonitorCapability, gt as RetrainStatusSchema, h as CoverStateSchema, hn as ruleBelongsToAlarm$1, ht as RebuildObjectEmbeddingsRefusalSchema, in as mayWriteToLocation, it as NcSnoozeSchema, j as LockStateSchema, jn as errMsg$1, jt as audioModeOf, k as FailureCounters, kn as canonicalHash, kt as assertTimelapseCadences, l as AUDIO_MACRO_LABELS, ln as plateGalleryCapability, lt as NcTemplatePreviewSchema, m as COCO_TO_MACRO, mt as RECORDING_SOURCE_CAMSTACK_ADDON, nn as isVectorIndexDimConflict, nt as NcScheduleSchema, o as FACE_EMBEDDING_STEP_ID, on as occupancyScope, ot as NcSystemEventKindSchema, p as BirthDecisionRecordSchema, pn as resolveLocationMode, pt as RECORDING_EXPORT_MAX_READ_BYTES, q as NcAlarmConfigSchema, qn as number, qt as failureContributionCapability, rn as kebabToCamel, rt as NcSnoozeInputSchema, s as readClusterModelPinStrict, st as NcTaxonomySchema, t as BUILTIN_CLIP_MODELS, tn as isSystemEventSummaryRule, tt as NcRuleUpdateInputSchema, u as AlarmArmModeSchema, un as readDeviceStateFrom, ut as NcTemplateVarDescriptorSchema, vn as servedTemplateVars, vt as SCENE_DEFAULT_ANCHOR_THRESHOLD, w as EMBEDDING_REQUIREMENTS_UNAVAILABLE, wn as systemEventSummaryScheduleOf, wt as TimelapseRulePatchSchema, x as DeclaredDevices, xn as systemEventDeliveryProblems, xt as SceneMonitorSchema, y as DETECTION_MACRO_CLASSES, yn as storageOccupancyCapability, yt as SCENE_DEFAULT_UNCOVERED_POLICY, z as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, zn as nodePin } from "../clip-model-registry-D2zqXamW.mjs";
1
+ import { $ as NcRuleTargetSchema, $n as EventCategory, $t as isVectorIndexDimConflict, A as LockStateSchema, An as DeviceType, At as audioModeOf, B as NC_SENSOR_TRANSITION_KINDS, C as EMBEDDING_REQUIREMENTS_UNAVAILABLE, Cn as templateVarsFor, Ct as TimelapseRulePatchSchema, D as FULL_IMAGE_BBOX, Dn as canonicalHash, Dt as alarmPanelCapability, E as FIRST_LEVEL_MACRO_CLASSES, Et as addonWidgetsSourceCapability, F as MediaFileKindEnum, Fn as customAction, Ft as describeSystemEventDeliveryProblem, G as NC_TEMPLATE_VARS, Gn as discriminatedUnion, Gt as failureContributionCapability, H as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, Hn as _enum, Ht as encodeVectorBase64, I as NC_ALARM_SYSTEM_EVENT_KINDS, In as defineCustomActions, It as describeSystemEventFamilyProblem, J as NcConditionDescriptorSchema, Jn as object, Jt as isKnownTemplateVar, K as NcAlarmConfigSchema, Kn as literal, Kt as isDetectionMacroClass, L as NC_CONDITION_CATALOG, Ln as hydrateSchema, M as MAX_BIRTH_DECISION_RECORDS, Mn as BaseAddon, Mt as consumableLowPercentOf, Nn as CamProfileSchema, Nt as cosineSimilarity$1, O as FailureCounters, On as evaluateSensorEdge, Ot as assertTimelapseCadences, P as MOTION_CLOSE_AFTER_MS, Pn as createEvent, Pt as deriveRecordingMode, Q as NcRuleSchema, Qn as unknown, Qt as isSystemEventSummaryRule, R as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, Rn as isDeviceScopedCap, S as EMBEDDING_REQUIREMENTS_FAILED, Sn as templateCarriesSummary, St as TimelapseRuleInputSchema, T as EVENT_OWNER_TYPES, Tn as zoneAnalyticsCapability, Tt as TrackSourceSchema, U as NC_TANK_SYSTEM_EVENT_KINDS, Un as array, Ut as evictionPolicyOfLocation, Vn as sleep$1, W as NC_TAXONOMY, Wn as boolean, Wt as faceGalleryCapability, X as NcRuleInputSchema, Xn as record, Xt as isScheduleActive, Y as NcConditionsSchema, Yn as partialRecord, Yt as isMediaOwnerType, Z as NcRulePatchSchema, Zn as string, Zt as isSystemDelivery, _n as subKindsOf, _t as SCENE_DEFAULT_ANCHOR_THRESHOLD, a as CLIP_EMBEDDING_STEP_ID, an as pipelineAnalyticsCapability, at as NcSystemEventKindSchema, b as DeclaredDevices, bn as systemEventFilterApplies, bt as SceneMonitorSchema, c as resolveClusterModelPin, cn as readTimelapseGeneratedAt, ct as NcTemplatePreviewSchema, d as ArchivedDebugNoteSchema, en as kebabToCamel, et as NcRuleUpdateInputSchema, f as BaseDevice, fn as ruleBelongsToAlarm$1, ft as RECORDING_EXPORT_MAX_READ_BYTES, g as DEFAULT_EVENT_COLOR, gn as storageOccupancyCapability, h as CoverStateSchema, hn as servedTemplateVars, ht as RetrainStatusSchema, it as NcSnoozeSuppressedSchema, j as MAX_ARCHIVED_DEBUG_NOTES, jn as errMsg$1, jt as buildEventKindDescriptor, k as LabelAttributionSchema, kn as isSourceCap, kt as audioMetricsCapability, l as AUDIO_MACRO_LABELS, ln as resolveClipSearchMinScore, lt as NcTemplateVarDescriptorSchema, m as COCO_TO_MACRO, mn as sensorRaisedOf, mt as RebuildObjectEmbeddingsRefusalSchema, nn as notificationRulesCapability, nt as NcSnoozeInputSchema, o as FACE_EMBEDDING_STEP_ID, on as plateGalleryCapability, ot as NcTaxonomySchema, p as BirthDecisionRecordSchema, pn as sceneMonitorCapability, pt as RECORDING_SOURCE_CAMSTACK_ADDON, q as NcAlarmSettingsPatchSchema, qn as number, qt as isEventOwnerType, rn as occupancyScope, rt as NcSnoozeSchema, s as readClusterModelPinStrict, sn as readDeviceStateFrom, st as NcTemplatePreviewInputSchema, t as BUILTIN_CLIP_MODELS, tn as mayWriteToLocation, tt as NcScheduleSchema, u as AlarmArmModeSchema, un as resolveLocationMode, ut as OpsLogEntrySchema, v as DETECTION_MACRO_CLASSES, vn as systemEventDeliveryProblems, vt as SCENE_DEFAULT_UNCOVERED_POLICY, w as EVENT_KIND_BY_CAP, wn as vectorDimFromBase64, wt as TimelapseRuleSchema, x as EMBEDDING_MODEL_FAILED, xn as systemEventSummaryScheduleOf, xt as TIMELAPSE_DENSE_FLOOR_SEC, yn as systemEventFamilyProblems, yt as SCENE_DIVERGED, z as NC_DEFAULT_SNOOZE_MINUTES, zn as nodePin, zt as embeddingRefusalOf } from "../clip-model-registry-BrHhUIfN.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";
@@ -67511,15 +67511,17 @@ function resolveMediaSettings(raw) {
67511
67511
  * `recording` became a device collection on 2026-09-24 (D625): a camera may
67512
67512
  * have several sources of recorded coverage and every read names one. Every
67513
67513
  * read in this addon wants the same one — the footage OUR recorder wrote,
67514
- * because everything downstream of it (a recorded clip cut, a timelapse, the
67515
- * earliest-footage floor of a retention sweep) is about bytes we hold and can
67516
- * export. A camera's own SD card is not an answer to any of those questions.
67514
+ * because everything downstream of it (a recorded clip cut, a timelapse) is
67515
+ * about bytes we hold and can export. The retention sweep's earliest-footage
67516
+ * floor is NOT asked here any more: it is one number the archive's ledger
67517
+ * already folds, and asking it as coverage from the epoch walked every hour
67518
+ * since 1970 (D674, `retention/archive-footage-floor.ts`). A camera's own SD card is not an answer to any of those questions.
67517
67519
  *
67518
67520
  * It also owns the `read: 'unreadable'` rule, in one place: a read that FAILED
67519
67521
  * throws here rather than arriving downstream as an empty range list. Every
67520
67522
  * caller of this module treats "no ranges" as "recording is off for this
67521
- * camera" and acts on it — dropping the attachment, skipping the render,
67522
- * lowering the sweep floor — so folding a failed read into that shape would
67523
+ * camera" and acts on it — dropping the attachment, skipping the render —
67524
+ * so folding a failed read into that shape would
67523
67525
  * make an unreachable recorder look exactly like a camera nobody records
67524
67526
  * (D393). The callers already handle a throw and log it as a read failure.
67525
67527
  */
@@ -67538,6 +67540,248 @@ async function ourRecordingAvailability(api, input) {
67538
67540
  profilesWithFootage: answer.profilesWithFootage
67539
67541
  };
67540
67542
  }
67543
+ //#endregion
67544
+ //#region src/pipeline-analytics/retention-policy.ts
67545
+ /**
67546
+ * Analytics retention policy — **ONE window per device** (D62).
67547
+ *
67548
+ * A device has one retention window, and everything scoped to that device is
67549
+ * deleted on it: the track (which cascades to its object events, their child
67550
+ * crops, its media, its unassigned face/plate rows and crops, and its CLIP
67551
+ * vector) AND the trackless roots (motion, audio, sensor events). The only
67552
+ * thing outside it is the ops log, which is global and longer than any device
67553
+ * window because it records what retention did — including for devices that no
67554
+ * longer exist.
67555
+ *
67556
+ * Two modes:
67557
+ *
67558
+ * - `follow-recordings` (the DEFAULT): the window is the instant of the oldest
67559
+ * segment still on disk. That follows the recorder's age retention AND its
67560
+ * disk-pressure eviction automatically, with no second policy to keep in
67561
+ * sync. A camera with no footage at all (recording off / brand new) falls
67562
+ * back to `trackRetentionDays`, so analytics stay bounded either way.
67563
+ *
67564
+ * - `custom`: `trackRetentionDays`, and nothing else. `0` = keep forever, and
67565
+ * that is the ONE way retention is off for a device.
67566
+ *
67567
+ * ## Why there is only one number here
67568
+ *
67569
+ * There used to be four: `trackRetentionDays` plus a day window per event
67570
+ * family (motion / object / audio). [D61](../../../../docs/decisions/adr-0061.md)
67571
+ * made object events, faces, plates, crops and vectors TRACK-OWNED — they leave
67572
+ * through the cascade and by no other path — which silently disconnected
67573
+ * `retentionObjectDays` from the thing it was named after. An operator could set
67574
+ * object retention to 30 days, the form would save it, and the events would die
67575
+ * with their track at 7. A knob that decides nothing is worse than a missing
67576
+ * one: it reads as a decision that was made.
67577
+ *
67578
+ * So the per-family windows are gone (D62), not merely ignored, and the
67579
+ * surviving surface is ENUMERATED in {@link RETENTION_SETTING_KEYS} so
67580
+ * `scripts/check-retention-settings-consumed.ts` can fail the build the next
67581
+ * time a knob and its reader come apart.
67582
+ *
67583
+ * Follow-mode floor: nothing younger than {@link FOLLOW_FLOOR_DAYS} is ever
67584
+ * pruned, even when footage is younger (a camera that STARTED recording
67585
+ * yesterday must not nuke its whole history back to the first segment).
67586
+ */
67587
+ var DAY_MS$1 = 1440 * 60 * 1e3;
67588
+ /**
67589
+ * Per-device analytics retention settings as stored in the device blob.
67590
+ * `retentionMode` absent = follow-recordings (the default).
67591
+ */
67592
+ var RetentionSettingsSchema = object({
67593
+ retentionMode: _enum(["follow-recordings", "custom"]).default("follow-recordings"),
67594
+ trackRetentionDays: number().min(0).default(7)
67595
+ });
67596
+ /** The default retention window, in days. The form's default reads from here so
67597
+ * the schema is the single source. */
67598
+ var TRACK_RETENTION_DEFAULT_DAYS = RetentionSettingsSchema.parse({}).trackRetentionDays;
67599
+ /** Resolve the settings from a raw device-store blob — invalid or missing
67600
+ * fields fall back to defaults, never throw. */
67601
+ function resolveRetentionSettings(raw) {
67602
+ const parsed = RetentionSettingsSchema.safeParse(raw);
67603
+ if (parsed.success) return parsed.data;
67604
+ const shape = RetentionSettingsSchema.shape;
67605
+ const defaults = RetentionSettingsSchema.parse({});
67606
+ const mode = shape.retentionMode.safeParse(raw["retentionMode"]);
67607
+ const days = shape.trackRetentionDays.safeParse(raw["trackRetentionDays"]);
67608
+ return {
67609
+ retentionMode: mode.success ? mode.data : defaults.retentionMode,
67610
+ trackRetentionDays: days.success ? days.data : defaults.trackRetentionDays
67611
+ };
67612
+ }
67613
+ /**
67614
+ * Follow-mode cutoff: the oldest retained footage instant, floored so anything
67615
+ * younger than {@link FOLLOW_FLOOR_DAYS} survives. `null` footage (none on
67616
+ * disk) → `null`, and the caller falls back to the custom window.
67617
+ */
67618
+ function followCutoffMs(nowMs, earliestFootageMs) {
67619
+ if (earliestFootageMs === null) return null;
67620
+ return Math.min(earliestFootageMs, nowMs - 7 * DAY_MS$1);
67621
+ }
67622
+ /** Compute the effective cutoff for one device at `nowMs`. */
67623
+ function resolveRetentionCutoffs(settings, nowMs, earliestFootageMs) {
67624
+ if (settings.retentionMode === "follow-recordings") {
67625
+ const cutoff = followCutoffMs(nowMs, earliestFootageMs);
67626
+ if (cutoff !== null) return {
67627
+ cutoffMs: cutoff,
67628
+ effectiveMode: "follow-recordings"
67629
+ };
67630
+ }
67631
+ return {
67632
+ cutoffMs: settings.trackRetentionDays <= 0 ? null : nowMs - settings.trackRetentionDays * DAY_MS$1,
67633
+ effectiveMode: "custom"
67634
+ };
67635
+ }
67636
+ //#endregion
67637
+ //#region src/pipeline-analytics/retention/archive-footage-floor.ts
67638
+ /**
67639
+ * How long one archive answer serves every camera. The ledger's floor moves
67640
+ * only when retention deletes an hour (minutes-to-days granularity), and the
67641
+ * sweep it feeds runs every 5 min — the same 10 min the walk was cached for.
67642
+ */
67643
+ var FOOTAGE_FLOOR_TTL_MS = 10 * 6e4;
67644
+ /**
67645
+ * How long a FAILED read is reused before it is asked again. Short, so the
67646
+ * first sweep after the recorder answers is a real one; long enough that one
67647
+ * pass over ~24 cameras is one failed call, not twenty-four.
67648
+ */
67649
+ var FOOTAGE_FLOOR_RETRY_MS = 6e4;
67650
+ /** What a follow-recordings camera gets when its floor could not be read:
67651
+ * retention OFF for this pass — nothing is deleted on a guess. */
67652
+ var UNREADABLE_FLOOR_CUTOFFS = {
67653
+ cutoffMs: null,
67654
+ effectiveMode: "follow-recordings",
67655
+ floorUnreadable: true
67656
+ };
67657
+ var NO_FOOTAGE = {
67658
+ kind: "read",
67659
+ oldestMs: null
67660
+ };
67661
+ /**
67662
+ * Turn one `getStorageUsage` answer into per-camera floors — or say why it
67663
+ * cannot be.
67664
+ *
67665
+ * - `devicesRead !== 'archive'`: the rows are UNKNOWN (`unavailable`: no
67666
+ * ledger, a ledger not loaded yet, no location resolved) or come from a
67667
+ * recorder predating D418, whose rows are RESIDENCY. No floor may be read.
67668
+ * - `complete !== true`: the ledger load was cut at its row cap, or the
67669
+ * recorder predates the field. A camera ABSENT from such an answer may hold
67670
+ * footage, and a PRESENT row's `oldestMs` is the oldest of a fraction (the
67671
+ * load keeps the newest hours) — too young, i.e. a delete. So the whole
67672
+ * answer is unreadable, not only the absent cameras.
67673
+ * - A row whose `oldestMs` is ABSENT comes from a recorder predating the
67674
+ * field — unknown for that camera, never "no footage".
67675
+ * - A camera with no row in a COMPLETE answer, or a row whose `oldestMs` is
67676
+ * `null`: the archive holds no footage for it — the custom window applies.
67677
+ */
67678
+ function footageFloorsOf(usage) {
67679
+ if (usage.devicesRead !== "archive") return `recordingArchive.getStorageUsage answered devicesRead=${usage.devicesRead ?? "absent"} — the per-camera rows are not the archive`;
67680
+ if (usage.complete !== true) return `recordingArchive.getStorageUsage answered complete=${String(usage.complete ?? "absent")} — the per-camera rows may be a fraction of the archive`;
67681
+ const floors = /* @__PURE__ */ new Map();
67682
+ for (const row of usage.devices) floors.set(row.deviceId, row.oldestMs === void 0 ? {
67683
+ kind: "unreadable",
67684
+ reason: "the recorder omitted oldestMs for this camera"
67685
+ } : {
67686
+ kind: "read",
67687
+ oldestMs: row.oldestMs
67688
+ });
67689
+ return floors;
67690
+ }
67691
+ /**
67692
+ * Per-camera floors, one archive call per {@link FOOTAGE_FLOOR_TTL_MS} for the
67693
+ * whole fleet, single-flight.
67694
+ */
67695
+ var ArchiveFootageFloors = class {
67696
+ deps;
67697
+ answer = null;
67698
+ inFlight = null;
67699
+ /** Cameras whose sweep is currently skipped — one line per episode, not per
67700
+ * pass: two sweeps × every camera × every 5 min would be a flood. */
67701
+ skipped = /* @__PURE__ */ new Set();
67702
+ /** Is the archive currently unreadable? The read-failure line is said once
67703
+ * per EPISODE (and its end once), not once per ~sweep per runner. */
67704
+ readFailing = false;
67705
+ constructor(deps) {
67706
+ this.deps = deps;
67707
+ }
67708
+ nowMs() {
67709
+ return this.deps.now?.() ?? Date.now();
67710
+ }
67711
+ async floorOf(deviceId) {
67712
+ const answer = await this.current();
67713
+ if ("unreadable" in answer) return {
67714
+ kind: "unreadable",
67715
+ reason: answer.unreadable
67716
+ };
67717
+ return answer.floors.get(deviceId) ?? NO_FOOTAGE;
67718
+ }
67719
+ /**
67720
+ * The per-device effective cutoffs (mode + footage → numbers). A follow
67721
+ * camera whose floor is unreadable is NOT swept this pass, and that is logged.
67722
+ */
67723
+ async cutoffsFor(deviceId, settings, nowMs) {
67724
+ if (settings.retentionMode !== "follow-recordings") return resolveRetentionCutoffs(settings, nowMs, null);
67725
+ const floor = await this.floorOf(deviceId);
67726
+ if (floor.kind === "unreadable") {
67727
+ if (!this.skipped.has(deviceId)) {
67728
+ this.skipped.add(deviceId);
67729
+ this.deps.logger.warn("retention: follow-recordings floor unreadable — this camera is not swept until the archive answers", {
67730
+ tags: { deviceId },
67731
+ meta: { reason: floor.reason }
67732
+ });
67733
+ }
67734
+ return UNREADABLE_FLOOR_CUTOFFS;
67735
+ }
67736
+ if (this.skipped.delete(deviceId)) this.deps.logger.info("retention: follow-recordings floor readable again — sweeping", {
67737
+ tags: { deviceId },
67738
+ meta: { oldestMs: floor.oldestMs }
67739
+ });
67740
+ return resolveRetentionCutoffs(settings, nowMs, floor.oldestMs);
67741
+ }
67742
+ async current() {
67743
+ const held = this.answer;
67744
+ const now = this.nowMs();
67745
+ if (held !== null) {
67746
+ const ttl = "unreadable" in held ? FOOTAGE_FLOOR_RETRY_MS : FOOTAGE_FLOOR_TTL_MS;
67747
+ if (now - held.atMs < ttl) return held;
67748
+ }
67749
+ this.inFlight ??= this.read().finally(() => {
67750
+ this.inFlight = null;
67751
+ });
67752
+ return this.inFlight;
67753
+ }
67754
+ async read() {
67755
+ let answer;
67756
+ try {
67757
+ const floors = footageFloorsOf(await this.deps.api.recordingArchive.getStorageUsage.query({}));
67758
+ answer = typeof floors === "string" ? {
67759
+ atMs: this.nowMs(),
67760
+ unreadable: floors
67761
+ } : {
67762
+ atMs: this.nowMs(),
67763
+ floors
67764
+ };
67765
+ } catch (err) {
67766
+ answer = {
67767
+ atMs: this.nowMs(),
67768
+ unreadable: `recordingArchive.getStorageUsage failed: ${err instanceof Error ? err.message : String(err)}`
67769
+ };
67770
+ }
67771
+ if ("unreadable" in answer && !this.readFailing) {
67772
+ this.readFailing = true;
67773
+ this.deps.logger.warn("retention: the archive floor could not be read — follow-recordings cameras are not swept on a guess", { meta: {
67774
+ reason: answer.unreadable,
67775
+ retryMs: FOOTAGE_FLOOR_RETRY_MS
67776
+ } });
67777
+ } else if (!("unreadable" in answer) && this.readFailing) {
67778
+ this.readFailing = false;
67779
+ this.deps.logger.info("retention: the archive floor is readable again", { meta: { cameras: answer.floors.size } });
67780
+ }
67781
+ this.answer = answer;
67782
+ return answer;
67783
+ }
67784
+ };
67541
67785
  function toDetectionBbox(bbox) {
67542
67786
  return {
67543
67787
  x: bbox.x,
@@ -78966,7 +79210,7 @@ async function sweepTracklessRoots(deps) {
78966
79210
  const cutoffs = await deps.resolveCutoffs(deviceId, deps.nowMs);
78967
79211
  const cutoffMs = cutoffs.cutoffMs;
78968
79212
  if (cutoffMs === null) {
78969
- deps.onRetentionDisabled(deviceId);
79213
+ if (cutoffs.floorUnreadable !== true) deps.onRetentionDisabled(deviceId);
78970
79214
  continue;
78971
79215
  }
78972
79216
  const events = await deps.evictTracklessEvents({
@@ -78988,99 +79232,6 @@ async function sweepTracklessRoots(deps) {
78988
79232
  }
78989
79233
  }
78990
79234
  //#endregion
78991
- //#region src/pipeline-analytics/retention-policy.ts
78992
- /**
78993
- * Analytics retention policy — **ONE window per device** (D62).
78994
- *
78995
- * A device has one retention window, and everything scoped to that device is
78996
- * deleted on it: the track (which cascades to its object events, their child
78997
- * crops, its media, its unassigned face/plate rows and crops, and its CLIP
78998
- * vector) AND the trackless roots (motion, audio, sensor events). The only
78999
- * thing outside it is the ops log, which is global and longer than any device
79000
- * window because it records what retention did — including for devices that no
79001
- * longer exist.
79002
- *
79003
- * Two modes:
79004
- *
79005
- * - `follow-recordings` (the DEFAULT): the window is the instant of the oldest
79006
- * segment still on disk. That follows the recorder's age retention AND its
79007
- * disk-pressure eviction automatically, with no second policy to keep in
79008
- * sync. A camera with no footage at all (recording off / brand new) falls
79009
- * back to `trackRetentionDays`, so analytics stay bounded either way.
79010
- *
79011
- * - `custom`: `trackRetentionDays`, and nothing else. `0` = keep forever, and
79012
- * that is the ONE way retention is off for a device.
79013
- *
79014
- * ## Why there is only one number here
79015
- *
79016
- * There used to be four: `trackRetentionDays` plus a day window per event
79017
- * family (motion / object / audio). [D61](../../../../docs/decisions/adr-0061.md)
79018
- * made object events, faces, plates, crops and vectors TRACK-OWNED — they leave
79019
- * through the cascade and by no other path — which silently disconnected
79020
- * `retentionObjectDays` from the thing it was named after. An operator could set
79021
- * object retention to 30 days, the form would save it, and the events would die
79022
- * with their track at 7. A knob that decides nothing is worse than a missing
79023
- * one: it reads as a decision that was made.
79024
- *
79025
- * So the per-family windows are gone (D62), not merely ignored, and the
79026
- * surviving surface is ENUMERATED in {@link RETENTION_SETTING_KEYS} so
79027
- * `scripts/check-retention-settings-consumed.ts` can fail the build the next
79028
- * time a knob and its reader come apart.
79029
- *
79030
- * Follow-mode floor: nothing younger than {@link FOLLOW_FLOOR_DAYS} is ever
79031
- * pruned, even when footage is younger (a camera that STARTED recording
79032
- * yesterday must not nuke its whole history back to the first segment).
79033
- */
79034
- var DAY_MS$1 = 1440 * 60 * 1e3;
79035
- /**
79036
- * Per-device analytics retention settings as stored in the device blob.
79037
- * `retentionMode` absent = follow-recordings (the default).
79038
- */
79039
- var RetentionSettingsSchema = object({
79040
- retentionMode: _enum(["follow-recordings", "custom"]).default("follow-recordings"),
79041
- trackRetentionDays: number().min(0).default(7)
79042
- });
79043
- /** The default retention window, in days. The form's default reads from here so
79044
- * the schema is the single source. */
79045
- var TRACK_RETENTION_DEFAULT_DAYS = RetentionSettingsSchema.parse({}).trackRetentionDays;
79046
- /** Resolve the settings from a raw device-store blob — invalid or missing
79047
- * fields fall back to defaults, never throw. */
79048
- function resolveRetentionSettings(raw) {
79049
- const parsed = RetentionSettingsSchema.safeParse(raw);
79050
- if (parsed.success) return parsed.data;
79051
- const shape = RetentionSettingsSchema.shape;
79052
- const defaults = RetentionSettingsSchema.parse({});
79053
- const mode = shape.retentionMode.safeParse(raw["retentionMode"]);
79054
- const days = shape.trackRetentionDays.safeParse(raw["trackRetentionDays"]);
79055
- return {
79056
- retentionMode: mode.success ? mode.data : defaults.retentionMode,
79057
- trackRetentionDays: days.success ? days.data : defaults.trackRetentionDays
79058
- };
79059
- }
79060
- /**
79061
- * Follow-mode cutoff: the oldest retained footage instant, floored so anything
79062
- * younger than {@link FOLLOW_FLOOR_DAYS} survives. `null` footage (none on
79063
- * disk) → `null`, and the caller falls back to the custom window.
79064
- */
79065
- function followCutoffMs(nowMs, earliestFootageMs) {
79066
- if (earliestFootageMs === null) return null;
79067
- return Math.min(earliestFootageMs, nowMs - 7 * DAY_MS$1);
79068
- }
79069
- /** Compute the effective cutoff for one device at `nowMs`. */
79070
- function resolveRetentionCutoffs(settings, nowMs, earliestFootageMs) {
79071
- if (settings.retentionMode === "follow-recordings") {
79072
- const cutoff = followCutoffMs(nowMs, earliestFootageMs);
79073
- if (cutoff !== null) return {
79074
- cutoffMs: cutoff,
79075
- effectiveMode: "follow-recordings"
79076
- };
79077
- }
79078
- return {
79079
- cutoffMs: settings.trackRetentionDays <= 0 ? null : nowMs - settings.trackRetentionDays * DAY_MS$1,
79080
- effectiveMode: "custom"
79081
- };
79082
- }
79083
- //#endregion
79084
79235
  //#region src/pipeline-analytics/runtime/binding-cache.ts
79085
79236
  var BindingCache = class {
79086
79237
  api;
@@ -82466,7 +82617,7 @@ async function projectSensorMarkers(deps, data, timestamp) {
82466
82617
  return 0;
82467
82618
  }
82468
82619
  const producingDeviceName = await deps.producingDeviceName(data.deviceId);
82469
- const settled = await Promise.allSettled(cameraIds.map((cameraId) => deps.markers.materialize({
82620
+ const settled = await Promise.allSettled(cameraIds.map((cameraId) => deps.markers.materializeOrShare({
82470
82621
  cameraId,
82471
82622
  sourceDeviceId: data.deviceId,
82472
82623
  source: "sensor",
@@ -82479,8 +82630,8 @@ async function projectSensorMarkers(deps, data, timestamp) {
82479
82630
  settled.forEach((outcome, i) => {
82480
82631
  const cameraId = cameraIds[i];
82481
82632
  if (cameraId === void 0) return;
82482
- const track = outcome.status === "fulfilled" ? outcome.value : null;
82483
- if (track === null) {
82633
+ const result = outcome.status === "fulfilled" ? outcome.value : null;
82634
+ if (result === null || result.kind === "not-materialized") {
82484
82635
  deps.onSkipped({
82485
82636
  cameraId,
82486
82637
  sourceDeviceId: data.deviceId,
@@ -82497,9 +82648,10 @@ async function projectSensorMarkers(deps, data, timestamp) {
82497
82648
  sourceDeviceId: data.deviceId,
82498
82649
  capName: data.capName,
82499
82650
  kind: descriptor.kind,
82500
- trackId: track.trackId,
82651
+ trackId: result.track.trackId,
82501
82652
  selfCamera: cameraId === data.deviceId,
82502
- hasMedia: track.snapshots.length > 0
82653
+ hasMedia: result.track.snapshots.length > 0,
82654
+ via: result.kind
82503
82655
  });
82504
82656
  });
82505
82657
  return landed;
@@ -82818,7 +82970,8 @@ var DEFAULT_DEBOUNCE_MS = 1e4;
82818
82970
  /**
82819
82971
  * What the debounce treats as "the same event happening again".
82820
82972
  *
82821
- * `(camera, kind)` — plus, for an audio row, the class HEARD. Since D189 every
82973
+ * `(camera, kind)` — plus, for an audio row, the class HEARD, and for a sensor
82974
+ * row, the SENSOR. Since D189 every
82822
82975
  * audio row carries `kind: 'audio'` and puts the class on `audioLabels`, so
82823
82976
  * keying on `kind` alone would collapse this to one bucket per camera. The
82824
82977
  * episode hysteresis cools down PER CLASS, so a bark three seconds after speech
@@ -82830,7 +82983,8 @@ var DEFAULT_DEBOUNCE_MS = 1e4;
82830
82983
  */
82831
82984
  function debounceKey(input) {
82832
82985
  const heard = input.audioLabels?.[0]?.label;
82833
- return heard === void 0 ? `${input.cameraId}:${input.kind}` : `${input.cameraId}:${input.kind}:${heard}`;
82986
+ if (heard !== void 0) return `${input.cameraId}:${input.kind}:${heard}`;
82987
+ return input.source === "sensor" ? `${input.cameraId}:${input.kind}:${input.sourceDeviceId}` : `${input.cameraId}:${input.kind}`;
82834
82988
  }
82835
82989
  /** A hanging snapshot cap must not stall the sensor state-change path. */
82836
82990
  var DEFAULT_SNAPSHOT_TIMEOUT_MS = 1e4;
@@ -82874,8 +83028,8 @@ var SyntheticTrackMaterializer = class {
82874
83028
  snapshotTimeoutMs;
82875
83029
  makeId;
82876
83030
  now;
82877
- /** Last snapshot time per {@link debounceKey}. */
82878
- lastAt = /* @__PURE__ */ new Map();
83031
+ /** The open window per {@link debounceKey}: its start and the marker it lands. */
83032
+ windows = /* @__PURE__ */ new Map();
82879
83033
  constructor(deps) {
82880
83034
  this.deps = deps;
82881
83035
  this.debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS;
@@ -82907,16 +83061,50 @@ var SyntheticTrackMaterializer = class {
82907
83061
  return null;
82908
83062
  }
82909
83063
  }
83064
+ /** The window `input` falls in, or null when it opens a new one. */
83065
+ openWindow(input) {
83066
+ const window = this.windows.get(debounceKey(input));
83067
+ return window !== void 0 && this.now() - window.at < this.debounceMs ? window : null;
83068
+ }
83069
+ startWindow(input) {
83070
+ const landing = this.land(input);
83071
+ this.windows.set(debounceKey(input), {
83072
+ at: this.now(),
83073
+ landing
83074
+ });
83075
+ return landing;
83076
+ }
82910
83077
  /**
82911
83078
  * Materialize a synthetic track for a non-spatial event on `cameraId`.
82912
83079
  * Returns the persisted track, or null when debounced. Snapshot failure
82913
83080
  * still lands a track (with no media) — it never blocks the durable record.
83081
+ *
83082
+ * The AUDIO contract: a debounced call is a second episode that must not
83083
+ * adopt the first one's row, so it answers null. A SENSOR projection wants
83084
+ * the window's marker instead — {@link materializeOrShare}.
82914
83085
  */
82915
83086
  async materialize(input) {
82916
- const key = debounceKey(input);
82917
- const last = this.lastAt.get(key);
82918
- if (last !== void 0 && this.now() - last < this.debounceMs) return null;
82919
- this.lastAt.set(key, this.now());
83087
+ if (this.openWindow(input) !== null) return null;
83088
+ return this.startWindow(input);
83089
+ }
83090
+ /**
83091
+ * Like {@link materialize}, but a call inside an open window answers the
83092
+ * window's OWN marker (awaiting it while it is still landing) with kind
83093
+ * `debounced`. Two state changes of one doorbell press are one event: the
83094
+ * second must name the same media owner, not none (2026-09-29, camera 615 —
83095
+ * the notification that won the rule was the debounced one, and shipped
83096
+ * without a still).
83097
+ */
83098
+ async materializeOrShare(input) {
83099
+ const window = this.openWindow(input);
83100
+ const track = await (window === null ? this.startWindow(input) : window.landing);
83101
+ if (track === null) return { kind: "not-materialized" };
83102
+ return {
83103
+ kind: window === null ? "materialized" : "debounced",
83104
+ track
83105
+ };
83106
+ }
83107
+ async land(input) {
82920
83108
  const trackId = this.makeId();
82921
83109
  const ts = input.timestamp;
82922
83110
  let mediaId = null;
@@ -95334,37 +95522,26 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
95334
95522
  await this.trackCloser.sweep();
95335
95523
  await this.summarySealer?.sweep();
95336
95524
  }
95337
- /** Unified retention (Phase 1): earliest retained footage per device — the
95338
- * follow-recordings cutoff source. Cached 10 min: availability answers in
95339
- * ~70 ms but the sweep asks once per device per pass. `null` = no footage
95340
- * (or recorder unreachable) → the policy falls back to the custom windows. */
95341
- earliestFootageCache = /* @__PURE__ */ new Map();
95342
- async earliestFootageMs(deviceId) {
95343
- const cached = this.earliestFootageCache.get(deviceId);
95344
- if (cached && Date.now() - cached.at < 10 * 6e4) return cached.value;
95345
- let value = null;
95346
- try {
95347
- const res = await ourRecordingAvailability(this.ctx.api, {
95348
- deviceId,
95349
- fromMs: 0,
95350
- toMs: Date.now()
95351
- });
95352
- let min = Number.POSITIVE_INFINITY;
95353
- for (const r of res.ranges) if (r.startMs < min) min = r.startMs;
95354
- value = Number.isFinite(min) ? min : null;
95355
- } catch {
95356
- value = null;
95357
- }
95358
- this.earliestFootageCache.set(deviceId, {
95359
- at: Date.now(),
95360
- value
95525
+ /**
95526
+ * The follow-recordings floor per camera, read from the ARCHIVE's hour ledger
95527
+ * (`recordingArchive.getStorageUsage`, one call for every camera) — never an
95528
+ * availability walk from the epoch, which cost the recorder 2.2 GB and an OOM
95529
+ * (D674). LAZY: `ctx` is not assigned when field initialisers run.
95530
+ */
95531
+ footageFloorsInstance = null;
95532
+ get footageFloors() {
95533
+ this.footageFloorsInstance ??= new ArchiveFootageFloors({
95534
+ api: this.ctx.api,
95535
+ logger: this.ctx.logger
95361
95536
  });
95362
- return value;
95537
+ return this.footageFloorsInstance;
95363
95538
  }
95364
- /** The per-device effective cutoffs (mode + footage → numbers). */
95539
+ /** The per-device effective cutoffs (mode + footage → numbers). A
95540
+ * follow-recordings camera whose floor could not be read gets NO cutoff this
95541
+ * pass (logged in `ArchiveFootageFloors`), never the custom window. */
95365
95542
  async deviceRetentionCutoffs(deviceId, nowMs) {
95366
- const settings = resolveRetentionSettings(await this.readDeviceBlob(deviceId));
95367
- return resolveRetentionCutoffs(settings, nowMs, settings.retentionMode === "follow-recordings" ? await this.earliestFootageMs(deviceId) : null);
95543
+ const raw = await this.readDeviceBlob(deviceId);
95544
+ return this.footageFloors.cutoffsFor(deviceId, resolveRetentionSettings(raw), nowMs);
95368
95545
  }
95369
95546
  /**
95370
95547
  * The devices one retention pass covers — see `retention/retention-scope.ts`
@@ -96822,6 +96999,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
96822
96999
  capName: r.capName,
96823
97000
  kind: r.kind,
96824
97001
  trackId: r.trackId,
97002
+ reason: r.via,
96825
97003
  sourceDeviceId: r.sourceDeviceId,
96826
97004
  selfCamera: r.selfCamera,
96827
97005
  hasMedia: r.hasMedia
@@ -96842,6 +97020,18 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
96842
97020
  });
96843
97021
  return;
96844
97022
  }
97023
+ if (r.cameraId !== null && r.reason === "not-materialized") {
97024
+ this.ctx.logger.warn("synthetic sensor-track NOT materialized", {
97025
+ tags: { deviceId: r.cameraId },
97026
+ meta: {
97027
+ capName: r.capName,
97028
+ kind: r.kind,
97029
+ reason: r.reason,
97030
+ sourceDeviceId: r.sourceDeviceId
97031
+ }
97032
+ });
97033
+ return;
97034
+ }
96845
97035
  this.ctx.logger.debug("synthetic sensor-track NOT projected", {
96846
97036
  tags: { deviceId: r.cameraId ?? r.sourceDeviceId },
96847
97037
  meta: {
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DW6-zerK.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-B28yTZRG.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.293",
3
+ "version": "1.2.295",
4
4
  "description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",
@@ -132,6 +132,7 @@
132
132
  "build:widgets": "vite build --config vite.widgets.pipeline-analytics.config.ts",
133
133
  "dev": "vite build --watch",
134
134
  "typecheck": "tsc --noEmit",
135
+ "test": "vitest run",
135
136
  "publish:npm": "npm publish --access public"
136
137
  },
137
138
  "peerDependencies": {