@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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-MQmqxd7v.js");
5
+ const require_dist = require("../dist-Bi1fiPc0.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_path = require("node:path");
8
8
  node_path = require_dist.__toESM(node_path);
@@ -1542,6 +1542,33 @@ function pointInPolygon$1(point, polygon) {
1542
1542
  }
1543
1543
  return inside;
1544
1544
  }
1545
+ /** Parametric line-segment intersection. Returns intersection point or null. */
1546
+ function lineIntersection(a, b) {
1547
+ const dx1 = a.p2.x - a.p1.x;
1548
+ const dy1 = a.p2.y - a.p1.y;
1549
+ const dx2 = b.p2.x - b.p1.x;
1550
+ const dy2 = b.p2.y - b.p1.y;
1551
+ const denom = dx1 * dy2 - dy1 * dx2;
1552
+ if (Math.abs(denom) < 1e-10) return null;
1553
+ const t = ((b.p1.x - a.p1.x) * dy2 - (b.p1.y - a.p1.y) * dx2) / denom;
1554
+ const u = ((b.p1.x - a.p1.x) * dy1 - (b.p1.y - a.p1.y) * dx1) / denom;
1555
+ if (t < 0 || t > 1 || u < 0 || u > 1) return null;
1556
+ return {
1557
+ x: a.p1.x + t * dx1,
1558
+ y: a.p1.y + t * dy1
1559
+ };
1560
+ }
1561
+ /** Detect tripwire crossing and direction via cross product. */
1562
+ function tripwireCrossing(prev, curr, wire) {
1563
+ if (!lineIntersection({
1564
+ p1: prev,
1565
+ p2: curr
1566
+ }, wire)) return null;
1567
+ return {
1568
+ crossed: true,
1569
+ direction: (curr.x - prev.x) * (wire.p2.y - wire.p1.y) - (curr.y - prev.y) * (wire.p2.x - wire.p1.x) > 0 ? "right" : "left"
1570
+ };
1571
+ }
1545
1572
  /** Centroid of a bounding box { x, y, w, h } */
