@camstack/addon-post-analysis 1.2.24 → 1.2.25

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 { A as errMsg, B as number, D as subKindsOf, E as plateGalleryCapability, F as nodePin, H as string, I as _enum, L as array, M as DeviceType, N as createEvent, O as videoclipsCapability, P as hydrateSchema, R as boolean, S as faceGalleryCapability, T as pipelineAnalyticsCapability, U as EventCategory, V as object, _ as buildEventKindDescriptor, a as NC_CONDITION_CATALOG, b as defineCustomActions, c as NcRuleInputSchema, d as NcTaxonomySchema, f as OpsLogEntrySchema, g as audioMetricsCapability, h as addonWidgetsSourceCapability, i as MACRO_LABELS, j as BaseAddon, k as zoneAnalyticsCapability, l as NcRulePatchSchema, m as TimelapseRuleSchema, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as TimelapseRuleInputSchema, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as cosineSimilarity, w as notificationRulesCapability, y as customAction, z as literal } from "../dist-h7dnARE_.mjs";
1
+ import { A as subKindsOf, B as array, C as defineCustomActions, D as notificationRulesCapability, F as DeviceType, G as string, H as literal, I as createEvent, K as EventCategory, L as hydrateSchema, M as zoneAnalyticsCapability, N as errMsg, O as pipelineAnalyticsCapability, P as BaseAddon, R as nodePin, S as customAction, T as faceGalleryCapability, U as number, V as boolean, W as object, _ as TimelapseRuleSchema, a as NC_CONDITION_CATALOG, b as buildEventKindDescriptor, c as NcRuleInputSchema, d as NcSnoozeInputSchema, f as NcSnoozeSchema, g as TimelapseRuleInputSchema, h as OpsLogEntrySchema, i as MACRO_LABELS, j as videoclipsCapability, k as plateGalleryCapability, l as NcRulePatchSchema, m as NcTaxonomySchema, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as NcSnoozeSuppressedSchema, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as addonWidgetsSourceCapability, x as cosineSimilarity, y as audioMetricsCapability, z as _enum } from "../dist-DsCSHZ9N.mjs";
2
2
  import { promises } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
@@ -1536,6 +1536,33 @@ function pointInPolygon$1(point, polygon) {
1536
1536
  }
1537
1537
  return inside;
1538
1538
  }
1539
+ /** Parametric line-segment intersection. Returns intersection point or null. */
1540
+ function lineIntersection(a, b) {
1541
+ const dx1 = a.p2.x - a.p1.x;
1542
+ const dy1 = a.p2.y - a.p1.y;
1543
+ const dx2 = b.p2.x - b.p1.x;
1544
+ const dy2 = b.p2.y - b.p1.y;
1545
+ const denom = dx1 * dy2 - dy1 * dx2;
1546
+ if (Math.abs(denom) < 1e-10) return null;
1547
+ const t = ((b.p1.x - a.p1.x) * dy2 - (b.p1.y - a.p1.y) * dx2) / denom;
1548
+ const u = ((b.p1.x - a.p1.x) * dy1 - (b.p1.y - a.p1.y) * dx1) / denom;
1549
+ if (t < 0 || t > 1 || u < 0 || u > 1) return null;
1550
+ return {
1551
+ x: a.p1.x + t * dx1,
1552
+ y: a.p1.y + t * dy1
1553
+ };
1554
+ }
1555
+ /** Detect tripwire crossing and direction via cross product. */
1556
+ function tripwireCrossing(prev, curr, wire) {
1557
+ if (!lineIntersection({
1558
+ p1: prev,
1559
+ p2: curr
1560
+ }, wire)) return null;
1561
+ return {
1562
+ crossed: true,
1563
+ direction: (curr.x - prev.x) * (wire.p2.y - wire.p1.y) - (curr.y - prev.y) * (wire.p2.x - wire.p1.x) > 0 ? "right" : "left"
1564
+ };
1565
+ }
1539
1566
  /** Centroid of a bounding box { x, y, w, h } */