1546
1573
  function bboxCentroid(bbox) {
1547
1574
  return {
@@ -1556,6 +1583,95 @@ function normalizeToPixel(p, width, height) {
1556
1583
  y: p.y * height
1557
1584
  };
1558
1585
  }
1586
+ var NONE = [];
1587
+ /**
1588
+ * Which side of the directed line the point is on.
1589
+ *
1590
+ * Sign of the 2D cross product of the line's own vector with (point − p1):
1591
+ * positive is the right-hand side of p1 → p2. Exactly zero means ON the line,
1592
+ * which is deliberately its own value: it is neither side, so it can neither
1593
+ * confirm nor cancel a crossing.
1594
+ */
1595
+ function sideOf(point, wire) {
1596
+ const cross = (wire.p2.x - wire.p1.x) * (point.y - wire.p1.y) - (wire.p2.y - wire.p1.y) * (point.x - wire.p1.x);
1597
+ if (cross > 0) return 1;
1598
+ if (cross < 0) return -1;
1599
+ return 0;
1600
+ }
1601
+ var TripwireTracker = class {
1602
+ config;
1603
+ /** `trackId` → `wireId` → state. Keyed by track so `forget` is one delete. */
1604
+ tracks = /* @__PURE__ */ new Map();
1605
+ constructor(config = {}) {
1606
+ this.config = {
1607
+ confirmFrames: 2,
1608
+ ...config
1609
+ };
1610
+ }
1611
+ /**
1612
+ * Feed one frame's centroid for one track against one wire.
1613
+ *
1614
+ * Returns the crossings CONFIRMED on this frame — usually none. Coordinates
1615
+ * are whatever space the caller uses consistently; the zone geometry is
1616
+ * normalised (0–1), so the frame processor passes normalised centroids.
1617
+ */
1618
+ observe(trackId, wireId, wire, centroid) {
1619
+ const wires = this.tracks.get(trackId) ?? /* @__PURE__ */ new Map();
1620
+ this.tracks.set(trackId, wires);
1621
+ const side = sideOf(centroid, wire);
1622
+ const state = wires.get(wireId);
1623
+ if (state === void 0) {
1624
+ wires.set(wireId, {
1625
+ last: centroid,
1626
+ confirmedSide: side,
1627
+ pendingSide: side,
1628
+ pendingFrames: 0,
1629
+ sawIntersection: false,
1630
+ pendingDirection: "in"
1631
+ });
1632
+ return NONE;
1633
+ }
1634
+ const crossedNow = tripwireCrossing(state.last, centroid, wire) !== null;
1635
+ state.last = centroid;
1636
+ if (side === 0) return NONE;
1637
+ if (side === state.confirmedSide) {
1638
+ state.pendingSide = side;
1639
+ state.pendingFrames = 0;
1640
+ state.sawIntersection = false;
1641
+ return NONE;
1642
+ }
1643
+ if (side !== state.pendingSide) {
1644
+ state.pendingSide = side;
1645
+ state.pendingFrames = 1;
1646
+ state.sawIntersection = crossedNow;
1647
+ state.pendingDirection = side > 0 ? "in" : "out";
1648
+ } else {
1649
+ state.pendingFrames += 1;
1650
+ state.sawIntersection = state.sawIntersection || crossedNow;
1651
+ }
1652
+ if (state.pendingFrames < this.config.confirmFrames) return NONE;
1653
+ if (!state.sawIntersection) {
1654
+ state.confirmedSide = side;
1655
+ state.pendingFrames = 0;
1656
+ return NONE;
1657
+ }
1658
+ state.confirmedSide = side;
1659
+ state.pendingFrames = 0;
1660
+ state.sawIntersection = false;
1661
+ return [{
1662
+ wireId,
1663
+ direction: state.pendingDirection
1664
+ }];
1665
+ }
1666
+ /** Drop a track. Called when the subject leaves, so state cannot accumulate. */
1667
+ forget(trackId) {
1668
+ this.tracks.delete(trackId);
1669
+ }
1670
+ /** Number of tracks held — a leak check for the caller's tests. */
1671
+ size() {
1672
+ return this.tracks.size;
1673
+ }
1674
+ };
1559
1675
  //#endregion
1560
1676
  //#region src/pipeline-analytics/pipeline/tracker/sort-tracker.ts
1561
1677
  var DEFAULT_TRACKER_CONFIG = {
@@ -2244,10 +2360,11 @@ var DetectionEventEmitter = class {
2244
2360
  const eventType = ZONE_TYPE_TO_EVENT[ze.type];
2245
2361
  if (!eventType) continue;
2246
2362
  if (!this.config.enabledTypes.includes(eventType)) continue;
2247
- const track = trackMap.get(ze.trackId);
2363
+ const present = trackMap.get(ze.trackId);
2364
+ const track = present ?? this.lastKnownTracks.get(ze.trackId);
2248
2365
  if (!track || track.trackAge < this.config.minTrackAge) continue;
2249
2366
  const state = stateMap.get(ze.trackId);
2250
- this.valuedTracks.add(ze.trackId);
2367
+ if (present) this.valuedTracks.add(ze.trackId);
2251
2368
  events.push({
2252
2369
  id: `evt-${++eventIdCounter}`,
2253
2370
  type: eventType,
@@ -2594,6 +2711,19 @@ var FrameProcessor = class {
2594
2711
  /** Zone crossings per track — the producer of `zone.enter` / `zone.exit`. */
2595
2712
  zoneTransitions = new ZoneTransitionTracker();
2596
2713
  /**
2714
+ * Tripwire crossings. Separate from `zoneTransitions` because a tripwire is
2715
+ * a DIRECTED line, not an area: membership is meaningless and the question is
2716
+ * which way the subject went over it.
2717
+ */
2718
+ tripwires = new TripwireTracker();
2719
+ /**
2720
+ * Last detection seen per track. An edge exit fires for a track that is
2721
+ * ABSENT from the frame, so there is no current detection to attach — this is
2722
+ * the one the subject had when it was last seen. Cleared with the rest of the
2723
+ * per-track caches when the track leaves.
2724
+ */
2725
+ lastDetectionByTrack = /* @__PURE__ */ new Map();
2726
+ /**
2597
2727
  * Last zone membership seen per track, so an event for a track that is GONE
2598
2728
  * this frame still carries the zones it was in. See the use site.
2599
2729
  */
@@ -2789,6 +2919,7 @@ var FrameProcessor = class {
2789
2919
  }
2790
2920
  this.lastZoneOverlaps = overlapsByTrack;
2791
2921
  this.lastZoneRejections = rejectedByTrack;
2922
+ for (const td of trackedDetections) this.lastDetectionByTrack.set(td.trackId, td);
2792
2923
  for (const [trackId, zoneIds] of zonesByTrack) this.lastZonesByTrack.set(trackId, zoneIds);
2793
2924
  for (const state of objectStates) {
2794
2925
  if (zonesByTrack.has(state.trackId)) continue;
@@ -2807,16 +2938,58 @@ var FrameProcessor = class {
2807
2938
  timestamp
2808
2939
  });
2809
2940
  };
2941
+ for (const state of objectStates) {
2942
+ if (state.state !== "leaving") continue;
2943
+ const stillInside = this.zoneTransitions.forget(state.trackId);
2944
+ this.tripwires.forget(state.trackId);
2945
+ if (stillInside.length === 0) continue;
2946
+ const last = this.lastDetectionByTrack.get(state.trackId);
2947
+ if (last === void 0) continue;
2948
+ for (const zoneId of stillInside) pushZoneEvent("zone-exit", zoneId, last);
2949
+ }
2810
2950
  for (const td of trackedDetections) {
2811
2951
  const crossings = this.zoneTransitions.observe(td.trackId, zonesByTrack.get(td.trackId) ?? []);
2812
2952
  for (const zoneId of crossings.entered) pushZoneEvent("zone-enter", zoneId, td);
2813
2953
  for (const zoneId of crossings.exited) pushZoneEvent("zone-exit", zoneId, td);
2814
2954
  }
2955
+ const wires = this.zones.filter((z) => z.kind === "tripwire" && z.polygon.length >= 2);
2956
+ if (wires.length > 0) for (const td of trackedDetections) {
2957
+ const centroid = bboxCentroid({
2958
+ x: td.bbox.x / frameWidth,
2959
+ y: td.bbox.y / frameHeight,
2960
+ w: td.bbox.w / frameWidth,
2961
+ h: td.bbox.h / frameHeight
2962
+ });
2963
+ for (const zone of wires) {
2964
+ const a = zone.polygon[0];
2965
+ const b = zone.polygon[1];
2966
+ if (a === void 0 || b === void 0) continue;
2967
+ const wire = {
2968
+ p1: {
2969
+ x: a.x,
2970
+ y: a.y
2971
+ },
2972
+ p2: {
2973
+ x: b.x,
2974
+ y: b.y
2975
+ }
2976
+ };
2977
+ for (const cross of this.tripwires.observe(td.trackId, zone.id, wire, centroid)) zoneEvents.push({
2978
+ type: "tripwire-cross",
2979
+ zoneId: zone.id,
2980
+ zoneName: zone.name,
2981
+ trackId: td.trackId,
2982
+ detection: td,
2983
+ direction: cross.direction,
2984
+ timestamp
2985
+ });
2986
+ }
2987
+ }
2815
2988
  const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, zoneEvents, [], String(this.deviceId));
2816
2989
  for (const state of objectStates) {
2817
2990
  if (state.state !== "leaving") continue;
2818
- this.zoneTransitions.forget(state.trackId);
2819
2991
  this.lastZonesByTrack.delete(state.trackId);
2992
+ this.lastDetectionByTrack.delete(state.trackId);
2820
2993
  }
2821
2994
  const tracked = trackedDetections.map((td) => {
2822
2995
  const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
@@ -2856,9 +3029,9 @@ var FrameProcessor = class {
2856
3029
  };
2857
3030
  });
2858
3031
  const crossingOf = (e) => {
2859
- const direction = e.type === "zone.enter" ? "enter" : e.type === "zone.exit" ? "exit" : void 0;
2860
- if (direction === void 0) return void 0;
2861
3032
  const ze = e.zoneEvents[0];
3033
+ const direction = e.type === "zone.enter" ? "enter" : e.type === "zone.exit" ? "exit" : e.type === "tripwire.cross" ? ze?.direction === "out" ? "exit" : "enter" : void 0;
3034
+ if (direction === void 0) return void 0;
2862
3035
  if (ze === void 0) return void 0;
2863
3036
  return {
2864
3037
  direction,
@@ -4950,6 +5123,29 @@ var NcDispatcher = class {
4950
5123
  } });
4951
5124
  return { ok: true };
4952
5125
  }
5126
+ if (this.deps.snoozeGate !== void 0) {
5127
+ const snoozeId = await this.deps.snoozeGate({
5128
+ ruleId: entry.ruleId,
5129
+ ruleName: entry.payload.ruleName,
5130
+ targetId: target.id,
5131
+ deviceId: entry.deviceId,
5132
+ ...target.ownerUserId !== void 0 ? { ownerUserId: target.ownerUserId } : {},
5133
+ at: this.now()
5134
+ });
5135
+ if (snoozeId !== null) {
5136
+ this.deps.logger.info("notification suppressed by snooze", {
5137
+ tags: { deviceId: entry.deviceId },
5138
+ meta: {
5139
+ snoozeId,
5140
+ ruleId: entry.ruleId,
5141
+ targetId: target.id,
5142
+ target: target.name,
5143
+ eventId: entry.recordId
5144
+ }
5145
+ });
5146
+ return { ok: true };
5147
+ }
5148
+ }
4953
5149
  const notification = await this.buildNotification(entry);
4954
5150
  try {
4955
5151
  const result = await this.deps.send({
@@ -4982,6 +5178,81 @@ var NcDispatcher = class {
4982
5178
  };
4983
5179
  }
4984
5180
  }
5181
+ /**
5182
+ * Deliver ONE end-of-window snooze digest to one target.
5183
+ *
5184
+ * Best-effort by design, and the return value says which it was rather than
5185
+ * throwing: the caller marks the window handled either way, because a digest
5186
+ * retried every drain tick for hours is worse than a digest missed once — and
5187
+ * a partially-delivered one must never be re-sent to the targets that already
5188
+ * have it. A failure is logged with the window id, so a missing summary is a
5189
+ * findable line rather than a silence inside a silence.
5190
+ *
5191
+ * The digest carries no media and no `tag`: it is not an event, and tagging
5192
+ * it with a rule id would let a phone collapse it into the very
5193
+ * notifications it is reporting on.
5194
+ */
5195
+ async deliverDigest(input) {
5196
+ let target;
5197
+ try {
5198
+ target = await this.resolveTarget(input.targetId);
5199
+ } catch (err) {
5200
+ this.deps.logger.warn("snooze digest: target catalog unreachable", { meta: {
5201
+ snoozeId: input.snoozeId,
5202
+ targetId: input.targetId,
5203
+ error: String(err)
5204
+ } });
5205
+ return false;
5206
+ }
5207
+ if (target === null || !target.enabled) {
5208
+ this.deps.logger.warn("snooze digest: target gone or disabled", { meta: {
5209
+ snoozeId: input.snoozeId,
5210
+ targetId: input.targetId
5211
+ } });
5212
+ return false;
5213
+ }
5214
+ try {
5215
+ const result = await this.deps.send({
5216
+ addonId: target.addonId,
5217
+ targetId: target.id,
5218
+ notification: {
5219
+ title: input.title,
5220
+ body: input.body,
5221
+ priority: 2
5222
+ }
5223
+ });
5224
+ if (!result.success) {
5225
+ this.deps.logger.warn("snooze digest send failed", { meta: {
5226
+ snoozeId: input.snoozeId,
5227
+ targetId: target.id,
5228
+ error: result.error
5229
+ } });
5230
+ return false;
5231
+ }
5232
+ this.deps.logger.info("snooze digest delivered", { meta: {
5233
+ snoozeId: input.snoozeId,
5234
+ target: target.name,
5235
+ hidden: input.total
5236
+ } });
5237
+ return true;
5238
+ } catch (err) {
5239
+ this.deps.logger.warn("snooze digest send threw", { meta: {
5240
+ snoozeId: input.snoozeId,
5241
+ targetId: target.id,
5242
+ error: String(err)
5243
+ } });
5244
+ return false;
5245
+ }
5246
+ }
5247
+ /** Device display names for a set of ids, for the digest's lines. */
5248
+ async resolveDeviceNames(deviceIds) {
5249
+ const out = /* @__PURE__ */ new Map();
5250
+ for (const id of new Set(deviceIds)) {
5251
+ const name = await this.deps.getDeviceName(id).catch(() => null);
5252
+ if (name !== null) out.set(id, name);
5253
+ }
5254
+ return out;
5255
+ }
4985
5256
  async resolveTarget(targetId) {
4986
5257
  const cached = this.cachedTarget(targetId);
4987
5258
  if (cached !== null) return cached;
@@ -5100,7 +5371,8 @@ var NcDispatcher = class {
5100
5371
  format: want.format,
5101
5372
  ...entry.payload.mediaClipPreRollSec !== void 0 ? { preRollSec: entry.payload.mediaClipPreRollSec } : {},
5102
5373
  ...entry.payload.mediaClipPostRollSec !== void 0 ? { postRollSec: entry.payload.mediaClipPostRollSec } : {},
5103
- ...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {}
5374
+ ...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {},
5375
+ ...entry.payload.mediaClipSpeed !== void 0 ? { speed: entry.payload.mediaClipSpeed } : {}
5104
5376
  });
5105
5377
  if (rendered !== null && rendered.byteLength > 0) {
5106
5378
  const bytes = new Uint8Array(rendered.byteLength);
@@ -6341,6 +6613,474 @@ var NcRuleStore = class {
6341
6613
  }
6342
6614
  };
6343
6615
  //#endregion
6616
+ //#region src/notification-center/snooze-digest.ts
6617
+ /** `14:03` in the node's local zone — a digest is read by a person, at home. */
6618
+ function clock(ms) {
6619
+ const d = new Date(ms);
6620
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
6621
+ }
6622
+ function plural(n, one, many) {
6623
+ return n === 1 ? `1 ${one}` : `${n} ${many}`;
6624
+ }
6625
+ /**
6626
+ * What the window covered, in the words the operator chose it with — the rule's
6627
+ * NAME, the camera's name. "a rule was snoozed" tells the reader nothing they
6628
+ * could act on.
6629
+ */
6630
+ function scopeLabel(snooze, rows, deviceNames) {
6631
+ switch (snooze.scope) {
6632
+ case "all": return "Everything";
6633
+ case "device": {
6634
+ const id = snooze.deviceId;
6635
+ if (id === void 0) return "A camera";
6636
+ return deviceNames.get(id) ?? `Camera ${id}`;
6637
+ }
6638
+ case "rule": {
6639
+ const named = rows.find((r) => r.ruleId === snooze.ruleId);
6640
+ return named !== void 0 ? `“${named.ruleName}”` : "A rule";
6641
+ }
6642
+ }
6643
+ }
6644
+ /**
6645
+ * One digest for ONE target — the counters passed in are already the rows
6646
+ * belonging to it, so the message says what THAT destination missed rather
6647
+ * than what the household did.
6648
+ *
6649
+ * Returns null when the window hid nothing: "snooze ended, 0 notifications" is
6650
+ * a notification the operator did not need, and sending it for every quiet
6651
+ * window is how a summary becomes the noise it was meant to suppress.
6652
+ */
6653
+ function buildSnoozeDigest(snooze, rows, deviceNames) {
6654
+ const total = rows.reduce((sum, r) => sum + r.count, 0);
6655
+ if (total === 0) return null;
6656
+ const from = clock(Math.min(...rows.map((r) => r.firstAt)));
6657
+ const to = clock(snooze.expiresAt);
6658
+ const title = `Snooze ended — ${plural(total, "notification", "notifications")} hidden`;
6659
+ const lines = rows.toSorted((a, b) => b.count - a.count || a.ruleName.localeCompare(b.ruleName)).map((r) => {
6660
+ const where = deviceNames.get(r.deviceId) ?? `camera ${r.deviceId}`;
6661
+ return `• ${r.ruleName} on ${where} — ${r.count}`;
6662
+ });
6663
+ return {
6664
+ title,
6665
+ body: [`${scopeLabel(snooze, rows, deviceNames)} was snoozed${snooze.global ? " for everyone" : ""}, ${from}–${to}.`, ...lines].join("\n"),
6666
+ total
6667
+ };
6668
+ }
6669
+ /** The distinct targets a window owes a digest to. */
6670
+ function digestTargets(rows) {
6671
+ return [...new Set(rows.map((r) => r.targetId))];
6672
+ }
6673
+ //#endregion
6674
+ //#region src/notification-center/snooze-policy.ts
6675
+ /** How broad a scope is, for choosing between overlapping snoozes. */
6676
+ var BREADTH = {
6677
+ rule: 1,
6678
+ device: 2,
6679
+ all: 3
6680
+ };
6681
+ function covers(entry, q) {
6682
+ if (q.nowMs < entry.startedAt) return false;
6683
+ if (q.nowMs >= entry.expiresAt) return false;
6684
+ if (!entry.global && entry.userId !== q.userId) return false;
6685
+ switch (entry.scope) {
6686
+ case "all": return true;
6687
+ case "rule": return entry.ruleId !== void 0 && entry.ruleId === q.ruleId;
6688
+ case "device": return entry.deviceId !== void 0 && entry.deviceId === q.deviceId;
6689
+ }
6690
+ }
6691
+ /**
6692
+ * The snooze that silences this notification, or null.
6693
+ *
6694
+ * The BROADEST match wins so one window produces one digest: an "all" snooze
6695
+ * overlapping a rule snooze should not leave the recipient two summaries for
6696
+ * the same silence.
6697
+ */
6698
+ function activeSnoozeFor(entries, q) {
6699
+ let best = null;
6700
+ for (const e of entries) {
6701
+ if (!covers(e, q)) continue;
6702
+ if (best === null || BREADTH[e.scope] > BREADTH[best.scope]) best = e;
6703
+ }
6704
+ return best;
6705
+ }
6706
+ /**
6707
+ * May this caller silence EVERYONE, not just themselves?
6708
+ *
6709
+ * Global is the only part of a snooze that can affect a person who did not ask
6710
+ * for it, so it is never reachable by accident:
6711
+ *
6712
+ * - **rule scope** — the rule itself must opt in (`snoozeAllowGlobal`, default
6713
+ * off). That is the gardener case: this rule is known to be noisy for
6714
+ * everybody in a way that is legitimate to silence for everybody.
6715
+ * - **device or whole-feed scope** — there is no rule to carry the opt-in, and
6716
+ * "silence this camera for the household" is an administrative act. Admin
6717
+ * only.
6718
+ *
6719
+ * A personal snooze needs no permission at all: it silences only its author.
6720
+ */
6721
+ function canSetGlobal(req) {
6722
+ if (req.isAdmin) return true;
6723
+ if (req.scope !== "rule") return false;
6724
+ return req.ruleAllowsGlobal === true;
6725
+ }
6726
+ /** Entries whose window has closed and whose digest has not been sent yet. */
6727
+ function dueSummaries(entries, nowMs, alreadySent) {
6728
+ return entries.filter((e) => e.summary && nowMs >= e.expiresAt && !alreadySent.has(e.id));
6729
+ }
6730
+ //#endregion
6731
+ //#region src/notification-center/snooze-store.ts
6732
+ /**
6733
+ * NcSnoozeStore — the durable half of snooze.
6734
+ *
6735
+ * Two collections, and the split matters:
6736
+ *
6737
+ * - **windows** (`notification-center:snoozes`) — small, mirrored into RAM,
6738
+ * read on the dispatcher's hot path with zero I/O. Refreshed on the drain
6739
+ * tick, so a window opened on the hub takes effect on the evaluating node
6740
+ * within one tick (D8: reconcile, never trust an event to arrive).
6741
+ * - **suppression counters** (`notification-center:snooze-suppressed`) — one
6742
+ * row per (window, target, rule, camera), holding a COUNT and the first/last
6743
+ * instant. Not one row per hidden notification: a snooze exists because
6744
+ * something is noisy, so the row-per-event version of this table is unbounded
6745
+ * exactly when it is used most. The digest reads "42 events, 14:03–17:58",
6746
+ * which is the aggregate anyway.
6747
+ *
6748
+ * Expiry is never a job here either. Nothing in this file ends a window; the
6749
+ * clock does, and {@link NcSnoozeStore.prune} only reclaims rows long after the
6750
+ * fact. A prune that never runs delays a digest and wastes a few KB — it can
6751
+ * never leave the operator silenced.
6752
+ */
6753
+ var NC_SNOOZES_COLLECTION = "notification-center:snoozes";
6754
+ var NC_SNOOZE_SUPPRESSED_COLLECTION = "notification-center:snooze-suppressed";
6755
+ var NC_SNOOZES_COLUMNS = [
6756
+ {
6757
+ name: "id",
6758
+ type: "TEXT",
6759
+ primaryKey: true,
6760
+ notNull: true
6761
+ },
6762
+ {
6763
+ name: "userId",
6764
+ type: "TEXT",
6765
+ notNull: true
6766
+ },
6767
+ {
6768
+ name: "expiresAt",
6769
+ type: "INTEGER",
6770
+ notNull: true
6771
+ },
6772
+ (
6773
+ /** The FULL window (Zod-validated on read); the scalars are projections. */
6774
+ {
6775
+ name: "snooze",
6776
+ type: "JSON",
6777
+ notNull: true
6778
+ })
6779
+ ];
6780
+ var NC_SNOOZES_INDEXES = [{
6781
+ name: "idx_nc_snoozes_expires",
6782
+ columns: ["expiresAt"]
6783
+ }];
6784
+ var NC_SNOOZE_SUPPRESSED_COLUMNS = [
6785
+ {
6786
+ name: "id",
6787
+ type: "TEXT",
6788
+ primaryKey: true,
6789
+ notNull: true
6790
+ },
6791
+ {
6792
+ name: "snoozeId",
6793
+ type: "TEXT",
6794
+ notNull: true
6795
+ },
6796
+ {
6797
+ name: "targetId",
6798
+ type: "TEXT",
6799
+ notNull: true
6800
+ },
6801
+ {
6802
+ name: "ruleId",
6803
+ type: "TEXT",
6804
+ notNull: true
6805
+ },
6806
+ {
6807
+ name: "ruleName",
6808
+ type: "TEXT",
6809
+ notNull: true
6810
+ },
6811
+ {
6812
+ name: "deviceId",
6813
+ type: "INTEGER",
6814
+ notNull: true
6815
+ },
6816
+ {
6817
+ name: "count",
6818
+ type: "INTEGER",
6819
+ notNull: true
6820
+ },
6821
+ {
6822
+ name: "firstAt",
6823
+ type: "INTEGER",
6824
+ notNull: true
6825
+ },
6826
+ {
6827
+ name: "lastAt",
6828
+ type: "INTEGER",
6829
+ notNull: true
6830
+ }
6831
+ ];
6832
+ var NC_SNOOZE_SUPPRESSED_INDEXES = [{
6833
+ name: "idx_nc_snooze_suppressed_snooze",
6834
+ columns: ["snoozeId"]
6835
+ }];
6836
+ /** How long a closed window's rows survive, for a retried digest and for
6837
+ * "what did I miss" to still answer after a restart. */
6838
+ var PRUNE_HORIZON_MS = 6 * 36e5;
6839
+ /** Counter key — one row per (window, target, rule, camera). */
6840
+ function counterKey(s) {
6841
+ return `${s.snoozeId}|${s.targetId}|${s.ruleId}|${s.deviceId}`;
6842
+ }
6843
+ var NcSnoozeStore = class {
6844
+ byId = /* @__PURE__ */ new Map();
6845
+ store;
6846
+ logger;
6847
+ now;
6848
+ newId;
6849
+ constructor(deps) {
6850
+ this.store = deps.store;
6851
+ this.logger = deps.logger;
6852
+ this.now = deps.now ?? (() => Date.now());
6853
+ this.newId = deps.newId ?? (() => (0, node_crypto.randomUUID)());
6854
+ }
6855
+ static async declare(store) {
6856
+ await store.declareCollection.mutate({
6857
+ collection: NC_SNOOZES_COLLECTION,
6858
+ columns: [...NC_SNOOZES_COLUMNS],
6859
+ indexes: [...NC_SNOOZES_INDEXES]
6860
+ });
6861
+ await store.declareCollection.mutate({
6862
+ collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
6863
+ columns: [...NC_SNOOZE_SUPPRESSED_COLUMNS],
6864
+ indexes: [...NC_SNOOZE_SUPPRESSED_INDEXES]
6865
+ });
6866
+ }
6867
+ /**
6868
+ * (Re)hydrate every window. Called at boot and on the drain tick — the table
6869
+ * holds active windows and recently closed ones, so it stays small enough to
6870
+ * read whole. A row whose JSON no longer validates is SKIPPED: a malformed
6871
+ * window must never be able to silence anything.
6872
+ */
6873
+ async load() {
6874
+ try {
6875
+ const rows = await this.store.query.query({
6876
+ collection: NC_SNOOZES_COLLECTION,
6877
+ filter: { limit: 1e3 }
6878
+ });
6879
+ this.byId.clear();
6880
+ let skipped = 0;
6881
+ for (const row of rows) {
6882
+ const parsed = require_dist.NcSnoozeSchema.safeParse(row.data["snooze"]);
6883
+ if (!parsed.success) {
6884
+ skipped += 1;
6885
+ continue;
6886
+ }
6887
+ this.byId.set(parsed.data.id, parsed.data);
6888
+ }
6889
+ if (skipped > 0) this.logger.warn("snooze rows skipped as malformed — they silence NOTHING", { meta: { skipped } });
6890
+ } catch (err) {
6891
+ this.logger.warn("snooze load failed — keeping the windows already in memory", { meta: { error: String(err) } });
6892
+ }
6893
+ }
6894
+ /** Every known window, newest first. */
6895
+ list() {
6896
+ return [...this.byId.values()].toSorted((a, b) => b.startedAt - a.startedAt);
6897
+ }
6898
+ /** The windows a caller may SEE: their own, plus the global ones that
6899
+ * silence them. Never another person's private silence. */
6900
+ listVisibleTo(userId, isAdmin) {
6901
+ if (isAdmin) return this.list();
6902
+ return this.list().filter((s) => s.userId === userId || s.global);
6903
+ }
6904
+ get(snoozeId) {
6905
+ return this.byId.get(snoozeId) ?? null;
6906
+ }
6907
+ /** Hot-path read — the window silencing this delivery, or null. */
6908
+ activeFor(query) {
6909
+ return activeSnoozeFor([...this.byId.values()], query);
6910
+ }
6911
+ /**
6912
+ * Open a window. Permission for `global` is the CALLER's problem (see
6913
+ * `canSetGlobal`) — this store persists what it is given, exactly like the
6914
+ * rule store leaves ownership to the bridge action.
6915
+ */
6916
+ async create(input) {
6917
+ const startedAt = this.now();
6918
+ const snooze = require_dist.NcSnoozeSchema.parse({
6919
+ id: this.newId(),
6920
+ userId: input.userId,
6921
+ scope: input.scope,
6922
+ ...input.ruleId !== void 0 ? { ruleId: input.ruleId } : {},
6923
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
6924
+ startedAt,
6925
+ expiresAt: startedAt + input.durationMinutes * 6e4,
6926
+ global: input.global,
6927
+ summary: input.summary
6928
+ });
6929
+ await this.persist(snooze);
6930
+ this.byId.set(snooze.id, snooze);
6931
+ return snooze;
6932
+ }
6933
+ /**
6934
+ * End a window NOW by moving its expiry to this instant — never by deleting
6935
+ * it. The digest of what it hid is still owed to whoever opened it, and a
6936
+ * deleted row owes nothing.
6937
+ */
6938
+ async cancel(snoozeId) {
6939
+ const existing = this.byId.get(snoozeId);
6940
+ if (existing === void 0) return null;
6941
+ const now = this.now();
6942
+ if (existing.expiresAt <= now) return existing;
6943
+ const ended = {
6944
+ ...existing,
6945
+ expiresAt: now
6946
+ };
6947
+ await this.persist(ended);
6948
+ this.byId.set(ended.id, ended);
6949
+ return ended;
6950
+ }
6951
+ /**
6952
+ * Record that a window hid one notification. Read-modify-write on the
6953
+ * counter row; best-effort, because losing a line of a SUMMARY must never
6954
+ * cost the suppression decision that produced it.
6955
+ */
6956
+ async recordSuppressed(s) {
6957
+ const key = counterKey(s);
6958
+ try {
6959
+ const existing = await this.readCounter(key);
6960
+ const next = {
6961
+ snoozeId: s.snoozeId,
6962
+ targetId: s.targetId,
6963
+ ruleId: s.ruleId,
6964
+ ruleName: s.ruleName,
6965
+ deviceId: s.deviceId,
6966
+ count: (existing?.count ?? 0) + 1,
6967
+ firstAt: existing?.firstAt ?? s.at,
6968
+ lastAt: s.at
6969
+ };
6970
+ await this.store.set.mutate({
6971
+ collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
6972
+ key,
6973
+ value: { ...next }
6974
+ });
6975
+ } catch (err) {
6976
+ this.logger.warn("snooze suppression counter write failed (digest will under-report)", {
6977
+ tags: { deviceId: s.deviceId },
6978
+ meta: {
6979
+ snoozeId: s.snoozeId,
6980
+ ruleId: s.ruleId,
6981
+ error: String(err)
6982
+ }
6983
+ });
6984
+ }
6985
+ }
6986
+ /** Windows that have closed and still owe a digest. */
6987
+ dueDigests(nowMs) {
6988
+ const sent = new Set([...this.byId.values()].filter((s) => s.digestSentAt !== void 0).map((s) => s.id));
6989
+ return dueSummaries([...this.byId.values()], nowMs, sent);
6990
+ }
6991
+ /** What one window hid, grouped per target. */
6992
+ async suppressedFor(snoozeId) {
6993
+ try {
6994
+ const rows = await this.store.query.query({
6995
+ collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
6996
+ filter: {
6997
+ where: { snoozeId },
6998
+ limit: 1e3
6999
+ }
7000
+ });
7001
+ const out = [];
7002
+ for (const row of rows) {
7003
+ const parsed = require_dist.NcSnoozeSuppressedSchema.safeParse(row.data);
7004
+ if (parsed.success) out.push(parsed.data);
7005
+ }
7006
+ return out;
7007
+ } catch (err) {
7008
+ this.logger.warn("snooze suppression read failed", { meta: {
7009
+ snoozeId,
7010
+ error: String(err)
7011
+ } });
7012
+ return [];
7013
+ }
7014
+ }
7015
+ /**
7016
+ * Mark a digest as handled.
7017
+ *
7018
+ * Called after the ATTEMPT, not after a success: a digest that cannot be
7019
+ * delivered is not worth retrying every two seconds for six hours, and a
7020
+ * partially-sent one must not be re-sent to the targets that already got it.
7021
+ * The failure is logged with the window id — a missing digest is then a
7022
+ * findable line, not a silence inside a silence.
7023
+ */
7024
+ async markDigestSent(snoozeId) {
7025
+ const existing = this.byId.get(snoozeId);
7026
+ if (existing === void 0) return;
7027
+ const marked = {
7028
+ ...existing,
7029
+ digestSentAt: this.now()
7030
+ };
7031
+ await this.persist(marked);
7032
+ this.byId.set(marked.id, marked);
7033
+ }
7034
+ /**
7035
+ * Drop windows that closed more than {@link PRUNE_HORIZON_MS} ago, and their
7036
+ * counters. Only ever touches CLOSED windows — an active one cannot be
7037
+ * pruned into oblivion by a clock argument.
7038
+ */
7039
+ async prune(nowMs) {
7040
+ const horizon = nowMs - PRUNE_HORIZON_MS;
7041
+ const stale = [...this.byId.values()].filter((s) => s.expiresAt < horizon);
7042
+ let dropped = 0;
7043
+ for (const s of stale) try {
7044
+ await this.store.deleteWhere.mutate({
7045
+ collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
7046
+ filter: { where: { snoozeId: s.id } }
7047
+ });
7048
+ await this.store.delete.mutate({
7049
+ collection: NC_SNOOZES_COLLECTION,
7050
+ key: s.id
7051
+ });
7052
+ this.byId.delete(s.id);
7053
+ dropped += 1;
7054
+ } catch (err) {
7055
+ this.logger.warn("snooze prune failed", { meta: {
7056
+ snoozeId: s.id,
7057
+ error: String(err)
7058
+ } });
7059
+ }
7060
+ return dropped;
7061
+ }
7062
+ /** `get` returns the stored VALUE, not a `{ id, data }` row. */
7063
+ async readCounter(key) {
7064
+ const value = await this.store.get.query({
7065
+ collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
7066
+ key
7067
+ });
7068
+ const parsed = require_dist.NcSnoozeSuppressedSchema.safeParse(value);
7069
+ return parsed.success ? parsed.data : null;
7070
+ }
7071
+ async persist(snooze) {
7072
+ await this.store.set.mutate({
7073
+ collection: NC_SNOOZES_COLLECTION,
7074
+ key: snooze.id,
7075
+ value: {
7076
+ userId: snooze.userId,
7077
+ expiresAt: snooze.expiresAt,
7078
+ snooze
7079
+ }
7080
+ });
7081
+ }
7082
+ };
7083
+ //#endregion
6344
7084
  //#region src/notification-center/timelapse/timelapse-store.ts
6345
7085
  var NC_TIMELAPSE_RULES_COLLECTION = "notification-center:timelapse-rules";
6346
7086
  var NC_TIMELAPSE_RULES_COLUMNS = [
@@ -6702,6 +7442,9 @@ var NotificationCenter = class NotificationCenter {
6702
7442
  * snapshots; watched keys are recomputed from the enabled occupancy rules. */
6703
7443
  occupancyWatcher = new OccupancyWatcher();
6704
7444
  occupancyStore;
7445
+ /** Snooze windows + what they hid. Mirrored into RAM; the dispatcher's gate
7446
+ * reads it with no I/O, the drain tick refreshes it. */
7447
+ snoozes;
6705
7448
  /** True when ≥1 enabled `device-event` rule declares an occupancy condition —
6706
7449
  * the watcher is idle (zero per-frame cost) otherwise. */
6707
7450
  occupancyEnabled = false;
@@ -6734,10 +7477,17 @@ var NotificationCenter = class NotificationCenter {
6734
7477
  store: deps.store,
6735
7478
  logger: this.logger.child("occupancy")
6736
7479
  });
7480
+ this.snoozes = new NcSnoozeStore({
7481
+ store: deps.store,
7482
+ logger: this.logger.child("snooze"),
7483
+ ...deps.now !== void 0 ? { now: deps.now } : {}
7484
+ });
6737
7485
  this.dispatcher = new NcDispatcher({
6738
7486
  ...deps.dispatcher,
6739
7487
  logger: this.logger.child("dispatch"),
6740
- isRuleTargetDisabled: (ruleId, targetId) => this.rules.isRuleTargetDisabled(ruleId, targetId)
7488
+ ...deps.now !== void 0 ? { now: deps.now } : {},
7489
+ isRuleTargetDisabled: (ruleId, targetId) => this.rules.isRuleTargetDisabled(ruleId, targetId),
7490
+ snoozeGate: (input) => this.snoozeGate(input)
6741
7491
  });
6742
7492
  this.outbox = new NcOutbox({
6743
7493
  store: deps.store,
@@ -6767,6 +7517,7 @@ var NotificationCenter = class NotificationCenter {
6767
7517
  await NcRuleStore.declare(store);
6768
7518
  await NcOutbox.declare(store);
6769
7519
  await OccupancyStore.declare(store);
7520
+ await NcSnoozeStore.declare(store);
6770
7521
  await TimelapseStore.declare(store);
6771
7522
  }
6772
7523
  /**
@@ -6777,6 +7528,7 @@ var NotificationCenter = class NotificationCenter {
6777
7528
  */
6778
7529
  async start(opts) {
6779
7530
  await this.rules.load();
7531
+ await this.snoozes.load();
6780
7532
  this.refreshOccupancyWatch();
6781
7533
  if (!opts.evaluation) return;
6782
7534
  this.evaluationActive = true;
@@ -7005,6 +7757,12 @@ var NotificationCenter = class NotificationCenter {
7005
7757
  this.logger.debug("getHistory: skipping malformed outbox row", { meta: { id: entry.id } });
7006
7758
  }
7007
7759
  return { entries: mapped };
7760
+ },
7761
+ listSnoozes: async ({ caller }) => ({ snoozes: [...this.snoozes.listVisibleTo(caller.userId, caller.isAdmin)] }),
7762
+ createSnooze: async ({ snooze, caller }) => ({ snooze: await this.createSnooze(snooze, caller) }),
7763
+ cancelSnooze: async ({ snoozeId, caller }) => {
7764
+ await this.cancelSnooze(snoozeId, caller);
7765
+ return { success: true };
7008
7766
  }
7009
7767
  };
7010
7768
  }
@@ -7135,6 +7893,7 @@ var NotificationCenter = class NotificationCenter {
7135
7893
  ...rule.media.gif === true ? { mediaGif: true } : {},
7136
7894
  ...rule.media.clip === true ? { mediaClip: true } : {},
7137
7895
  ...rule.media.clipPreRollSec !== void 0 ? { mediaClipPreRollSec: rule.media.clipPreRollSec } : {},
7896
+ ...rule.media?.clipSpeed !== void 0 ? { mediaClipSpeed: rule.media.clipSpeed } : {},
7138
7897
  ...rule.media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: rule.media.clipPostRollSec } : {},
7139
7898
  ...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
7140
7899
  ...target.params !== void 0 ? { params: target.params } : {},
@@ -7327,9 +8086,127 @@ var NotificationCenter = class NotificationCenter {
7327
8086
  await this.outbox.setWatermark(now);
7328
8087
  }
7329
8088
  async drainTick() {
8089
+ await this.snoozes.load();
7330
8090
  await this.outbox.drainOnce();
8091
+ await this.deliverDueDigests();
7331
8092
  this.drainTicks += 1;
7332
- if (this.drainTicks % WATERMARK_EVERY_TICKS === 0) await this.outbox.setWatermark(this.now());
8093
+ if (this.drainTicks % WATERMARK_EVERY_TICKS === 0) {
8094
+ await this.outbox.setWatermark(this.now());
8095
+ await this.snoozes.prune(this.now());
8096
+ }
8097
+ }
8098
+ /**
8099
+ * The dispatcher's gate: is this delivery silenced, and if so, count it.
8100
+ *
8101
+ * Returns the window's id (the dispatcher logs it) or null. Recording is
8102
+ * awaited rather than fired off so a suppression cannot be lost to a shutdown
8103
+ * between the decision and the row — an under-reported digest is the one
8104
+ * failure of this feature the operator cannot detect.
8105
+ */
8106
+ async snoozeGate(input) {
8107
+ const active = this.snoozes.activeFor({
8108
+ nowMs: input.at,
8109
+ ...input.ownerUserId !== void 0 ? { userId: input.ownerUserId } : {},
8110
+ ruleId: input.ruleId,
8111
+ deviceId: input.deviceId
8112
+ });
8113
+ if (active === null) return null;
8114
+ if (active.summary) await this.snoozes.recordSuppressed({
8115
+ snoozeId: active.id,
8116
+ targetId: input.targetId,
8117
+ ruleId: input.ruleId,
8118
+ ruleName: input.ruleName,
8119
+ deviceId: input.deviceId,
8120
+ at: input.at
8121
+ });
8122
+ return active.id;
8123
+ }
8124
+ /**
8125
+ * Send the summary owed by every window that has closed since the last tick.
8126
+ *
8127
+ * The digest goes exactly where the silenced notifications would have gone —
8128
+ * one per target that was actually suppressed — so it needs no routing of its
8129
+ * own and can never reach someone the snooze never touched.
8130
+ */
8131
+ async deliverDueDigests() {
8132
+ const due = this.snoozes.dueDigests(this.now());
8133
+ for (const snooze of due) try {
8134
+ const rows = await this.snoozes.suppressedFor(snooze.id);
8135
+ const names = await this.dispatcher.resolveDeviceNames(rows.map((r) => r.deviceId));
8136
+ for (const targetId of digestTargets(rows)) {
8137
+ const digest = buildSnoozeDigest(snooze, rows.filter((r) => r.targetId === targetId), names);
8138
+ if (digest === null) continue;
8139
+ await this.dispatcher.deliverDigest({
8140
+ snoozeId: snooze.id,
8141
+ targetId,
8142
+ title: digest.title,
8143
+ body: digest.body,
8144
+ total: digest.total
8145
+ });
8146
+ }
8147
+ await this.snoozes.markDigestSent(snooze.id);
8148
+ } catch (err) {
8149
+ this.logger.warn("snooze digest tick failed", { meta: {
8150
+ snoozeId: snooze.id,
8151
+ error: String(err)
8152
+ } });
8153
+ }
8154
+ }
8155
+ /** The durable snooze store — the cap provider and the `nc.*` bridge serve
8156
+ * CRUD from it, the same write-through cache the gate reads. */
8157
+ get snoozeStore() {
8158
+ return this.snoozes;
8159
+ }
8160
+ /**
8161
+ * Open a window on behalf of a caller, with the permission check that makes
8162
+ * `global` safe. Lives here rather than in the store because it needs the
8163
+ * RULE (its `snoozeAllowGlobal` opt-in), and a permission decision that can
8164
+ * silence other people belongs where both halves are visible.
8165
+ */
8166
+ async createSnooze(input, caller) {
8167
+ if (input.scope === "rule" && input.ruleId === void 0) throw new Error("a rule snooze needs a ruleId");
8168
+ if (input.scope === "device" && input.deviceId === void 0) throw new Error("a camera snooze needs a deviceId");
8169
+ const wantsGlobal = input.global === true;
8170
+ if (wantsGlobal) {
8171
+ const rule = input.ruleId !== void 0 ? this.rules.get(input.ruleId) : null;
8172
+ if (!canSetGlobal({
8173
+ scope: input.scope,
8174
+ ...rule !== null ? { ruleAllowsGlobal: rule.snoozeAllowGlobal === true } : {},
8175
+ isAdmin: caller.isAdmin
8176
+ })) throw new Error("forbidden: this snooze may not be made global");
8177
+ }
8178
+ const snooze = await this.snoozes.create({
8179
+ userId: caller.userId,
8180
+ scope: input.scope,
8181
+ ...input.ruleId !== void 0 ? { ruleId: input.ruleId } : {},
8182
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
8183
+ durationMinutes: input.durationMinutes,
8184
+ global: wantsGlobal,
8185
+ summary: input.summary !== false
8186
+ });
8187
+ this.logger.info("snooze opened", {
8188
+ ...snooze.deviceId !== void 0 ? { tags: { deviceId: snooze.deviceId } } : {},
8189
+ meta: {
8190
+ snoozeId: snooze.id,
8191
+ scope: snooze.scope,
8192
+ by: caller.userId,
8193
+ global: snooze.global,
8194
+ minutes: input.durationMinutes,
8195
+ ...snooze.ruleId !== void 0 ? { ruleId: snooze.ruleId } : {}
8196
+ }
8197
+ });
8198
+ return snooze;
8199
+ }
8200
+ /** End a window early. A user may end their own; an admin may end any. */
8201
+ async cancelSnooze(snoozeId, caller) {
8202
+ const existing = this.snoozes.get(snoozeId);
8203
+ if (existing === null) throw new Error(`snooze not found: ${snoozeId}`);
8204
+ if (!caller.isAdmin && existing.userId !== caller.userId) throw new Error(`forbidden: snooze not owned: ${snoozeId}`);
8205
+ await this.snoozes.cancel(snoozeId);
8206
+ this.logger.info("snooze cancelled", { meta: {
8207
+ snoozeId,
8208
+ by: caller.userId
8209
+ } });
7333
8210
  }
7334
8211
  /** The "at least one addressee" invariant lives here, not in Zod: the
7335
8212
  * schema allows empty `targets` (a users-only rule) and a cross-field
@@ -7446,6 +8323,21 @@ var ncActions = require_dist.defineCustomActions({
7446
8323
  }), require_dist.object({ success: require_dist.literal(true) }), {
7447
8324
  kind: "mutation",
7448
8325
  caller: "required"
8326
+ }),
8327
+ /**
8328
+ * Snooze, from the phone — the surface that matters most for this feature,
8329
+ * because "silence this until morning" is a thing you press in bed, not at a
8330
+ * desk. Scoped to the caller: their own windows plus the global ones that
8331
+ * silence them.
8332
+ */
8333
+ "nc.listSnoozes": require_dist.customAction(require_dist.object({}), require_dist.object({ snoozes: require_dist.array(require_dist.NcSnoozeSchema) }), { caller: "required" }),
8334
+ "nc.createSnooze": require_dist.customAction(require_dist.object({ snooze: require_dist.NcSnoozeInputSchema }), require_dist.object({ snooze: require_dist.NcSnoozeSchema }), {
8335
+ kind: "mutation",
8336
+ caller: "required"
8337
+ }),
8338
+ "nc.cancelSnooze": require_dist.customAction(require_dist.object({ snoozeId: require_dist.string() }), require_dist.object({ success: require_dist.literal(true) }), {
8339
+ kind: "mutation",
8340
+ caller: "required"
7449
8341
  })
7450
8342
  });
7451
8343
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
@@ -7538,6 +8430,28 @@ function makeNcActionHandlers(deps) {
7538
8430
  } });
7539
8431
  return { success: true };
7540
8432
  },
8433
+ "nc.listSnoozes": async (_input, caller) => {
8434
+ const c = requireCaller(caller);
8435
+ return { snoozes: [...deps.snooze.list(c.userId, c.isAdmin)] };
8436
+ },
8437
+ "nc.createSnooze": async (input, caller) => {
8438
+ const c = requireCaller(caller);
8439
+ const parsed = require_dist.NcSnoozeInputSchema.parse(input.snooze);
8440
+ if (parsed.ruleId !== void 0) assertRuleVisible(parsed.ruleId, c);
8441
+ const snooze = await deps.snooze.create(parsed, c);
8442
+ deps.logger.info("nc snooze opened", { meta: {
8443
+ snoozeId: snooze.id,
8444
+ scope: snooze.scope,
8445
+ by: c.userId,
8446
+ global: snooze.global
8447
+ } });
8448
+ return { snooze };
8449
+ },
8450
+ "nc.cancelSnooze": async (input, caller) => {
8451
+ const c = requireCaller(caller);
8452
+ await deps.snooze.cancel(input.snoozeId, c);
8453
+ return { success: true };
8454
+ },
7541
8455
  "nc.setRuleTargetEnabled": async (input, caller) => {
7542
8456
  const c = requireCaller(caller);
7543
8457
  assertRuleVisible(input.ruleId, c);
@@ -11866,6 +12780,37 @@ var OpsLogStore = class {
11866
12780
  }
11867
12781
  return out;
11868
12782
  }