1540
1567
  function bboxCentroid(bbox) {
1541
1568
  return {
@@ -1550,6 +1577,95 @@ function normalizeToPixel(p, width, height) {
1550
1577
  y: p.y * height
1551
1578
  };
1552
1579
  }
1580
+ var NONE = [];
1581
+ /**
1582
+ * Which side of the directed line the point is on.
1583
+ *
1584
+ * Sign of the 2D cross product of the line's own vector with (point − p1):
1585
+ * positive is the right-hand side of p1 → p2. Exactly zero means ON the line,
1586
+ * which is deliberately its own value: it is neither side, so it can neither
1587
+ * confirm nor cancel a crossing.
1588
+ */
1589
+ function sideOf(point, wire) {
1590
+ const cross = (wire.p2.x - wire.p1.x) * (point.y - wire.p1.y) - (wire.p2.y - wire.p1.y) * (point.x - wire.p1.x);
1591
+ if (cross > 0) return 1;
1592
+ if (cross < 0) return -1;
1593
+ return 0;
1594
+ }
1595
+ var TripwireTracker = class {
1596
+ config;
1597
+ /** `trackId` → `wireId` → state. Keyed by track so `forget` is one delete. */
1598
+ tracks = /* @__PURE__ */ new Map();
1599
+ constructor(config = {}) {
1600
+ this.config = {
1601
+ confirmFrames: 2,
1602
+ ...config
1603
+ };
1604
+ }
1605
+ /**
1606
+ * Feed one frame's centroid for one track against one wire.
1607
+ *
1608
+ * Returns the crossings CONFIRMED on this frame — usually none. Coordinates
1609
+ * are whatever space the caller uses consistently; the zone geometry is
1610
+ * normalised (0–1), so the frame processor passes normalised centroids.
1611
+ */
1612
+ observe(trackId, wireId, wire, centroid) {
1613
+ const wires = this.tracks.get(trackId) ?? /* @__PURE__ */ new Map();
1614
+ this.tracks.set(trackId, wires);
1615
+ const side = sideOf(centroid, wire);
1616
+ const state = wires.get(wireId);
1617
+ if (state === void 0) {
1618
+ wires.set(wireId, {
1619
+ last: centroid,
1620
+ confirmedSide: side,
1621
+ pendingSide: side,
1622
+ pendingFrames: 0,
1623
+ sawIntersection: false,
1624
+ pendingDirection: "in"
1625
+ });
1626
+ return NONE;
1627
+ }
1628
+ const crossedNow = tripwireCrossing(state.last, centroid, wire) !== null;
1629
+ state.last = centroid;
1630
+ if (side === 0) return NONE;
1631
+ if (side === state.confirmedSide) {
1632
+ state.pendingSide = side;
1633
+ state.pendingFrames = 0;
1634
+ state.sawIntersection = false;
1635
+ return NONE;
1636
+ }
1637
+ if (side !== state.pendingSide) {
1638
+ state.pendingSide = side;
1639
+ state.pendingFrames = 1;
1640
+ state.sawIntersection = crossedNow;
1641
+ state.pendingDirection = side > 0 ? "in" : "out";
1642
+ } else {
1643
+ state.pendingFrames += 1;
1644
+ state.sawIntersection = state.sawIntersection || crossedNow;
1645
+ }
1646
+ if (state.pendingFrames < this.config.confirmFrames) return NONE;
1647
+ if (!state.sawIntersection) {
1648
+ state.confirmedSide = side;
1649
+ state.pendingFrames = 0;
1650
+ return NONE;
1651
+ }
1652
+ state.confirmedSide = side;
1653
+ state.pendingFrames = 0;
1654
+ state.sawIntersection = false;
1655
+ return [{
1656
+ wireId,
1657
+ direction: state.pendingDirection
1658
+ }];
1659
+ }
1660
+ /** Drop a track. Called when the subject leaves, so state cannot accumulate. */
1661
+ forget(trackId) {
1662
+ this.tracks.delete(trackId);
1663
+ }
1664
+ /** Number of tracks held — a leak check for the caller's tests. */
1665
+ size() {
1666
+ return this.tracks.size;
1667
+ }
1668
+ };
1553
1669
  //#endregion
1554
1670
  //#region src/pipeline-analytics/pipeline/tracker/sort-tracker.ts
1555
1671
  var DEFAULT_TRACKER_CONFIG = {
@@ -2238,10 +2354,11 @@ var DetectionEventEmitter = class {
2238
2354
  const eventType = ZONE_TYPE_TO_EVENT[ze.type];
2239
2355
  if (!eventType) continue;
2240
2356
  if (!this.config.enabledTypes.includes(eventType)) continue;
2241
- const track = trackMap.get(ze.trackId);
2357
+ const present = trackMap.get(ze.trackId);
2358
+ const track = present ?? this.lastKnownTracks.get(ze.trackId);
2242
2359
  if (!track || track.trackAge < this.config.minTrackAge) continue;
2243
2360
  const state = stateMap.get(ze.trackId);
2244
- this.valuedTracks.add(ze.trackId);
2361
+ if (present) this.valuedTracks.add(ze.trackId);
2245
2362
  events.push({
2246
2363
  id: `evt-${++eventIdCounter}`,
2247
2364
  type: eventType,
@@ -2588,6 +2705,19 @@ var FrameProcessor = class {
2588
2705
  /** Zone crossings per track — the producer of `zone.enter` / `zone.exit`. */
2589
2706
  zoneTransitions = new ZoneTransitionTracker();
2590
2707
  /**
2708
+ * Tripwire crossings. Separate from `zoneTransitions` because a tripwire is
2709
+ * a DIRECTED line, not an area: membership is meaningless and the question is
2710
+ * which way the subject went over it.
2711
+ */
2712
+ tripwires = new TripwireTracker();
2713
+ /**
2714
+ * Last detection seen per track. An edge exit fires for a track that is
2715
+ * ABSENT from the frame, so there is no current detection to attach — this is
2716
+ * the one the subject had when it was last seen. Cleared with the rest of the
2717
+ * per-track caches when the track leaves.
2718
+ */
2719
+ lastDetectionByTrack = /* @__PURE__ */ new Map();
2720
+ /**
2591
2721
  * Last zone membership seen per track, so an event for a track that is GONE
2592
2722
  * this frame still carries the zones it was in. See the use site.
2593
2723
  */
@@ -2783,6 +2913,7 @@ var FrameProcessor = class {
2783
2913
  }
2784
2914
  this.lastZoneOverlaps = overlapsByTrack;
2785
2915
  this.lastZoneRejections = rejectedByTrack;
2916
+ for (const td of trackedDetections) this.lastDetectionByTrack.set(td.trackId, td);
2786
2917
  for (const [trackId, zoneIds] of zonesByTrack) this.lastZonesByTrack.set(trackId, zoneIds);
2787
2918
  for (const state of objectStates) {
2788
2919
  if (zonesByTrack.has(state.trackId)) continue;
@@ -2801,16 +2932,58 @@ var FrameProcessor = class {
2801
2932
  timestamp
2802
2933
  });
2803
2934
  };
2935
+ for (const state of objectStates) {
2936
+ if (state.state !== "leaving") continue;
2937
+ const stillInside = this.zoneTransitions.forget(state.trackId);
2938
+ this.tripwires.forget(state.trackId);
2939
+ if (stillInside.length === 0) continue;
2940
+ const last = this.lastDetectionByTrack.get(state.trackId);
2941
+ if (last === void 0) continue;
2942
+ for (const zoneId of stillInside) pushZoneEvent("zone-exit", zoneId, last);
2943
+ }
2804
2944
  for (const td of trackedDetections) {
2805
2945
  const crossings = this.zoneTransitions.observe(td.trackId, zonesByTrack.get(td.trackId) ?? []);
2806
2946
  for (const zoneId of crossings.entered) pushZoneEvent("zone-enter", zoneId, td);
2807
2947
  for (const zoneId of crossings.exited) pushZoneEvent("zone-exit", zoneId, td);
2808
2948
  }
2949
+ const wires = this.zones.filter((z) => z.kind === "tripwire" && z.polygon.length >= 2);
2950
+ if (wires.length > 0) for (const td of trackedDetections) {
2951
+ const centroid = bboxCentroid({
2952
+ x: td.bbox.x / frameWidth,
2953
+ y: td.bbox.y / frameHeight,
2954
+ w: td.bbox.w / frameWidth,
2955
+ h: td.bbox.h / frameHeight
2956
+ });
2957
+ for (const zone of wires) {
2958
+ const a = zone.polygon[0];
2959
+ const b = zone.polygon[1];
2960
+ if (a === void 0 || b === void 0) continue;
2961
+ const wire = {
2962
+ p1: {
2963
+ x: a.x,
2964
+ y: a.y
2965
+ },
2966
+ p2: {
2967
+ x: b.x,
2968
+ y: b.y
2969
+ }
2970
+ };
2971
+ for (const cross of this.tripwires.observe(td.trackId, zone.id, wire, centroid)) zoneEvents.push({
2972
+ type: "tripwire-cross",
2973
+ zoneId: zone.id,
2974
+ zoneName: zone.name,
2975
+ trackId: td.trackId,
2976
+ detection: td,
2977
+ direction: cross.direction,
2978
+ timestamp
2979
+ });
2980
+ }
2981
+ }
2809
2982
  const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, zoneEvents, [], String(this.deviceId));
2810
2983
  for (const state of objectStates) {
2811
2984
  if (state.state !== "leaving") continue;
2812
- this.zoneTransitions.forget(state.trackId);
2813
2985
  this.lastZonesByTrack.delete(state.trackId);
2986
+ this.lastDetectionByTrack.delete(state.trackId);
2814
2987
  }
2815
2988
  const tracked = trackedDetections.map((td) => {
2816
2989
  const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
@@ -2850,9 +3023,9 @@ var FrameProcessor = class {
2850
3023
  };
2851
3024
  });
2852
3025
  const crossingOf = (e) => {
2853
- const direction = e.type === "zone.enter" ? "enter" : e.type === "zone.exit" ? "exit" : void 0;
2854
- if (direction === void 0) return void 0;
2855
3026
  const ze = e.zoneEvents[0];
3027
+ const direction = e.type === "zone.enter" ? "enter" : e.type === "zone.exit" ? "exit" : e.type === "tripwire.cross" ? ze?.direction === "out" ? "exit" : "enter" : void 0;
3028
+ if (direction === void 0) return void 0;
2856
3029
  if (ze === void 0) return void 0;
2857
3030
  return {
2858
3031
  direction,
@@ -4944,6 +5117,29 @@ var NcDispatcher = class {
4944
5117
  } });
4945
5118
  return { ok: true };
4946
5119
  }
5120
+ if (this.deps.snoozeGate !== void 0) {
5121
+ const snoozeId = await this.deps.snoozeGate({
5122
+ ruleId: entry.ruleId,
5123
+ ruleName: entry.payload.ruleName,
5124
+ targetId: target.id,
5125
+ deviceId: entry.deviceId,
5126
+ ...target.ownerUserId !== void 0 ? { ownerUserId: target.ownerUserId } : {},
5127
+ at: this.now()
5128
+ });
5129
+ if (snoozeId !== null) {
5130
+ this.deps.logger.info("notification suppressed by snooze", {
5131
+ tags: { deviceId: entry.deviceId },
5132
+ meta: {
5133
+ snoozeId,
5134
+ ruleId: entry.ruleId,
5135
+ targetId: target.id,
5136
+ target: target.name,
5137
+ eventId: entry.recordId
5138
+ }
5139
+ });
5140
+ return { ok: true };
5141
+ }
5142
+ }
4947
5143
  const notification = await this.buildNotification(entry);
4948
5144
  try {
4949
5145
  const result = await this.deps.send({
@@ -4976,6 +5172,81 @@ var NcDispatcher = class {
4976
5172
  };
4977
5173
  }
4978
5174
  }
5175
+ /**
5176
+ * Deliver ONE end-of-window snooze digest to one target.
5177
+ *
5178
+ * Best-effort by design, and the return value says which it was rather than
5179
+ * throwing: the caller marks the window handled either way, because a digest
5180
+ * retried every drain tick for hours is worse than a digest missed once — and
5181
+ * a partially-delivered one must never be re-sent to the targets that already
5182
+ * have it. A failure is logged with the window id, so a missing summary is a
5183
+ * findable line rather than a silence inside a silence.
5184
+ *
5185
+ * The digest carries no media and no `tag`: it is not an event, and tagging
5186
+ * it with a rule id would let a phone collapse it into the very
5187
+ * notifications it is reporting on.
5188
+ */
5189
+ async deliverDigest(input) {
5190
+ let target;
5191
+ try {
5192
+ target = await this.resolveTarget(input.targetId);
5193
+ } catch (err) {
5194
+ this.deps.logger.warn("snooze digest: target catalog unreachable", { meta: {
5195
+ snoozeId: input.snoozeId,
5196
+ targetId: input.targetId,
5197
+ error: String(err)
5198
+ } });
5199
+ return false;
5200
+ }
5201
+ if (target === null || !target.enabled) {
5202
+ this.deps.logger.warn("snooze digest: target gone or disabled", { meta: {
5203
+ snoozeId: input.snoozeId,
5204
+ targetId: input.targetId
5205
+ } });
5206
+ return false;
5207
+ }
5208
+ try {
5209
+ const result = await this.deps.send({
5210
+ addonId: target.addonId,
5211
+ targetId: target.id,
5212
+ notification: {
5213
+ title: input.title,
5214
+ body: input.body,
5215
+ priority: 2
5216
+ }
5217
+ });
5218
+ if (!result.success) {
5219
+ this.deps.logger.warn("snooze digest send failed", { meta: {
5220
+ snoozeId: input.snoozeId,
5221
+ targetId: target.id,
5222
+ error: result.error
5223
+ } });
5224
+ return false;
5225
+ }
5226
+ this.deps.logger.info("snooze digest delivered", { meta: {
5227
+ snoozeId: input.snoozeId,
5228
+ target: target.name,
5229
+ hidden: input.total
5230
+ } });
5231
+ return true;
5232
+ } catch (err) {
5233
+ this.deps.logger.warn("snooze digest send threw", { meta: {
5234
+ snoozeId: input.snoozeId,
5235
+ targetId: target.id,
5236
+ error: String(err)
5237
+ } });
5238
+ return false;
5239
+ }
5240
+ }
5241
+ /** Device display names for a set of ids, for the digest's lines. */
5242
+ async resolveDeviceNames(deviceIds) {
5243
+ const out = /* @__PURE__ */ new Map();
5244
+ for (const id of new Set(deviceIds)) {
5245
+ const name = await this.deps.getDeviceName(id).catch(() => null);
5246
+ if (name !== null) out.set(id, name);
5247
+ }
5248
+ return out;
5249
+ }
4979
5250
  async resolveTarget(targetId) {
4980
5251
  const cached = this.cachedTarget(targetId);
4981
5252
  if (cached !== null) return cached;
@@ -5094,7 +5365,8 @@ var NcDispatcher = class {
5094
5365
  format: want.format,
5095
5366
  ...entry.payload.mediaClipPreRollSec !== void 0 ? { preRollSec: entry.payload.mediaClipPreRollSec } : {},
5096
5367
  ...entry.payload.mediaClipPostRollSec !== void 0 ? { postRollSec: entry.payload.mediaClipPostRollSec } : {},
5097
- ...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {}
5368
+ ...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {},
5369
+ ...entry.payload.mediaClipSpeed !== void 0 ? { speed: entry.payload.mediaClipSpeed } : {}
5098
5370
  });
5099
5371
  if (rendered !== null && rendered.byteLength > 0) {
5100
5372
  const bytes = new Uint8Array(rendered.byteLength);
@@ -6335,6 +6607,474 @@ var NcRuleStore = class {
6335
6607
  }
6336
6608
  };
6337
6609
  //#endregion
6610
+ //#region src/notification-center/snooze-digest.ts
6611
+ /** `14:03` in the node's local zone — a digest is read by a person, at home. */
6612
+ function clock(ms) {
6613
+ const d = new Date(ms);
6614
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
6615
+ }
6616
+ function plural(n, one, many) {
6617
+ return n === 1 ? `1 ${one}` : `${n} ${many}`;
6618
+ }
6619
+ /**
6620
+ * What the window covered, in the words the operator chose it with — the rule's
6621
+ * NAME, the camera's name. "a rule was snoozed" tells the reader nothing they
6622
+ * could act on.
6623
+ */
6624
+ function scopeLabel(snooze, rows, deviceNames) {
6625
+ switch (snooze.scope) {
6626
+ case "all": return "Everything";
6627
+ case "device": {
6628
+ const id = snooze.deviceId;
6629
+ if (id === void 0) return "A camera";
6630
+ return deviceNames.get(id) ?? `Camera ${id}`;
6631
+ }
6632
+ case "rule": {
6633
+ const named = rows.find((r) => r.ruleId === snooze.ruleId);
6634
+ return named !== void 0 ? `“${named.ruleName}”` : "A rule";
6635
+ }
6636
+ }
6637
+ }
6638
+ /**
6639
+ * One digest for ONE target — the counters passed in are already the rows
6640
+ * belonging to it, so the message says what THAT destination missed rather
6641
+ * than what the household did.
6642
+ *
6643
+ * Returns null when the window hid nothing: "snooze ended, 0 notifications" is
6644
+ * a notification the operator did not need, and sending it for every quiet
6645
+ * window is how a summary becomes the noise it was meant to suppress.
6646
+ */
6647
+ function buildSnoozeDigest(snooze, rows, deviceNames) {
6648
+ const total = rows.reduce((sum, r) => sum + r.count, 0);
6649
+ if (total === 0) return null;
6650
+ const from = clock(Math.min(...rows.map((r) => r.firstAt)));
6651
+ const to = clock(snooze.expiresAt);
6652
+ const title = `Snooze ended — ${plural(total, "notification", "notifications")} hidden`;
6653
+ const lines = rows.toSorted((a, b) => b.count - a.count || a.ruleName.localeCompare(b.ruleName)).map((r) => {
6654
+ const where = deviceNames.get(r.deviceId) ?? `camera ${r.deviceId}`;
6655
+ return `• ${r.ruleName} on ${where} — ${r.count}`;
6656
+ });
6657
+ return {
6658
+ title,
6659
+ body: [`${scopeLabel(snooze, rows, deviceNames)} was snoozed${snooze.global ? " for everyone" : ""}, ${from}–${to}.`, ...lines].join("\n"),
6660
+ total
6661
+ };
6662
+ }
6663
+ /** The distinct targets a window owes a digest to. */
6664
+ function digestTargets(rows) {
6665
+ return [...new Set(rows.map((r) => r.targetId))];
6666
+ }
6667
+ //#endregion
6668
+ //#region src/notification-center/snooze-policy.ts
6669
+ /** How broad a scope is, for choosing between overlapping snoozes. */
6670
+ var BREADTH = {
6671
+ rule: 1,
6672
+ device: 2,
6673
+ all: 3
6674
+ };
6675
+ function covers(entry, q) {
6676
+ if (q.nowMs < entry.startedAt) return false;
6677
+ if (q.nowMs >= entry.expiresAt) return false;
6678
+ if (!entry.global && entry.userId !== q.userId) return false;
6679
+ switch (entry.scope) {
6680
+ case "all": return true;
6681
+ case "rule": return entry.ruleId !== void 0 && entry.ruleId === q.ruleId;
6682
+ case "device": return entry.deviceId !== void 0 && entry.deviceId === q.deviceId;
6683
+ }
6684
+ }
6685
+ /**
6686
+ * The snooze that silences this notification, or null.
6687
+ *
6688
+ * The BROADEST match wins so one window produces one digest: an "all" snooze
6689
+ * overlapping a rule snooze should not leave the recipient two summaries for
6690
+ * the same silence.
6691
+ */
6692
+ function activeSnoozeFor(entries, q) {
6693
+ let best = null;
6694
+ for (const e of entries) {
6695
+ if (!covers(e, q)) continue;
6696
+ if (best === null || BREADTH[e.scope] > BREADTH[best.scope]) best = e;
6697
+ }
6698
+ return best;
6699
+ }
6700
+ /**
6701
+ * May this caller silence EVERYONE, not just themselves?
6702
+ *
6703
+ * Global is the only part of a snooze that can affect a person who did not ask
6704
+ * for it, so it is never reachable by accident:
6705
+ *
6706
+ * - **rule scope** — the rule itself must opt in (`snoozeAllowGlobal`, default
6707
+ * off). That is the gardener case: this rule is known to be noisy for
6708
+ * everybody in a way that is legitimate to silence for everybody.
6709
+ * - **device or whole-feed scope** — there is no rule to carry the opt-in, and
6710
+ * "silence this camera for the household" is an administrative act. Admin
6711
+ * only.
6712
+ *
6713
+ * A personal snooze needs no permission at all: it silences only its author.
6714
+ */
6715
+ function canSetGlobal(req) {
6716
+ if (req.isAdmin) return true;
6717
+ if (req.scope !== "rule") return false;
6718
+ return req.ruleAllowsGlobal === true;
6719
+ }
6720
+ /** Entries whose window has closed and whose digest has not been sent yet. */
6721
+ function dueSummaries(entries, nowMs, alreadySent) {
6722
+ return entries.filter((e) => e.summary && nowMs >= e.expiresAt && !alreadySent.has(e.id));
6723
+ }
6724
+ //#endregion
6725
+ //#region src/notification-center/snooze-store.ts
6726
+ /**
6727
+ * NcSnoozeStore — the durable half of snooze.
6728
+ *
6729
+ * Two collections, and the split matters:
6730
+ *
6731
+ * - **windows** (`notification-center:snoozes`) — small, mirrored into RAM,
6732
+ * read on the dispatcher's hot path with zero I/O. Refreshed on the drain
6733
+ * tick, so a window opened on the hub takes effect on the evaluating node
6734
+ * within one tick (D8: reconcile, never trust an event to arrive).
6735
+ * - **suppression counters** (`notification-center:snooze-suppressed`) — one
6736
+ * row per (window, target, rule, camera), holding a COUNT and the first/last
6737
+ * instant. Not one row per hidden notification: a snooze exists because
6738
+ * something is noisy, so the row-per-event version of this table is unbounded
6739
+ * exactly when it is used most. The digest reads "42 events, 14:03–17:58",
6740
+ * which is the aggregate anyway.
6741
+ *
6742
+ * Expiry is never a job here either. Nothing in this file ends a window; the
6743
+ * clock does, and {@link NcSnoozeStore.prune} only reclaims rows long after the
6744
+ * fact. A prune that never runs delays a digest and wastes a few KB — it can
6745
+ * never leave the operator silenced.
6746
+ */
6747
+ var NC_SNOOZES_COLLECTION = "notification-center:snoozes";
6748
+ var NC_SNOOZE_SUPPRESSED_COLLECTION = "notification-center:snooze-suppressed";
6749
+ var NC_SNOOZES_COLUMNS = [
6750
+ {
6751
+ name: "id",
6752
+ type: "TEXT",
6753
+ primaryKey: true,
6754
+ notNull: true
6755
+ },
6756
+ {
6757
+ name: "userId",
6758
+ type: "TEXT",
6759
+ notNull: true
6760
+ },
6761
+ {
6762
+ name: "expiresAt",
6763
+ type: "INTEGER",
6764
+ notNull: true
6765
+ },
6766
+ (
6767
+ /** The FULL window (Zod-validated on read); the scalars are projections. */
6768
+ {
6769
+ name: "snooze",
6770
+ type: "JSON",
6771
+ notNull: true
6772
+ })
6773
+ ];
6774
+ var NC_SNOOZES_INDEXES = [{
6775
+ name: "idx_nc_snoozes_expires",
6776
+ columns: ["expiresAt"]
6777
+ }];
6778
+ var NC_SNOOZE_SUPPRESSED_COLUMNS = [
6779
+ {
6780
+ name: "id",
6781
+ type: "TEXT",
6782
+ primaryKey: true,
6783
+ notNull: true
6784
+ },
6785
+ {
6786
+ name: "snoozeId",
6787
+ type: "TEXT",
6788
+ notNull: true
6789
+ },
6790
+ {
6791
+ name: "targetId",
6792
+ type: "TEXT",
6793
+ notNull: true
6794
+ },
6795
+ {
6796
+ name: "ruleId",
6797
+ type: "TEXT",
6798
+ notNull: true
6799
+ },
6800
+ {
6801
+ name: "ruleName",
6802
+ type: "TEXT",
6803
+ notNull: true
6804
+ },
6805
+ {
6806
+ name: "deviceId",
6807
+ type: "INTEGER",
6808
+ notNull: true
6809
+ },
6810
+ {
6811
+ name: "count",
6812
+ type: "INTEGER",
6813
+ notNull: true
6814
+ },
6815
+ {
6816
+ name: "firstAt",
6817
+ type: "INTEGER",
6818
+ notNull: true
6819
+ },
6820
+ {
6821
+ name: "lastAt",
6822
+ type: "INTEGER",
6823
+ notNull: true
6824
+ }
6825
+ ];
6826
+ var NC_SNOOZE_SUPPRESSED_INDEXES = [{
6827
+ name: "idx_nc_snooze_suppressed_snooze",
6828
+ columns: ["snoozeId"]
6829
+ }];
6830
+ /** How long a closed window's rows survive, for a retried digest and for
6831
+ * "what did I miss" to still answer after a restart. */
6832
+ var PRUNE_HORIZON_MS = 6 * 36e5;
6833
+ /** Counter key — one row per (window, target, rule, camera). */
6834
+ function counterKey(s) {
6835
+ return `${s.snoozeId}|${s.targetId}|${s.ruleId}|${s.deviceId}`;
6836
+ }
6837
+ var NcSnoozeStore = class {
6838
+ byId = /* @__PURE__ */ new Map();
6839
+ store;
6840
+ logger;
6841
+ now;
6842
+ newId;
6843
+ constructor(deps) {
6844
+ this.store = deps.store;
6845
+ this.logger = deps.logger;
6846
+ this.now = deps.now ?? (() => Date.now());
6847
+ this.newId = deps.newId ?? (() => randomUUID());
6848
+ }
6849
+ static async declare(store) {
6850
+ await store.declareCollection.mutate({
6851
+ collection: NC_SNOOZES_COLLECTION,
6852
+ columns: [...NC_SNOOZES_COLUMNS],
6853
+ indexes: [...NC_SNOOZES_INDEXES]
6854
+ });
6855
+ await store.declareCollection.mutate({
6856
+ collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
6857
+ columns: [...NC_SNOOZE_SUPPRESSED_COLUMNS],
6858
+ indexes: [...NC_SNOOZE_SUPPRESSED_INDEXES]
6859
+ });
6860
+ }
6861
+ /**
6862
+ * (Re)hydrate every window. Called at boot and on the drain tick — the table
6863
+ * holds active windows and recently closed ones, so it stays small enough to
6864
+ * read whole. A row whose JSON no longer validates is SKIPPED: a malformed
6865
+ * window must never be able to silence anything.
6866
+ */
6867
+ async load() {
6868
+ try {
6869
+ const rows = await this.store.query.query({
6870
+ collection: NC_SNOOZES_COLLECTION,
6871
+ filter: { limit: 1e3 }
6872
+ });
6873
+ this.byId.clear();
6874
+ let skipped = 0;
6875
+ for (const row of rows) {
6876
+ const parsed = NcSnoozeSchema.safeParse(row.data["snooze"]);
6877
+ if (!parsed.success) {
6878
+ skipped += 1;
6879
+ continue;
6880
+ }
6881
+ this.byId.set(parsed.data.id, parsed.data);
6882
+ }
6883
+ if (skipped > 0) this.logger.warn("snooze rows skipped as malformed — they silence NOTHING", { meta: { skipped } });
6884
+ } catch (err) {
6885
+ this.logger.warn("snooze load failed — keeping the windows already in memory", { meta: { error: String(err) } });
6886
+ }
6887
+ }
6888
+ /** Every known window, newest first. */
6889
+ list() {
6890
+ return [...this.byId.values()].toSorted((a, b) => b.startedAt - a.startedAt);
6891
+ }
6892
+ /** The windows a caller may SEE: their own, plus the global ones that
6893
+ * silence them. Never another person's private silence. */
6894
+ listVisibleTo(userId, isAdmin) {
6895
+ if (isAdmin) return this.list();
6896
+ return this.list().filter((s) => s.userId === userId || s.global);
6897
+ }
6898
+ get(snoozeId) {
6899
+ return this.byId.get(snoozeId) ?? null;
6900
+ }
6901
+ /** Hot-path read — the window silencing this delivery, or null. */
6902
+ activeFor(query) {
6903
+ return activeSnoozeFor([...this.byId.values()], query);
6904
+ }
6905
+ /**
6906
+ * Open a window. Permission for `global` is the CALLER's problem (see
6907
+ * `canSetGlobal`) — this store persists what it is given, exactly like the
6908
+ * rule store leaves ownership to the bridge action.
6909
+ */
6910
+ async create(input) {
6911
+ const startedAt = this.now();
6912
+ const snooze = NcSnoozeSchema.parse({
6913
+ id: this.newId(),
6914
+ userId: input.userId,
6915
+ scope: input.scope,
6916
+ ...input.ruleId !== void 0 ? { ruleId: input.ruleId } : {},
6917
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
6918
+ startedAt,
6919
+ expiresAt: startedAt + input.durationMinutes * 6e4,
6920
+ global: input.global,
6921
+ summary: input.summary
6922
+ });
6923
+ await this.persist(snooze);
6924
+ this.byId.set(snooze.id, snooze);
6925
+ return snooze;
6926
+ }
6927
+ /**
6928
+ * End a window NOW by moving its expiry to this instant — never by deleting
6929
+ * it. The digest of what it hid is still owed to whoever opened it, and a
6930
+ * deleted row owes nothing.
6931
+ */
6932
+ async cancel(snoozeId) {
6933
+ const existing = this.byId.get(snoozeId);
6934
+ if (existing === void 0) return null;
6935
+ const now = this.now();
6936
+ if (existing.expiresAt <= now) return existing;
6937
+ const ended = {
6938
+ ...existing,
6939
+ expiresAt: now
6940
+ };
6941
+ await this.persist(ended);
6942
+ this.byId.set(ended.id, ended);
6943
+ return ended;
6944
+ }
6945
+ /**
6946
+ * Record that a window hid one notification. Read-modify-write on the
6947
+ * counter row; best-effort, because losing a line of a SUMMARY must never
6948
+ * cost the suppression decision that produced it.
6949
+ */
6950
+ async recordSuppressed(s) {
6951
+ const key = counterKey(s);
6952
+ try {
6953
+ const existing = await this.readCounter(key);
6954
+ const next = {
6955
+ snoozeId: s.snoozeId,
6956
+ targetId: s.targetId,
6957
+ ruleId: s.ruleId,
6958
+ ruleName: s.ruleName,
6959
+ deviceId: s.deviceId,
6960
+ count: (existing?.count ?? 0) + 1,
6961
+ firstAt: existing?.firstAt ?? s.at,
6962
+ lastAt: s.at
6963
+ };
6964
+ await this.store.set.mutate({
6965
+ collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
6966
+ key,
6967
+ value: { ...next }
6968
+ });
6969
+ } catch (err) {
6970
+ this.logger.warn("snooze suppression counter write failed (digest will under-report)", {
6971
+ tags: { deviceId: s.deviceId },
6972
+ meta: {
6973
+ snoozeId: s.snoozeId,
6974
+ ruleId: s.ruleId,
6975
+ error: String(err)
6976
+ }
6977
+ });
6978
+ }
6979
+ }
6980
+ /** Windows that have closed and still owe a digest. */
6981
+ dueDigests(nowMs) {
6982
+ const sent = new Set([...this.byId.values()].filter((s) => s.digestSentAt !== void 0).map((s) => s.id));
6983
+ return dueSummaries([...this.byId.values()], nowMs, sent);
6984
+ }
6985
+ /** What one window hid, grouped per target. */
6986
+ async suppressedFor(snoozeId) {
6987
+ try {
6988
+ const rows = await this.store.query.query({
6989
+ collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
6990
+ filter: {
6991
+ where: { snoozeId },
6992
+ limit: 1e3
6993
+ }
6994
+ });
6995
+ const out = [];
6996
+ for (const row of rows) {
6997
+ const parsed = NcSnoozeSuppressedSchema.safeParse(row.data);
6998
+ if (parsed.success) out.push(parsed.data);
6999
+ }
7000
+ return out;
7001
+ } catch (err) {
7002
+ this.logger.warn("snooze suppression read failed", { meta: {
7003
+ snoozeId,
7004
+ error: String(err)
7005
+ } });
7006
+ return [];
7007
+ }
7008
+ }
7009
+ /**
7010
+ * Mark a digest as handled.
7011
+ *
7012
+ * Called after the ATTEMPT, not after a success: a digest that cannot be
7013
+ * delivered is not worth retrying every two seconds for six hours, and a
7014
+ * partially-sent one must not be re-sent to the targets that already got it.
7015
+ * The failure is logged with the window id — a missing digest is then a
7016
+ * findable line, not a silence inside a silence.
7017
+ */
7018
+ async markDigestSent(snoozeId) {
7019
+ const existing = this.byId.get(snoozeId);
7020
+ if (existing === void 0) return;
7021
+ const marked = {
7022
+ ...existing,
7023
+ digestSentAt: this.now()
7024
+ };
7025
+ await this.persist(marked);
7026
+ this.byId.set(marked.id, marked);
7027
+ }
7028
+ /**
7029
+ * Drop windows that closed more than {@link PRUNE_HORIZON_MS} ago, and their
7030
+ * counters. Only ever touches CLOSED windows — an active one cannot be
7031
+ * pruned into oblivion by a clock argument.
7032
+ */
7033
+ async prune(nowMs) {
7034
+ const horizon = nowMs - PRUNE_HORIZON_MS;
7035
+ const stale = [...this.byId.values()].filter((s) => s.expiresAt < horizon);
7036
+ let dropped = 0;
7037
+ for (const s of stale) try {
7038
+ await this.store.deleteWhere.mutate({
7039
+ collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
7040
+ filter: { where: { snoozeId: s.id } }
7041
+ });
7042
+ await this.store.delete.mutate({
7043
+ collection: NC_SNOOZES_COLLECTION,
7044
+ key: s.id
7045
+ });
7046
+ this.byId.delete(s.id);
7047
+ dropped += 1;
7048
+ } catch (err) {
7049
+ this.logger.warn("snooze prune failed", { meta: {
7050
+ snoozeId: s.id,
7051
+ error: String(err)
7052
+ } });
7053
+ }
7054
+ return dropped;
7055
+ }
7056
+ /** `get` returns the stored VALUE, not a `{ id, data }` row. */
7057
+ async readCounter(key) {
7058
+ const value = await this.store.get.query({
7059
+ collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
7060
+ key
7061
+ });
7062
+ const parsed = NcSnoozeSuppressedSchema.safeParse(value);
7063
+ return parsed.success ? parsed.data : null;
7064
+ }
7065
+ async persist(snooze) {
7066
+ await this.store.set.mutate({
7067
+ collection: NC_SNOOZES_COLLECTION,
7068
+ key: snooze.id,
7069
+ value: {
7070
+ userId: snooze.userId,
7071
+ expiresAt: snooze.expiresAt,
7072
+ snooze
7073
+ }
7074
+ });
7075
+ }
7076
+ };
7077
+ //#endregion
6338
7078
  //#region src/notification-center/timelapse/timelapse-store.ts
6339
7079
  var NC_TIMELAPSE_RULES_COLLECTION = "notification-center:timelapse-rules";
6340
7080
  var NC_TIMELAPSE_RULES_COLUMNS = [
@@ -6696,6 +7436,9 @@ var NotificationCenter = class NotificationCenter {
6696
7436
  * snapshots; watched keys are recomputed from the enabled occupancy rules. */
6697
7437
  occupancyWatcher = new OccupancyWatcher();
6698
7438
  occupancyStore;
7439
+ /** Snooze windows + what they hid. Mirrored into RAM; the dispatcher's gate
7440
+ * reads it with no I/O, the drain tick refreshes it. */
7441
+ snoozes;
6699
7442
  /** True when ≥1 enabled `device-event` rule declares an occupancy condition —
6700
7443
  * the watcher is idle (zero per-frame cost) otherwise. */
6701
7444
  occupancyEnabled = false;
@@ -6728,10 +7471,17 @@ var NotificationCenter = class NotificationCenter {
6728
7471
  store: deps.store,
6729
7472
  logger: this.logger.child("occupancy")
6730
7473
  });
7474
+ this.snoozes = new NcSnoozeStore({
7475
+ store: deps.store,
7476
+ logger: this.logger.child("snooze"),
7477
+ ...deps.now !== void 0 ? { now: deps.now } : {}
7478
+ });
6731
7479
  this.dispatcher = new NcDispatcher({
6732
7480
  ...deps.dispatcher,
6733
7481
  logger: this.logger.child("dispatch"),
6734
- isRuleTargetDisabled: (ruleId, targetId) => this.rules.isRuleTargetDisabled(ruleId, targetId)
7482
+ ...deps.now !== void 0 ? { now: deps.now } : {},
7483
+ isRuleTargetDisabled: (ruleId, targetId) => this.rules.isRuleTargetDisabled(ruleId, targetId),
7484
+ snoozeGate: (input) => this.snoozeGate(input)
6735
7485
  });
6736
7486
  this.outbox = new NcOutbox({
6737
7487
  store: deps.store,
@@ -6761,6 +7511,7 @@ var NotificationCenter = class NotificationCenter {
6761
7511
  await NcRuleStore.declare(store);
6762
7512
  await NcOutbox.declare(store);
6763
7513
  await OccupancyStore.declare(store);
7514
+ await NcSnoozeStore.declare(store);
6764
7515
  await TimelapseStore.declare(store);
6765
7516
  }
6766
7517
  /**
@@ -6771,6 +7522,7 @@ var NotificationCenter = class NotificationCenter {
6771
7522
  */
6772
7523
  async start(opts) {
6773
7524
  await this.rules.load();
7525
+ await this.snoozes.load();
6774
7526
  this.refreshOccupancyWatch();
6775
7527
  if (!opts.evaluation) return;
6776
7528
  this.evaluationActive = true;
@@ -6999,6 +7751,12 @@ var NotificationCenter = class NotificationCenter {
6999
7751
  this.logger.debug("getHistory: skipping malformed outbox row", { meta: { id: entry.id } });
7000
7752
  }
7001
7753
  return { entries: mapped };
7754
+ },
7755
+ listSnoozes: async ({ caller }) => ({ snoozes: [...this.snoozes.listVisibleTo(caller.userId, caller.isAdmin)] }),
7756
+ createSnooze: async ({ snooze, caller }) => ({ snooze: await this.createSnooze(snooze, caller) }),
7757
+ cancelSnooze: async ({ snoozeId, caller }) => {
7758
+ await this.cancelSnooze(snoozeId, caller);
7759
+ return { success: true };
7002
7760
  }
7003
7761
  };
7004
7762
  }
@@ -7129,6 +7887,7 @@ var NotificationCenter = class NotificationCenter {
7129
7887
  ...rule.media.gif === true ? { mediaGif: true } : {},
7130
7888
  ...rule.media.clip === true ? { mediaClip: true } : {},
7131
7889
  ...rule.media.clipPreRollSec !== void 0 ? { mediaClipPreRollSec: rule.media.clipPreRollSec } : {},
7890
+ ...rule.media?.clipSpeed !== void 0 ? { mediaClipSpeed: rule.media.clipSpeed } : {},
7132
7891
  ...rule.media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: rule.media.clipPostRollSec } : {},
7133
7892
  ...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
7134
7893
  ...target.params !== void 0 ? { params: target.params } : {},
@@ -7321,9 +8080,127 @@ var NotificationCenter = class NotificationCenter {
7321
8080
  await this.outbox.setWatermark(now);
7322
8081
  }
7323
8082
  async drainTick() {
8083
+ await this.snoozes.load();
7324
8084
  await this.outbox.drainOnce();
8085
+ await this.deliverDueDigests();
7325
8086
  this.drainTicks += 1;
7326
- if (this.drainTicks % WATERMARK_EVERY_TICKS === 0) await this.outbox.setWatermark(this.now());
8087
+ if (this.drainTicks % WATERMARK_EVERY_TICKS === 0) {
8088
+ await this.outbox.setWatermark(this.now());
8089
+ await this.snoozes.prune(this.now());
8090
+ }
8091
+ }
8092
+ /**
8093
+ * The dispatcher's gate: is this delivery silenced, and if so, count it.
8094
+ *
8095
+ * Returns the window's id (the dispatcher logs it) or null. Recording is
8096
+ * awaited rather than fired off so a suppression cannot be lost to a shutdown
8097
+ * between the decision and the row — an under-reported digest is the one
8098
+ * failure of this feature the operator cannot detect.
8099
+ */
8100
+ async snoozeGate(input) {
8101
+ const active = this.snoozes.activeFor({
8102
+ nowMs: input.at,
8103
+ ...input.ownerUserId !== void 0 ? { userId: input.ownerUserId } : {},
8104
+ ruleId: input.ruleId,
8105
+ deviceId: input.deviceId
8106
+ });
8107
+ if (active === null) return null;
8108
+ if (active.summary) await this.snoozes.recordSuppressed({
8109
+ snoozeId: active.id,
8110
+ targetId: input.targetId,
8111
+ ruleId: input.ruleId,
8112
+ ruleName: input.ruleName,
8113
+ deviceId: input.deviceId,
8114
+ at: input.at
8115
+ });
8116
+ return active.id;
8117
+ }
8118
+ /**
8119
+ * Send the summary owed by every window that has closed since the last tick.
8120
+ *
8121
+ * The digest goes exactly where the silenced notifications would have gone —
8122
+ * one per target that was actually suppressed — so it needs no routing of its
8123
+ * own and can never reach someone the snooze never touched.
8124
+ */
8125
+ async deliverDueDigests() {
8126
+ const due = this.snoozes.dueDigests(this.now());
8127
+ for (const snooze of due) try {
8128
+ const rows = await this.snoozes.suppressedFor(snooze.id);
8129
+ const names = await this.dispatcher.resolveDeviceNames(rows.map((r) => r.deviceId));
8130
+ for (const targetId of digestTargets(rows)) {
8131
+ const digest = buildSnoozeDigest(snooze, rows.filter((r) => r.targetId === targetId), names);
8132
+ if (digest === null) continue;
8133
+ await this.dispatcher.deliverDigest({
8134
+ snoozeId: snooze.id,
8135
+ targetId,
8136
+ title: digest.title,
8137
+ body: digest.body,
8138
+ total: digest.total
8139
+ });
8140
+ }
8141
+ await this.snoozes.markDigestSent(snooze.id);
8142
+ } catch (err) {
8143
+ this.logger.warn("snooze digest tick failed", { meta: {
8144
+ snoozeId: snooze.id,
8145
+ error: String(err)
8146
+ } });
8147
+ }
8148
+ }
8149
+ /** The durable snooze store — the cap provider and the `nc.*` bridge serve
8150
+ * CRUD from it, the same write-through cache the gate reads. */
8151
+ get snoozeStore() {
8152
+ return this.snoozes;
8153
+ }
8154
+ /**
8155
+ * Open a window on behalf of a caller, with the permission check that makes
8156
+ * `global` safe. Lives here rather than in the store because it needs the
8157
+ * RULE (its `snoozeAllowGlobal` opt-in), and a permission decision that can
8158
+ * silence other people belongs where both halves are visible.
8159
+ */
8160
+ async createSnooze(input, caller) {
8161
+ if (input.scope === "rule" && input.ruleId === void 0) throw new Error("a rule snooze needs a ruleId");
8162
+ if (input.scope === "device" && input.deviceId === void 0) throw new Error("a camera snooze needs a deviceId");
8163
+ const wantsGlobal = input.global === true;
8164
+ if (wantsGlobal) {
8165
+ const rule = input.ruleId !== void 0 ? this.rules.get(input.ruleId) : null;
8166
+ if (!canSetGlobal({
8167
+ scope: input.scope,
8168
+ ...rule !== null ? { ruleAllowsGlobal: rule.snoozeAllowGlobal === true } : {},
8169
+ isAdmin: caller.isAdmin
8170
+ })) throw new Error("forbidden: this snooze may not be made global");
8171
+ }
8172
+ const snooze = await this.snoozes.create({
8173
+ userId: caller.userId,
8174
+ scope: input.scope,
8175
+ ...input.ruleId !== void 0 ? { ruleId: input.ruleId } : {},
8176
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
8177
+ durationMinutes: input.durationMinutes,
8178
+ global: wantsGlobal,
8179
+ summary: input.summary !== false
8180
+ });
8181
+ this.logger.info("snooze opened", {
8182
+ ...snooze.deviceId !== void 0 ? { tags: { deviceId: snooze.deviceId } } : {},
8183
+ meta: {
8184
+ snoozeId: snooze.id,
8185
+ scope: snooze.scope,
8186
+ by: caller.userId,
8187
+ global: snooze.global,
8188
+ minutes: input.durationMinutes,
8189
+ ...snooze.ruleId !== void 0 ? { ruleId: snooze.ruleId } : {}
8190
+ }
8191
+ });
8192
+ return snooze;
8193
+ }
8194
+ /** End a window early. A user may end their own; an admin may end any. */
8195
+ async cancelSnooze(snoozeId, caller) {
8196
+ const existing = this.snoozes.get(snoozeId);
8197
+ if (existing === null) throw new Error(`snooze not found: ${snoozeId}`);
8198
+ if (!caller.isAdmin && existing.userId !== caller.userId) throw new Error(`forbidden: snooze not owned: ${snoozeId}`);
8199
+ await this.snoozes.cancel(snoozeId);
8200
+ this.logger.info("snooze cancelled", { meta: {
8201
+ snoozeId,
8202
+ by: caller.userId
8203
+ } });
7327
8204
  }
7328
8205
  /** The "at least one addressee" invariant lives here, not in Zod: the
7329
8206
  * schema allows empty `targets` (a users-only rule) and a cross-field
@@ -7440,6 +8317,21 @@ var ncActions = defineCustomActions({
7440
8317
  }), object({ success: literal(true) }), {
7441
8318
  kind: "mutation",
7442
8319
  caller: "required"
8320
+ }),
8321
+ /**
8322
+ * Snooze, from the phone — the surface that matters most for this feature,
8323
+ * because "silence this until morning" is a thing you press in bed, not at a
8324
+ * desk. Scoped to the caller: their own windows plus the global ones that
8325
+ * silence them.
8326
+ */
8327
+ "nc.listSnoozes": customAction(object({}), object({ snoozes: array(NcSnoozeSchema) }), { caller: "required" }),
8328
+ "nc.createSnooze": customAction(object({ snooze: NcSnoozeInputSchema }), object({ snooze: NcSnoozeSchema }), {
8329
+ kind: "mutation",
8330
+ caller: "required"
8331
+ }),
8332
+ "nc.cancelSnooze": customAction(object({ snoozeId: string() }), object({ success: literal(true) }), {
8333
+ kind: "mutation",
8334
+ caller: "required"
7443
8335
  })
7444
8336
  });
7445
8337
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
@@ -7532,6 +8424,28 @@ function makeNcActionHandlers(deps) {
7532
8424
  } });
7533
8425
  return { success: true };
7534
8426
  },