12783
+ /**
12784
+ * Delete audit entries older than `cutoffMs`. Returns how many went.
12785
+ *
12786
+ * The audit log is the one collection that records everyone else's
12787
+ * retention and had none of its own — it grows monotonically for the life of
12788
+ * the node (11,519 rows on the operator's hub before this existed).
12789
+ *
12790
+ * ONE statement, not a select-then-delete drain: `deleteWhere` is exactly
12791
+ * the bulk path that removed the N+1 loops from every other retention path.
12792
+ * `whereBetween` on `at` is inclusive, so `[0, cutoffMs - 1]` selects
12793
+ * strictly-older rows — the same boundary `EventStore.pruneBefore` uses.
12794
+ *
12795
+ * BEST-EFFORT, like `append`: a failed audit trim must never fail the
12796
+ * retention sweep that called it. It warns rather than throwing, because a
12797
+ * trim that silently does nothing is how a table gets to 11,519 rows.
12798
+ */
12799
+ async pruneBefore(cutoffMs) {
12800
+ try {
12801
+ const { deleted } = await this.store.deleteWhere.mutate({
12802
+ collection: OPS_LOG_COLLECTION,
12803
+ filter: { whereBetween: { at: [0, cutoffMs - 1] } }
12804
+ });
12805
+ return deleted;
12806
+ } catch (err) {
12807
+ this.logger.warn("ops-log: prune failed — the audit log will keep growing", { meta: {
12808
+ cutoffMs,
12809
+ error: err instanceof Error ? err.message : String(err)
12810
+ } });
12811
+ return 0;
12812
+ }
12813
+ }
11869
12814
  };