8427
+ "nc.listSnoozes": async (_input, caller) => {
8428
+ const c = requireCaller(caller);
8429
+ return { snoozes: [...deps.snooze.list(c.userId, c.isAdmin)] };
8430
+ },
8431
+ "nc.createSnooze": async (input, caller) => {
8432
+ const c = requireCaller(caller);
8433
+ const parsed = NcSnoozeInputSchema.parse(input.snooze);
8434
+ if (parsed.ruleId !== void 0) assertRuleVisible(parsed.ruleId, c);
8435
+ const snooze = await deps.snooze.create(parsed, c);
8436
+ deps.logger.info("nc snooze opened", { meta: {
8437
+ snoozeId: snooze.id,
8438
+ scope: snooze.scope,
8439
+ by: c.userId,
8440
+ global: snooze.global
8441
+ } });
8442
+ return { snooze };
8443
+ },
8444
+ "nc.cancelSnooze": async (input, caller) => {
8445
+ const c = requireCaller(caller);
8446
+ await deps.snooze.cancel(input.snoozeId, c);
8447
+ return { success: true };
8448
+ },
7535
8449
  "nc.setRuleTargetEnabled": async (input, caller) => {
7536
8450
  const c = requireCaller(caller);
7537
8451
  assertRuleVisible(input.ruleId, c);
@@ -11860,6 +12774,37 @@ var OpsLogStore = class {
11860
12774
  }
11861
12775
  return out;
11862
12776
  }
12777
+ /**
12778
+ * Delete audit entries older than `cutoffMs`. Returns how many went.
12779
+ *
12780
+ * The audit log is the one collection that records everyone else's
12781
+ * retention and had none of its own — it grows monotonically for the life of
12782
+ * the node (11,519 rows on the operator's hub before this existed).
12783
+ *
12784
+ * ONE statement, not a select-then-delete drain: `deleteWhere` is exactly
12785
+ * the bulk path that removed the N+1 loops from every other retention path.
12786
+ * `whereBetween` on `at` is inclusive, so `[0, cutoffMs - 1]` selects
12787
+ * strictly-older rows — the same boundary `EventStore.pruneBefore` uses.
12788
+ *
12789
+ * BEST-EFFORT, like `append`: a failed audit trim must never fail the
12790
+ * retention sweep that called it. It warns rather than throwing, because a
12791
+ * trim that silently does nothing is how a table gets to 11,519 rows.
12792
+ */
12793
+ async pruneBefore(cutoffMs) {
12794
+ try {
12795
+ const { deleted } = await this.store.deleteWhere.mutate({
12796
+ collection: OPS_LOG_COLLECTION,
12797
+ filter: { whereBetween: { at: [0, cutoffMs - 1] } }
12798
+ });
12799
+ return deleted;
12800
+ } catch (err) {
12801
+ this.logger.warn("ops-log: prune failed — the audit log will keep growing", { meta: {
12802
+ cutoffMs,
12803
+ error: err instanceof Error ? err.message : String(err)
12804
+ } });
12805
+ return 0;
12806
+ }
12807
+ }
11863
12808
  };