11870
12815
  /** SQLite stores nullable columns as `null`; the row schema uses `.nullable()`
11871
12816
  * for deviceId/detail (accepts null) but drop any stray null on required
@@ -20341,6 +21286,12 @@ var NcArtifactSecretSchema = require_dist.string();
20341
21286
  var EmbeddingEnabledSchema = require_dist.boolean();
20342
21287
  var SILENCE_FLOOR_DBFS = -55;
20343
21288
  var RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
21289
+ /**
21290
+ * How long the events ops-log is kept. 30 days: long enough to answer "what
21291
+ * deleted my footage last month", short enough that the audit never becomes
21292
+ * the largest thing the node stores.
21293
+ */
21294
+ var OPS_LOG_RETENTION_MS = 720 * 60 * 6e4;
20344
21295
  /** Throttle for the "overlay synthesis failed" warn — one line / minute / device. */
20345
21296
  var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
20346
21297
  var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
@@ -20854,6 +21805,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20854
21805
  return makeNcActionHandlers({
20855
21806
  ruleStore: center.ruleStore,
20856
21807
  logger: this.ctx.logger.child("nc-actions"),
21808
+ snooze: {
21809
+ list: (userId, isAdmin) => center.snoozeStore.listVisibleTo(userId, isAdmin),
21810
+ create: (input, caller) => center.createSnooze(input, caller),
21811
+ cancel: (snoozeId, caller) => center.cancelSnooze(snoozeId, caller)
21812
+ },
20857
21813
  listCallerTargetIds: async (userId) => {
20858
21814
  return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
20859
21815
  }
@@ -20885,7 +21841,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20885
21841
  let storage = this.ctx.kernel.storage;
20886
21842
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
20887
21843
  if (mediaRoot) {
20888
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-DrRsUzaw.js"));
21844
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-DFKBSXUx.js"));
20889
21845
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
20890
21846
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
20891
21847
  }
@@ -21266,6 +22222,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21266
22222
  postRollSec: req.postRollSec ?? NC_FOOTAGE_POST_ROLL_SEC,
21267
22223
  maxWidth: NC_FOOTAGE_MAX_WIDTH,
21268
22224
  fps: NC_FOOTAGE_FPS,
22225
+ ...req.speed !== void 0 ? { speed: req.speed } : {},
21269
22226
  ...req.profile === "high" || req.profile === "mid" || req.profile === "low" ? { profile: req.profile } : {}
21270
22227
  });
21271
22228
  const buf = Buffer.from(res.base64, "base64");
@@ -21423,6 +22380,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21423
22380
  this.sweepRetention();
21424
22381
  this.runTrackRetentionSweep();
21425
22382
  this.stationaryRegistry?.sweep(Date.now());
22383
+ this.eventsOpsLog?.pruneBefore(Date.now() - OPS_LOG_RETENTION_MS);
21426
22384
  }, RETENTION_SWEEP_INTERVAL_MS);
21427
22385
  }
21428
22386
  /** Cap provider registrations returned from `onInitialize` — the widgets