11864
12809
  /** SQLite stores nullable columns as `null`; the row schema uses `.nullable()`
11865
12810
  * for deviceId/detail (accepts null) but drop any stray null on required
@@ -20335,6 +21280,12 @@ var NcArtifactSecretSchema = string();
20335
21280
  var EmbeddingEnabledSchema = boolean();
20336
21281
  var SILENCE_FLOOR_DBFS = -55;
20337
21282
  var RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
21283
+ /**
21284
+ * How long the events ops-log is kept. 30 days: long enough to answer "what
21285
+ * deleted my footage last month", short enough that the audit never becomes
21286
+ * the largest thing the node stores.
21287
+ */
21288
+ var OPS_LOG_RETENTION_MS = 720 * 60 * 6e4;
20338
21289
  /** Throttle for the "overlay synthesis failed" warn — one line / minute / device. */
20339
21290
  var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
20340
21291
  var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
@@ -20848,6 +21799,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
20848
21799
  return makeNcActionHandlers({
20849
21800
  ruleStore: center.ruleStore,
20850
21801
  logger: this.ctx.logger.child("nc-actions"),
21802
+ snooze: {
21803
+ list: (userId, isAdmin) => center.snoozeStore.listVisibleTo(userId, isAdmin),
21804
+ create: (input, caller) => center.createSnooze(input, caller),
21805
+ cancel: (snoozeId, caller) => center.cancelSnooze(snoozeId, caller)
21806
+ },
20851
21807
  listCallerTargetIds: async (userId) => {
20852
21808
  return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
20853
21809
  }
@@ -21260,6 +22216,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21260
22216
  postRollSec: req.postRollSec ?? NC_FOOTAGE_POST_ROLL_SEC,
21261
22217
  maxWidth: NC_FOOTAGE_MAX_WIDTH,
21262
22218
  fps: NC_FOOTAGE_FPS,
22219
+ ...req.speed !== void 0 ? { speed: req.speed } : {},
21263
22220
  ...req.profile === "high" || req.profile === "mid" || req.profile === "low" ? { profile: req.profile } : {}
21264
22221
  });
21265
22222
  const buf = Buffer.from(res.base64, "base64");
@@ -21417,6 +22374,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21417
22374
  this.sweepRetention();
21418
22375
  this.runTrackRetentionSweep();
21419
22376
  this.stationaryRegistry?.sweep(Date.now());
22377
+ this.eventsOpsLog?.pruneBefore(Date.now() - OPS_LOG_RETENTION_MS);
21420
22378
  }, RETENTION_SWEEP_INTERVAL_MS);
21421
22379
  }
21422
22380
  /** Cap provider registrations returned from `onInitialize` — the widgets