@camstack/addon-post-analysis 1.2.90 → 1.2.91

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-BBIhkNDY.js");
5
+ const require_dist = require("../dist-CTIUuGRB.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -7786,17 +7786,40 @@ function referencesAudioClass(classes) {
7786
7786
  if (classes === void 0) return false;
7787
7787
  return classes.some((c) => c.trim().toLowerCase().startsWith("audio-"));
7788
7788
  }
7789
- function matchesSystemEvent(condition, subject) {
7790
- if (!condition.kinds.includes(subject.kind)) return false;
7791
- if (condition.deviceIds !== void 0 && (subject.deviceId === void 0 || !condition.deviceIds.includes(subject.deviceId))) return false;
7792
- if (condition.deviceTypes !== void 0 && (subject.deviceType === void 0 || !condition.deviceTypes.includes(subject.deviceType))) return false;
7793
- if (condition.nodeIds !== void 0) {
7794
- if (!subjectNodes(subject).some((id) => condition.nodeIds.includes(id))) return false;
7789
+ /**
7790
+ * Match one system subject against one rule's system-event condition.
7791
+ *
7792
+ * EVERY NARROWING FILTER IS SCOPED TO ITS KIND FAMILY, and the scoping is not
7793
+ * decided here: `systemEventFilterApplies` (in `@camstack/types`) is the one
7794
+ * statement of it, and both rule editors compute their filter visibility from
7795
+ * the same predicate. A filter outside a kind's family is IGNORED — never an
7796
+ * exclusion. It used to be an exclusion, and that made the operator's
7797
+ * eleven-kind rule with `deviceTypes: ['camera']` deliverable for exactly the
7798
+ * two kinds that carry a device type; the other nine failed closed and logged
7799
+ * nothing for four days.
7800
+ *
7801
+ * Inside its family a filter still narrows, and still fails CLOSED when the
7802
+ * subject cannot produce the identity it tests.
7803
+ *
7804
+ * Returns `undefined` on a match.
7805
+ */
7806
+ function systemEventExclusion(condition, subject) {
7807
+ if (!condition.kinds.includes(subject.kind)) return "kinds";
7808
+ const applies = (filter) => require_dist.systemEventFilterApplies(subject.kind, filter);
7809
+ if (condition.deviceIds !== void 0 && applies("deviceIds")) {
7810
+ if (subject.deviceId === void 0 || !condition.deviceIds.includes(subject.deviceId)) return "deviceIds";
7795
7811
  }
7796
- if (condition.packageNames !== void 0) {
7797
- if (!subjectPackages(subject).some((name) => condition.packageNames.includes(name))) return false;
7812
+ if (condition.deviceTypes !== void 0 && applies("deviceTypes")) {
7813
+ if (subject.deviceType === void 0 || !condition.deviceTypes.includes(subject.deviceType)) return "deviceTypes";
7814
+ }
7815
+ const wantedNodes = condition.nodeIds;
7816
+ if (wantedNodes !== void 0 && applies("nodeIds")) {
7817
+ if (!subjectNodes(subject).some((id) => wantedNodes.includes(id))) return "nodeIds";
7818
+ }
7819
+ const wantedPackages = condition.packageNames;
7820
+ if (wantedPackages !== void 0 && applies("packageNames")) {
7821
+ if (!subjectPackages(subject).some((name) => wantedPackages.includes(name))) return "packageNames";
7798
7822
  }
7799
- return true;
7800
7823
  }
7801
7824
  function subjectNodes(subject) {
7802
7825
  const ids = [...subject.nodeIds ?? []];
@@ -7812,7 +7835,14 @@ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
7812
7835
  if (subject.kind === "system-event") {
7813
7836
  if (rule.delivery !== "system-event") return fail("delivery");
7814
7837
  const condition = rule.conditions.systemEvent;
7815
- if (condition === void 0 || subject.systemEvent === void 0 || !matchesSystemEvent(condition, subject.systemEvent)) return fail("systemEvent");
7838
+ const systemEvent = subject.systemEvent;
7839
+ if (condition === void 0 || systemEvent === void 0) return fail("systemEvent");
7840
+ const excluded = systemEventExclusion(condition, systemEvent);
7841
+ if (excluded !== void 0) return excluded === "kinds" ? fail("systemEvent") : {
7842
+ matched: false,
7843
+ failedCondition: "systemEvent",
7844
+ excludedBySystemEventFilter: excluded
7845
+ };
7816
7846
  if (rule.conditions.occupancy !== void 0) return fail("occupancy");
7817
7847
  if (rule.conditions.audio !== void 0) return fail("audio");
7818
7848
  } else if (subject.kind === "occupancy-event") {
@@ -20060,6 +20090,39 @@ var NotificationCenter = class NotificationCenter {
20060
20090
  if (identity.isCamera && identity.detectionActive === false) return "detection-off";
20061
20091
  }
20062
20092
  /**
20093
+ * "Your rule covers this kind, and your own filter dropped it."
20094
+ *
20095
+ * INFO, not debug, and unconditional. The alternative is what shipped: a
20096
+ * rule that selected eleven system-event kinds delivered two of them for
20097
+ * four days, and the nine it silently refused produced no line anywhere —
20098
+ * indistinguishable from an intake that had stopped emitting. The filter is
20099
+ * NAMED, and so is the value that failed it, because the fix is always an
20100
+ * edit to that one list.
20101
+ *
20102
+ * `tags.deviceId` carries the SUBJECT's numeric device id (the system
20103
+ * subject's own `deviceId` is the sentinel {@link NC_SYSTEM_DEVICE_ID}), so
20104
+ * "why is 617 louder than 2018?" is answerable by grouping. A node- or
20105
+ * package-scoped exclusion is about no device and carries no tag.
20106
+ */
20107
+ reportSystemFilterExclusion(rule, subject, filter) {
20108
+ const system = subject.systemEvent;
20109
+ if (system === void 0) return;
20110
+ this.logger.info("system event excluded by a rule filter", {
20111
+ ...system.deviceId !== void 0 ? { tags: { deviceId: system.deviceId } } : {},
20112
+ meta: {
20113
+ ruleId: rule.id,
20114
+ rule: rule.name,
20115
+ kind: system.kind,
20116
+ filter,
20117
+ eventId: subject.recordId,
20118
+ subject: system.subject,
20119
+ ...system.deviceType !== void 0 ? { deviceType: system.deviceType } : {},
20120
+ ...system.nodeId !== void 0 ? { nodeId: system.nodeId } : {},
20121
+ ...system.packageName !== void 0 ? { packageName: system.packageName } : {}
20122
+ }
20123
+ });
20124
+ }
20125
+ /**
20063
20126
  * SAY that the scene gate suppressed this rule.
20064
20127
  *
20065
20128
  * A branch that drops work silently logs it — the three-hour media blackout
@@ -20479,6 +20542,7 @@ var NotificationCenter = class NotificationCenter {
20479
20542
  const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id), (id) => this.zoneOwners.get(id), (sceneId) => this.sceneStates.get(sceneId));
20480
20543
  if (!evaluation.matched) {
20481
20544
  if (evaluation.failedCondition === "scene") this.reportSceneSuppression(rule, subject);
20545
+ if (evaluation.excludedBySystemEventFilter !== void 0) this.reportSystemFilterExclusion(rule, subject, evaluation.excludedBySystemEventFilter);
20482
20546
  rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
20483
20547
  outcomes.push({
20484
20548
  ruleId: rule.id,
@@ -22379,7 +22443,7 @@ var ncActions = require_dist.defineCustomActions({
22379
22443
  "nc.getArtifact": require_dist.customAction(require_dist.object({ artifactId: require_dist.string().min(1) }), require_dist.object({ artifact: NcArtifactRecordSchema.nullable() }), { caller: "required" })
22380
22444
  });
22381
22445
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
22382
- function requireCaller(caller) {
22446
+ function requireCaller$1(caller) {
22383
22447
  if (!caller || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error("forbidden: authenticated caller required");
22384
22448
  return caller;
22385
22449
  }
@@ -22477,14 +22541,14 @@ function makeNcActionHandlers(deps) {
22477
22541
  };
22478
22542
  return {
22479
22543
  "nc.listRules": async (_input, caller) => {
22480
- const c = requireCaller(caller);
22544
+ const c = requireCaller$1(caller);
22481
22545
  return { rules: (c.isAdmin ? deps.ruleStore.list() : deps.ruleStore.listForOwner(c.userId)).map((r) => ({
22482
22546
  ...r,
22483
22547
  readOnly: !c.isAdmin && r.ownerUserId !== c.userId
22484
22548
  })) };
22485
22549
  },
22486
22550
  "nc.listTrackGroups": async (input, caller) => {
22487
- requireCaller(caller);
22551
+ requireCaller$1(caller);
22488
22552
  const list = deps.listTrackGroups;
22489
22553
  if (list === void 0) return { groups: [] };
22490
22554
  return { groups: [...await list(input)] };
@@ -22494,7 +22558,7 @@ function makeNcActionHandlers(deps) {
22494
22558
  taxonomy: require_dist.NC_TAXONOMY
22495
22559
  }),
22496
22560
  "nc.createRule": async (input, caller) => {
22497
- const c = requireCaller(caller);
22561
+ const c = requireCaller$1(caller);
22498
22562
  const parsed = require_dist.NcRuleInputSchema.parse(input.rule);
22499
22563
  await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
22500
22564
  const rule = await deps.ruleStore.create({
@@ -22508,7 +22572,7 @@ function makeNcActionHandlers(deps) {
22508
22572
  return { rule };
22509
22573
  },
22510
22574
  "nc.updateRule": async (input, caller) => {
22511
- const c = requireCaller(caller);
22575
+ const c = requireCaller$1(caller);
22512
22576
  assertRuleEditable(input.ruleId, c);
22513
22577
  const patch = require_dist.NcRulePatchSchema.parse(input.patch);
22514
22578
  if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
@@ -22521,7 +22585,7 @@ function makeNcActionHandlers(deps) {
22521
22585
  return { rule };
22522
22586
  },
22523
22587
  "nc.deleteRule": async (input, caller) => {
22524
- const c = requireCaller(caller);
22588
+ const c = requireCaller$1(caller);
22525
22589
  assertRuleEditable(input.ruleId, c);
22526
22590
  await deps.ruleStore.delete(input.ruleId);
22527
22591
  deps.logger.info("nc rule deleted", { meta: {
@@ -22531,11 +22595,11 @@ function makeNcActionHandlers(deps) {
22531
22595
  return { success: true };
22532
22596
  },
22533
22597
  "nc.listSnoozes": async (_input, caller) => {
22534
- const c = requireCaller(caller);
22598
+ const c = requireCaller$1(caller);
22535
22599
  return { snoozes: [...deps.snooze.list(c.userId, c.isAdmin)] };
22536
22600
  },
22537
22601
  "nc.createSnooze": async (input, caller) => {
22538
- const c = requireCaller(caller);
22602
+ const c = requireCaller$1(caller);
22539
22603
  const parsed = require_dist.NcSnoozeInputSchema.parse(input.snooze);
22540
22604
  if (parsed.ruleId !== void 0) assertRuleVisible(parsed.ruleId, c);
22541
22605
  const snooze = await deps.snooze.create(parsed, c);
@@ -22548,12 +22612,12 @@ function makeNcActionHandlers(deps) {
22548
22612
  return { snooze };
22549
22613
  },
22550
22614
  "nc.cancelSnooze": async (input, caller) => {
22551
- const c = requireCaller(caller);
22615
+ const c = requireCaller$1(caller);
22552
22616
  await deps.snooze.cancel(input.snoozeId, c);
22553
22617
  return { success: true };
22554
22618
  },
22555
22619
  "nc.injectTestEvent": async (input, caller) => {
22556
- const c = requireCaller(caller);
22620
+ const c = requireCaller$1(caller);
22557
22621
  if (!c.isAdmin) throw new Error("forbidden: admin required to inject a test event");
22558
22622
  const inject = deps.injectTestEvent;
22559
22623
  if (inject === void 0) throw new Error("this node does not host the notification centre — inject on the hub");
@@ -22569,7 +22633,7 @@ function makeNcActionHandlers(deps) {
22569
22633
  return inject(parsed);
22570
22634
  },
22571
22635
  "nc.testRule": async (input, caller) => {
22572
- const c = requireCaller(caller);
22636
+ const c = requireCaller$1(caller);
22573
22637
  if (!c.isAdmin) throw new Error("forbidden: admin required to test a rule");
22574
22638
  const inject = deps.injectTestEvent;
22575
22639
  if (inject === void 0) throw new Error("this node does not host the notification centre — test on the hub");
@@ -22601,7 +22665,7 @@ function makeNcActionHandlers(deps) {
22601
22665
  };
22602
22666
  },
22603
22667
  "nc.getRuleStatus": async (input, caller) => {
22604
- const c = requireCaller(caller);
22668
+ const c = requireCaller$1(caller);
22605
22669
  const status = deps.ruleStatus;
22606
22670
  if (status === void 0) throw new Error("this node does not host the notification centre — ask the hub");
22607
22671
  const detection = deps.ruleStore.get(input.ruleId);
@@ -22617,7 +22681,7 @@ function makeNcActionHandlers(deps) {
22617
22681
  throw new Error(`rule not found: ${input.ruleId}`);
22618
22682
  },
22619
22683
  "nc.listTimelapseRules": async (_input, caller) => {
22620
- const c = requireCaller(caller);
22684
+ const c = requireCaller$1(caller);
22621
22685
  const tl = requireTimelapse();
22622
22686
  return { rules: (c.isAdmin ? tl.store.list() : tl.store.listForOwner(c.userId)).map((r) => ({
22623
22687
  ...r,
@@ -22625,7 +22689,7 @@ function makeNcActionHandlers(deps) {
22625
22689
  })) };
22626
22690
  },
22627
22691
  "nc.upsertTimelapseRule": async (input, caller) => {
22628
- const c = requireCaller(caller);
22692
+ const c = requireCaller$1(caller);
22629
22693
  const tl = requireTimelapse();
22630
22694
  if (input.ruleId === void 0) {
22631
22695
  if (input.rule === void 0) throw new Error("upsertTimelapseRule: rule or ruleId required");
@@ -22661,7 +22725,7 @@ function makeNcActionHandlers(deps) {
22661
22725
  return { rule };
22662
22726
  },
22663
22727
  "nc.deleteTimelapseRule": async (input, caller) => {
22664
- const c = requireCaller(caller);
22728
+ const c = requireCaller$1(caller);
22665
22729
  const tl = requireTimelapse();
22666
22730
  assertTimelapseEditable(input.ruleId, c, tl);
22667
22731
  await tl.store.delete(input.ruleId);
@@ -22672,7 +22736,7 @@ function makeNcActionHandlers(deps) {
22672
22736
  return { success: true };
22673
22737
  },
22674
22738
  "nc.testTimelapseRule": async (input, caller) => {
22675
- const c = requireCaller(caller);
22739
+ const c = requireCaller$1(caller);
22676
22740
  if (!c.isAdmin) throw new Error("forbidden: admin required to produce a test timelapse");
22677
22741
  const tl = requireTimelapse();
22678
22742
  deps.logger.info("timelapse test requested", {
@@ -22699,14 +22763,14 @@ function makeNcActionHandlers(deps) {
22699
22763
  };
22700
22764
  },
22701
22765
  "nc.setRuleTargetEnabled": async (input, caller) => {
22702
- const c = requireCaller(caller);
22766
+ const c = requireCaller$1(caller);
22703
22767
  assertRuleVisible(input.ruleId, c);
22704
22768
  await assertTargetsOwned([input.targetId], c);
22705
22769
  await deps.ruleStore.setRuleTargetEnabled(input.ruleId, input.targetId, input.enabled);
22706
22770
  return { success: true };
22707
22771
  },
22708
22772
  "nc.listSummaryRules": async (_input, caller) => {
22709
- const c = requireCaller(caller);
22773
+ const c = requireCaller$1(caller);
22710
22774
  const sum = requireSummary();
22711
22775
  return { rules: (c.isAdmin ? sum.store.list() : sum.store.listForOwner(c.userId)).map((r) => ({
22712
22776
  ...r,
@@ -22714,7 +22778,7 @@ function makeNcActionHandlers(deps) {
22714
22778
  })) };
22715
22779
  },
22716
22780
  "nc.upsertSummaryRule": async (input, caller) => {
22717
- const c = requireCaller(caller);
22781
+ const c = requireCaller$1(caller);
22718
22782
  if (!c.isAdmin) throw new Error("forbidden: admin required to author a summary rule");
22719
22783
  const sum = requireSummary();
22720
22784
  if (input.ruleId === void 0) {
@@ -22747,7 +22811,7 @@ function makeNcActionHandlers(deps) {
22747
22811
  return { rule };
22748
22812
  },
22749
22813
  "nc.deleteSummaryRule": async (input, caller) => {
22750
- const c = requireCaller(caller);
22814
+ const c = requireCaller$1(caller);
22751
22815
  if (!c.isAdmin) throw new Error("forbidden: admin required to delete a summary rule");
22752
22816
  await requireSummary().store.delete(input.ruleId);
22753
22817
  deps.logger.info("summary rule deleted", { meta: {
@@ -22757,7 +22821,7 @@ function makeNcActionHandlers(deps) {
22757
22821
  return { success: true };
22758
22822
  },
22759
22823
  "nc.testSummaryRule": async (input, caller) => {
22760
- const c = requireCaller(caller);
22824
+ const c = requireCaller$1(caller);
22761
22825
  if (!c.isAdmin) throw new Error("forbidden: admin required to produce a test summary");
22762
22826
  const sum = requireSummary();
22763
22827
  const startMs = input.windowStartMs;
@@ -22804,7 +22868,7 @@ function makeNcActionHandlers(deps) {
22804
22868
  "nc.setLanguage": async (input) => requireTexts().setLanguage(input.language),
22805
22869
  "nc.setTextOverrides": async (input) => requireTexts().setOverrides(input.language, input.overrides),
22806
22870
  "nc.listArtifacts": async (input, caller) => {
22807
- const c = requireCaller(caller);
22871
+ const c = requireCaller$1(caller);
22808
22872
  const page = await requireArtifacts().list(input, {
22809
22873
  userId: c.userId,
22810
22874
  isAdmin: c.isAdmin
@@ -22816,7 +22880,7 @@ function makeNcActionHandlers(deps) {
22816
22880
  };
22817
22881
  },
22818
22882
  "nc.getArtifact": async (input, caller) => {
22819
- const c = requireCaller(caller);
22883
+ const c = requireCaller$1(caller);
22820
22884
  return { artifact: await requireArtifacts().get(input.artifactId, {
22821
22885
  userId: c.userId,
22822
22886
  isAdmin: c.isAdmin
@@ -24465,11 +24529,11 @@ var WipeResultSchema = require_dist.object({ deleted: require_dist.number() });
24465
24529
  * and can never be called: the bridge insists on `{}` and the schema rejects
24466
24530
  * it. Verified on the live hub, both directions.
24467
24531
  */
24468
- var NoInputSchema = require_dist.object({});
24532
+ var NoInputSchema$1 = require_dist.object({});
24469
24533
  var embeddingActions = require_dist.defineCustomActions({
24470
24534
  "embedding.rebuild": require_dist.customAction(RebuildInputSchema, RebuildStartedSchema, { auth: "admin" }),
24471
- "embedding.rebuildStatus": require_dist.customAction(NoInputSchema, RebuildStatusSchema),
24472
- "embedding.wipe": require_dist.customAction(NoInputSchema, WipeResultSchema, { auth: "admin" })
24535
+ "embedding.rebuildStatus": require_dist.customAction(NoInputSchema$1, RebuildStatusSchema),
24536
+ "embedding.wipe": require_dist.customAction(NoInputSchema$1, WipeResultSchema, { auth: "admin" })
24473
24537
  });
24474
24538
  //#endregion
24475
24539
  //#region src/pipeline-analytics/map-with-concurrency.ts
@@ -30008,6 +30072,220 @@ function createVideoclipsProvider(deps) {
30008
30072
  }
30009
30073
  };
30010
30074
  }
30075
+ //#endregion
30076
+ //#region src/pipeline-analytics/viewer-settings-actions.ts
30077
+ /**
30078
+ * Viewer settings snapshots — hub-shared named blobs of the viewer's stores.
30079
+ *
30080
+ * Rides `addons.custom` (`viewerSettings.*`) so it ships with an addon deploy
30081
+ * and does not wait on a cap/codegen/server image. Ownership is the
30082
+ * authenticated caller (`caller: 'required'`); snapshots are visible to every
30083
+ * hub user, overwrite/delete only of one's own.
30084
+ */
30085
+ /**
30086
+ * @durable class=config owner=pipeline-analytics
30087
+ * write="a signed-in viewer user saves a named snapshot of their local stores"
30088
+ * retention="none — a row goes only when its owner deletes it"
30089
+ */
30090
+ var VIEWER_SETTINGS_SNAPSHOTS_COLLECTION = "viewer-settings-snapshots";
30091
+ var COLUMNS = [
30092
+ {
30093
+ name: "id",
30094
+ type: "TEXT",
30095
+ primaryKey: true,
30096
+ notNull: true
30097
+ },
30098
+ {
30099
+ name: "name",
30100
+ type: "TEXT",
30101
+ notNull: true
30102
+ },
30103
+ {
30104
+ name: "ownerUserId",
30105
+ type: "TEXT",
30106
+ notNull: true
30107
+ },
30108
+ {
30109
+ name: "ownerUsername",
30110
+ type: "TEXT",
30111
+ notNull: true
30112
+ },
30113
+ {
30114
+ name: "createdAt",
30115
+ type: "INTEGER",
30116
+ notNull: true
30117
+ },
30118
+ {
30119
+ name: "updatedAt",
30120
+ type: "INTEGER",
30121
+ notNull: true
30122
+ },
30123
+ {
30124
+ name: "blob",
30125
+ type: "JSON",
30126
+ notNull: true
30127
+ }
30128
+ ];
30129
+ var NameSchema = require_dist.string().trim().min(1).max(80);
30130
+ var BlobSchema = require_dist.record(require_dist.string(), require_dist.unknown());
30131
+ var ViewerSettingsSnapshotSummarySchema = require_dist.object({
30132
+ id: require_dist.string(),
30133
+ name: require_dist.string(),
30134
+ ownerUserId: require_dist.string(),
30135
+ ownerUsername: require_dist.string(),
30136
+ createdAt: require_dist.number().int(),
30137
+ updatedAt: require_dist.number().int(),
30138
+ mine: require_dist.boolean()
30139
+ });
30140
+ var ViewerSettingsSnapshotSchema = ViewerSettingsSnapshotSummarySchema.extend({ blob: BlobSchema });
30141
+ var NoInputSchema = require_dist.object({});
30142
+ var viewerSettingsActions = require_dist.defineCustomActions({
30143
+ "viewerSettings.list": require_dist.customAction(NoInputSchema, require_dist.object({ snapshots: require_dist.array(ViewerSettingsSnapshotSummarySchema) }), { caller: "required" }),
30144
+ "viewerSettings.save": require_dist.customAction(require_dist.object({
30145
+ name: NameSchema,
30146
+ blob: BlobSchema,
30147
+ ownerUsername: require_dist.string().min(1).max(80)
30148
+ }), require_dist.object({ snapshot: ViewerSettingsSnapshotSummarySchema }), {
30149
+ kind: "mutation",
30150
+ caller: "required"
30151
+ }),
30152
+ "viewerSettings.get": require_dist.customAction(require_dist.object({ id: require_dist.string().min(1) }), require_dist.object({ snapshot: ViewerSettingsSnapshotSchema.nullable() }), { caller: "required" }),
30153
+ "viewerSettings.delete": require_dist.customAction(require_dist.object({ id: require_dist.string().min(1) }), require_dist.object({ success: require_dist.literal(true) }), {
30154
+ kind: "mutation",
30155
+ caller: "required"
30156
+ })
30157
+ });
30158
+ function requireCaller(caller) {
30159
+ if (!caller) throw new Error("viewerSettings: authenticated caller required");
30160
+ return caller;
30161
+ }
30162
+ function asRecord(data) {
30163
+ return typeof data === "object" && data !== null ? data : {};
30164
+ }
30165
+ function toSummary(id, data, callerUserId) {
30166
+ const parsed = ViewerSettingsSnapshotSummarySchema.omit({ mine: true }).safeParse({
30167
+ id,
30168
+ name: data.name,
30169
+ ownerUserId: data.ownerUserId,
30170
+ ownerUsername: data.ownerUsername,
30171
+ createdAt: data.createdAt,
30172
+ updatedAt: data.updatedAt
30173
+ });
30174
+ if (!parsed.success) return null;
30175
+ return {
30176
+ ...parsed.data,
30177
+ mine: parsed.data.ownerUserId === callerUserId
30178
+ };
30179
+ }
30180
+ var ViewerSettingsSnapshotStore = class {
30181
+ store;
30182
+ constructor(store) {
30183
+ this.store = store;
30184
+ }
30185
+ static async declare(store) {
30186
+ await store.declareCollection.mutate({
30187
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
30188
+ columns: [...COLUMNS],
30189
+ indexes: [{
30190
+ name: "idx_viewer_settings_owner",
30191
+ columns: ["ownerUserId"]
30192
+ }]
30193
+ });
30194
+ }
30195
+ async list(caller) {
30196
+ const rows = await this.store.query.query({
30197
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
30198
+ filter: {
30199
+ orderBy: {
30200
+ field: "updatedAt",
30201
+ direction: "desc"
30202
+ },
30203
+ limit: 200
30204
+ }
30205
+ });
30206
+ const out = [];
30207
+ for (const row of rows) {
30208
+ const summary = toSummary(row.id, asRecord(row.data), caller.userId);
30209
+ if (summary) out.push(summary);
30210
+ }
30211
+ return out;
30212
+ }
30213
+ async get(id, caller) {
30214
+ const row = (await this.store.query.query({
30215
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
30216
+ filter: {
30217
+ where: { id },
30218
+ limit: 1
30219
+ }
30220
+ }))[0];
30221
+ if (!row) return null;
30222
+ const data = asRecord(row.data);
30223
+ const summary = toSummary(row.id, data, caller.userId);
30224
+ if (!summary) return null;
30225
+ const blob = BlobSchema.safeParse(data.blob);
30226
+ if (!blob.success) return null;
30227
+ return {
30228
+ ...summary,
30229
+ blob: blob.data
30230
+ };
30231
+ }
30232
+ async save(input, caller) {
30233
+ const now = Date.now();
30234
+ const prior = (await this.store.query.query({
30235
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
30236
+ filter: {
30237
+ where: {
30238
+ ownerUserId: caller.userId,
30239
+ name: input.name
30240
+ },
30241
+ limit: 1
30242
+ }
30243
+ }))[0];
30244
+ const id = prior?.id ?? (0, node_crypto.randomUUID)();
30245
+ const createdAt = typeof asRecord(prior?.data).createdAt === "number" ? asRecord(prior?.data).createdAt : now;
30246
+ await this.store.set.mutate({
30247
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
30248
+ key: id,
30249
+ value: {
30250
+ name: input.name,
30251
+ ownerUserId: caller.userId,
30252
+ ownerUsername: input.ownerUsername,
30253
+ createdAt,
30254
+ updatedAt: now,
30255
+ blob: input.blob
30256
+ }
30257
+ });
30258
+ return {
30259
+ id,
30260
+ name: input.name,
30261
+ ownerUserId: caller.userId,
30262
+ ownerUsername: input.ownerUsername,
30263
+ createdAt,
30264
+ updatedAt: now,
30265
+ mine: true
30266
+ };
30267
+ }
30268
+ async delete(id, caller) {
30269
+ const row = await this.get(id, caller);
30270
+ if (!row) throw new Error("viewerSettings: snapshot not found");
30271
+ if (row.ownerUserId !== caller.userId && !caller.isAdmin) throw new Error("viewerSettings: only the owner can delete this snapshot");
30272
+ await this.store.delete.mutate({
30273
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
30274
+ key: id
30275
+ });
30276
+ }
30277
+ };
30278
+ function makeViewerSettingsActionHandlers(store) {
30279
+ return {
30280
+ "viewerSettings.list": async (_input, caller) => ({ snapshots: await store.list(requireCaller(caller)) }),
30281
+ "viewerSettings.save": async (input, caller) => ({ snapshot: await store.save(input, requireCaller(caller)) }),
30282
+ "viewerSettings.get": async (input, caller) => ({ snapshot: await store.get(input.id, requireCaller(caller)) }),
30283
+ "viewerSettings.delete": async (input, caller) => {
30284
+ await store.delete(input.id, requireCaller(caller));
30285
+ return { success: true };
30286
+ }
30287
+ };
30288
+ }
30011
30289
  /**
30012
30290
  * The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
30013
30291
  *
@@ -30814,7 +31092,15 @@ var AudioDetectionSettingsSchema = require_dist.object({
30814
31092
  /** Flood ceiling: markers per camera per rolling 24 h. This, not the
30815
31093
  * thresholds, is what bounds the worst case — a mis-tuned threshold can
30816
31094
  * only ever cost the operator this many markers a day. */
30817
- audioMarkerMaxPerDay: require_dist.number().int().positive().default(12)
31095
+ audioMarkerMaxPerDay: require_dist.number().int().positive().default(12),
31096
+ /** Materialize one event + one synthetic track per classified episode. */
31097
+ audioEpisodeEnabled: require_dist.boolean().default(true),
31098
+ /** Class must be heard continuously this long before the episode opens. */
31099
+ audioEpisodeOpenHoldSec: require_dist.number().nonnegative().default(1.5),
31100
+ /** Class must be absent this long before the episode closes. */
31101
+ audioEpisodeCloseGapSec: require_dist.number().positive().default(12),
31102
+ /** After close, the same class cannot reopen until this elapses. */
31103
+ audioEpisodeCooldownSec: require_dist.number().nonnegative().default(30)
30818
31104
  });
30819
31105
  var AUDIO_DETECTION_DEFAULTS = AudioDetectionSettingsSchema.parse({});
30820
31106
  /**
@@ -30838,6 +31124,159 @@ function resolveAudioDetectionSettings(raw) {
30838
31124
  return resolved.success ? resolved.data : AUDIO_DETECTION_DEFAULTS;
30839
31125
  }
30840
31126
  //#endregion
31127
+ //#region src/pipeline-analytics/audio-episode-hysteresis.ts
31128
+ var EMPTY = {
31129
+ pendingClass: null,
31130
+ pendingSinceMs: 0,
31131
+ openClass: null,
31132
+ episodeId: null,
31133
+ lastHeardMs: 0,
31134
+ lastClosedClass: null,
31135
+ lastClosedMs: 0
31136
+ };
31137
+ function emptyAudioEpisodeState() {
31138
+ return EMPTY;
31139
+ }
31140
+ function episodeIdFor(className, timestampMs) {
31141
+ return `${className}:${timestampMs}`;
31142
+ }
31143
+ function inCooldown(state, className, timestampMs, knobs) {
31144
+ return state.lastClosedClass === className && timestampMs - state.lastClosedMs < knobs.cooldownMs;
31145
+ }
31146
+ function closedState(className, atMs) {
31147
+ return {
31148
+ ...EMPTY,
31149
+ lastClosedClass: className,
31150
+ lastClosedMs: atMs
31151
+ };
31152
+ }
31153
+ function observeAudioEpisode(state, sample, knobs) {
31154
+ if (!knobs.enabled) return {
31155
+ nextState: EMPTY,
31156
+ action: { kind: "none" }
31157
+ };
31158
+ const { timestampMs, className } = sample;
31159
+ const openClass = state.openClass;
31160
+ const openId = state.episodeId;
31161
+ if (openClass !== null && openId !== null) {
31162
+ if (className === openClass) return {
31163
+ nextState: {
31164
+ ...state,
31165
+ pendingClass: null,
31166
+ pendingSinceMs: 0,
31167
+ lastHeardMs: timestampMs
31168
+ },
31169
+ action: {
31170
+ kind: "hold",
31171
+ className: openClass,
31172
+ episodeId: openId
31173
+ }
31174
+ };
31175
+ if (className === null) {
31176
+ if (timestampMs - state.lastHeardMs < knobs.closeGapMs) return {
31177
+ nextState: state,
31178
+ action: { kind: "none" }
31179
+ };
31180
+ return {
31181
+ nextState: closedState(openClass, timestampMs),
31182
+ action: {
31183
+ kind: "close",
31184
+ className: openClass,
31185
+ episodeId: openId
31186
+ }
31187
+ };
31188
+ }
31189
+ if (inCooldown(state, className, timestampMs, knobs)) return {
31190
+ nextState: state,
31191
+ action: { kind: "none" }
31192
+ };
31193
+ const pendingSince = state.pendingClass === className ? state.pendingSinceMs : timestampMs;
31194
+ if (knobs.openHoldMs === 0 || state.pendingClass === className && timestampMs - pendingSince >= knobs.openHoldMs) {
31195
+ const openedId = episodeIdFor(className, timestampMs);
31196
+ return {
31197
+ nextState: {
31198
+ pendingClass: null,
31199
+ pendingSinceMs: 0,
31200
+ openClass: className,
31201
+ episodeId: openedId,
31202
+ lastHeardMs: timestampMs,
31203
+ lastClosedClass: openClass,
31204
+ lastClosedMs: timestampMs
31205
+ },
31206
+ action: {
31207
+ kind: "replace",
31208
+ closed: {
31209
+ className: openClass,
31210
+ episodeId: openId
31211
+ },
31212
+ opened: {
31213
+ className,
31214
+ episodeId: openedId
31215
+ }
31216
+ }
31217
+ };
31218
+ }
31219
+ return {
31220
+ nextState: {
31221
+ ...state,
31222
+ pendingClass: className,
31223
+ pendingSinceMs: pendingSince
31224
+ },
31225
+ action: { kind: "none" }
31226
+ };
31227
+ }
31228
+ if (className === null) {
31229
+ if (state.pendingClass === null) return {
31230
+ nextState: state,
31231
+ action: { kind: "none" }
31232
+ };
31233
+ return {
31234
+ nextState: {
31235
+ ...state,
31236
+ pendingClass: null,
31237
+ pendingSinceMs: 0
31238
+ },
31239
+ action: { kind: "none" }
31240
+ };
31241
+ }
31242
+ if (inCooldown(state, className, timestampMs, knobs)) return {
31243
+ nextState: state,
31244
+ action: { kind: "none" }
31245
+ };
31246
+ const pendingSince = state.pendingClass === className ? state.pendingSinceMs : timestampMs;
31247
+ if (knobs.openHoldMs === 0 || state.pendingClass === className && timestampMs - pendingSince >= knobs.openHoldMs) {
31248
+ const episodeId = episodeIdFor(className, timestampMs);
31249
+ return {
31250
+ nextState: {
31251
+ pendingClass: null,
31252
+ pendingSinceMs: 0,
31253
+ openClass: className,
31254
+ episodeId,
31255
+ lastHeardMs: timestampMs,
31256
+ lastClosedClass: state.lastClosedClass,
31257
+ lastClosedMs: state.lastClosedMs
31258
+ },
31259
+ action: {
31260
+ kind: "open",
31261
+ className,
31262
+ episodeId
31263
+ }
31264
+ };
31265
+ }
31266
+ if (state.pendingClass === className) return {
31267
+ nextState: state,
31268
+ action: { kind: "none" }
31269
+ };
31270
+ return {
31271
+ nextState: {
31272
+ ...state,
31273
+ pendingClass: className,
31274
+ pendingSinceMs: timestampMs
31275
+ },
31276
+ action: { kind: "none" }
31277
+ };
31278
+ }
31279
+ //#endregion
30841
31280
  //#region src/pipeline-analytics/audio-event-router.ts
30842
31281
  /**
30843
31282
  * Decide whether an audio frame is a (meaningful) classification event or a
@@ -48402,6 +48841,51 @@ function buildGlobalSettingsSchema() {
48402
48841
  }
48403
48842
  ]
48404
48843
  },
48844
+ {
48845
+ id: "audio-episodes",
48846
+ title: "Audio episodes",
48847
+ description: "One timeline track and one event per classified sound, held open with hysteresis so a long cry is not four tracks. No class filter: speech, crying, a bark — the knobs below are what stop repeats. Leave on for a day or two and judge the spam, then tighten per camera.",
48848
+ columns: 2,
48849
+ fields: [
48850
+ {
48851
+ type: "boolean",
48852
+ key: "audioEpisodeEnabled",
48853
+ label: "Create audio episodes",
48854
+ description: "When off, classified audio still notifies from other paths but does not open a synthetic track or coalesce classification events.",
48855
+ default: AUDIO_DETECTION_DEFAULTS.audioEpisodeEnabled
48856
+ },
48857
+ {
48858
+ type: "number",
48859
+ key: "audioEpisodeOpenHoldSec",
48860
+ label: "Open hold",
48861
+ description: "The class must be heard continuously this long before an episode opens. Filters 200 ms blips.",
48862
+ min: 0,
48863
+ step: .5,
48864
+ default: AUDIO_DETECTION_DEFAULTS.audioEpisodeOpenHoldSec,
48865
+ unit: "s"
48866
+ },
48867
+ {
48868
+ type: "number",
48869
+ key: "audioEpisodeCloseGapSec",
48870
+ label: "Close gap",
48871
+ description: "The class must be absent this long before the episode closes. A pause inside a cry does not start a second track.",
48872
+ min: 1,
48873
+ step: 1,
48874
+ default: AUDIO_DETECTION_DEFAULTS.audioEpisodeCloseGapSec,
48875
+ unit: "s"
48876
+ },
48877
+ {
48878
+ type: "number",
48879
+ key: "audioEpisodeCooldownSec",
48880
+ label: "Cooldown",
48881
+ description: "After an episode closes, the same class cannot reopen until this elapses.",
48882
+ min: 0,
48883
+ step: 5,
48884
+ default: AUDIO_DETECTION_DEFAULTS.audioEpisodeCooldownSec,
48885
+ unit: "s"
48886
+ }
48887
+ ]
48888
+ },
48405
48889
  {
48406
48890
  id: "face-recognition",
48407
48891
  title: "Face recognition",
@@ -49887,7 +50371,8 @@ var customActions = {
49887
50371
  ...ncActions,
49888
50372
  ...embeddingActions,
49889
50373
  ...debugActions,
49890
- ...orphanAuditActions
50374
+ ...orphanAuditActions,
50375
+ ...viewerSettingsActions
49891
50376
  };
49892
50377
  /**
49893
50378
  * Assist threshold when the caller does not pin one. Packages have their own
@@ -49989,7 +50474,12 @@ var LTS_FLUSH_INTERVAL_MS = 6e4;
49989
50474
  var OPS_LOG_RETENTION_MS = 720 * 60 * 6e4;
49990
50475
  /** Throttle for the "overlay synthesis failed" warn — one line / minute / device. */
49991
50476
  var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
49992
- var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
50477
+ /** How recently a spatial track must have been alive to count as "visual
50478
+ * happening" — stamp audio labels on that track instead of opening a
50479
+ * synthetic audio episode. */
50480
+ var AUDIO_EPISODE_VISUAL_QUIET_MS = 2e3;
50481
+ /** Persist lastSeen on an open synthetic audio track at most this often. */
50482
+ var AUDIO_EPISODE_PERSIST_MS = 2e3;
49993
50483
  var MOTION_EVENT_HEARTBEAT_MS = 5e3;
49994
50484
  /**
49995
50485
  * Stored media kinds that carry NO drawn bounding box, in fallback preference
@@ -50461,6 +50951,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
50461
50951
  * fallible read on the decision path.
50462
50952
  */
50463
50953
  lastTrackActivityMs = /* @__PURE__ */ new Map();
50954
+ /** Per-device classified-audio episode hysteresis. */
50955
+ audioEpisodeByDevice = /* @__PURE__ */ new Map();
50956
+ /** Open synthetic tracks keyed by hysteresis episodeId. */
50957
+ audioEpisodeTracks = /* @__PURE__ */ new Map();
50958
+ audioEpisodeLastPersistMs = /* @__PURE__ */ new Map();
50464
50959
  settingsCaches = new DeviceSettingsCacheHub();
50465
50960
  /** Avoids a store read every ~30 Hz audio frame. */
50466
50961
  audioSettingsCache = this.settingsCaches.registerDeviceCache("audio-detection", SETTINGS_CACHE_TTL_MS);
@@ -50744,6 +51239,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
50744
51239
  * NAME — its scope list is derived from the classification registry, not
50745
51240
  * from a set of store instances. */
50746
51241
  settingsStore = null;
51242
+ viewerSettingsSnapshots = null;
50747
51243
  /** Per-scope resume cursors, so a run interrupted by a restart costs one
50748
51244
  * page rather than a full rescan. In RAM: losing them costs a rescan, and
50749
51245
  * persisting them would be a durable record of a purely advisory position. */
@@ -50881,12 +51377,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
50881
51377
  ...ncActions,
50882
51378
  ...embeddingActions,
50883
51379
  ...debugActions,
50884
- ...orphanAuditActions
51380
+ ...orphanAuditActions,
51381
+ ...viewerSettingsActions
50885
51382
  },
50886
51383
  actionHandlers: {
50887
51384
  ...ncHandlers,
50888
51385
  ...this.buildEmbeddingActionHandlers(),
50889
- ...this.buildDebugActionHandlers()
51386
+ ...this.buildDebugActionHandlers(),
51387
+ ...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
50890
51388
  }
50891
51389
  } : {}
50892
51390
  };
@@ -51061,8 +51559,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
51061
51559
  OpsLogStore.declare(api.settingsStore),
51062
51560
  AnalyticsLts.declare(api.settingsStore),
51063
51561
  SceneStore.declare(api.settingsStore),
51064
- NotificationCenter.declare(api.settingsStore)
51562
+ NotificationCenter.declare(api.settingsStore),
51563
+ ViewerSettingsSnapshotStore.declare(api.settingsStore)
51065
51564
  ]);
51565
+ this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
51066
51566
  runLabelTierMigration({
51067
51567
  store: api.settingsStore,
51068
51568
  logger: this.ctx.logger,
@@ -52322,6 +52822,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
52322
52822
  this.forgetDeviceProcessors(deviceId);
52323
52823
  this.levelStateByDevice.delete(deviceId);
52324
52824
  this.lastTrackActivityMs.delete(deviceId);
52825
+ this.audioEpisodeByDevice.delete(deviceId);
52826
+ for (const [episodeId, track] of this.audioEpisodeTracks) if (track.deviceId === deviceId) {
52827
+ this.audioEpisodeTracks.delete(episodeId);
52828
+ this.audioEpisodeLastPersistMs.delete(episodeId);
52829
+ }
52325
52830
  this.audioMarkers?.forgetDevice(deviceId);
52326
52831
  this.settingsCaches.clearDevice(deviceId);
52327
52832
  this.bindingCache?.invalidate(deviceId);
@@ -54218,6 +54723,146 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
54218
54723
  maxPerDay: settings.audioMarkerMaxPerDay
54219
54724
  };
54220
54725
  }
54726
+ audioEpisodeKnobs(settings) {
54727
+ return {
54728
+ enabled: settings.audioEpisodeEnabled,
54729
+ openHoldMs: settings.audioEpisodeOpenHoldSec * 1e3,
54730
+ closeGapMs: settings.audioEpisodeCloseGapSec * 1e3,
54731
+ cooldownMs: settings.audioEpisodeCooldownSec * 1e3
54732
+ };
54733
+ }
54734
+ hasLiveVisualTrack(deviceId, atMs) {
54735
+ const at = this.lastTrackActivityMs.get(deviceId);
54736
+ return at !== void 0 && atMs - at < AUDIO_EPISODE_VISUAL_QUIET_MS;
54737
+ }
54738
+ async driveAudioEpisode(input) {
54739
+ const { nextState, action } = observeAudioEpisode(this.audioEpisodeByDevice.get(input.deviceId) ?? emptyAudioEpisodeState(), {
54740
+ timestampMs: input.timestamp,
54741
+ className: input.className
54742
+ }, input.knobs);
54743
+ this.audioEpisodeByDevice.set(input.deviceId, nextState);
54744
+ switch (action.kind) {
54745
+ case "none": return;
54746
+ case "open":
54747
+ await this.openAudioEpisode(input, action);
54748
+ return;
54749
+ case "hold":
54750
+ await this.holdAudioEpisode(input, action);
54751
+ return;
54752
+ case "close":
54753
+ await this.persistAudioEpisodeLastSeen(action, input.timestamp, true);
54754
+ return;
54755
+ case "replace":
54756
+ await this.persistAudioEpisodeLastSeen(action.closed, input.timestamp, true);
54757
+ await this.openAudioEpisode(input, action.opened);
54758
+ return;
54759
+ }
54760
+ }
54761
+ async openAudioEpisode(input, ref) {
54762
+ const ev = {
54763
+ id: (0, node_crypto.randomUUID)(),
54764
+ deviceId: input.deviceId,
54765
+ timestamp: input.timestamp,
54766
+ kind: "audio",
54767
+ rms: input.rms,
54768
+ dbfs: input.dbfs,
54769
+ classification: {
54770
+ className: ref.className,
54771
+ ...input.originalClass !== void 0 ? { originalClass: input.originalClass } : {},
54772
+ score: input.score
54773
+ }
54774
+ };
54775
+ this.lastAudioInsertByDevice.set(input.deviceId, {
54776
+ className: ref.className,
54777
+ atMs: input.timestamp
54778
+ });
54779
+ await this.eventStore.insertAudio(ev);
54780
+ this.notificationCenter?.onAudioEventPersisted(ev);
54781
+ this.trackStore?.addAudioLabelEpisode(input.deviceId, ref.className, input.score, input.timestamp);
54782
+ this.ctx.eventBus.emit({
54783
+ id: `pa-${ev.id}`,
54784
+ timestamp: new Date(ev.timestamp),
54785
+ source: {
54786
+ type: "addon",
54787
+ id: "pipeline-analytics",
54788
+ addonId: "pipeline-analytics"
54789
+ },
54790
+ category: require_dist.EventCategory.PipelineAnalyticsDetectionEvent,
54791
+ data: {
54792
+ deviceId: input.deviceId,
54793
+ kind: "audio",
54794
+ eventId: ev.id,
54795
+ timestamp: ev.timestamp
54796
+ }
54797
+ });
54798
+ if (this.hasLiveVisualTrack(input.deviceId, input.timestamp)) return;
54799
+ const materializer = this.syntheticTracks;
54800
+ if (materializer === null) return;
54801
+ try {
54802
+ const track = await materializer.materialize({
54803
+ cameraId: input.deviceId,
54804
+ sourceDeviceId: input.deviceId,
54805
+ source: "audio",
54806
+ kind: ref.className,
54807
+ timestamp: input.timestamp,
54808
+ audioLabels: [{
54809
+ label: ref.className,
54810
+ peakScore: input.score,
54811
+ count: 1,
54812
+ firstAt: input.timestamp,
54813
+ lastAt: input.timestamp
54814
+ }]
54815
+ });
54816
+ if (track) {
54817
+ this.audioEpisodeTracks.set(ref.episodeId, track);
54818
+ this.audioEpisodeLastPersistMs.set(ref.episodeId, input.timestamp);
54819
+ }
54820
+ } catch (err) {
54821
+ this.ctx.logger.warn("audio-episode synthetic track failed", {
54822
+ tags: { deviceId: input.deviceId },
54823
+ meta: {
54824
+ className: ref.className,
54825
+ error: require_dist.errMsg(err)
54826
+ }
54827
+ });
54828
+ }
54829
+ }
54830
+ async holdAudioEpisode(input, ref) {
54831
+ await this.persistAudioEpisodeLastSeen(ref, input.timestamp, false);
54832
+ }
54833
+ async persistAudioEpisodeLastSeen(ref, timestamp, force) {
54834
+ const track = this.audioEpisodeTracks.get(ref.episodeId);
54835
+ if (!track) {
54836
+ if (force) {
54837
+ this.audioEpisodeTracks.delete(ref.episodeId);
54838
+ this.audioEpisodeLastPersistMs.delete(ref.episodeId);
54839
+ }
54840
+ return;
54841
+ }
54842
+ const last = this.audioEpisodeLastPersistMs.get(ref.episodeId) ?? 0;
54843
+ if (!force && timestamp - last < AUDIO_EPISODE_PERSIST_MS) return;
54844
+ const updated = {
54845
+ ...track,
54846
+ lastSeen: timestamp
54847
+ };
54848
+ this.audioEpisodeTracks.set(ref.episodeId, updated);
54849
+ this.audioEpisodeLastPersistMs.set(ref.episodeId, timestamp);
54850
+ try {
54851
+ await this.trackStore?.persistSyntheticTrack(updated);
54852
+ } catch (err) {
54853
+ this.ctx.logger.warn("audio-episode lastSeen persist failed", {
54854
+ tags: { deviceId: track.deviceId },
54855
+ meta: {
54856
+ episodeId: ref.episodeId,
54857
+ error: require_dist.errMsg(err)
54858
+ }
54859
+ });
54860
+ }
54861
+ if (force) {
54862
+ this.audioEpisodeTracks.delete(ref.episodeId);
54863
+ this.audioEpisodeLastPersistMs.delete(ref.episodeId);
54864
+ }
54865
+ }
54221
54866
  async handleAudioResult(data) {
54222
54867
  if (this.shuttingDown) return;
54223
54868
  const { deviceId, frame } = data;
@@ -54261,48 +54906,27 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
54261
54906
  });
54262
54907
  return;
54263
54908
  }
54264
- const last = this.lastAudioInsertByDevice.get(deviceId);
54265
- const sameClass = last?.className === route.className;
54266
- const heartbeatDue = !last || timestamp - last.atMs >= AUDIO_EVENT_HEARTBEAT_MS;
54267
- if (sameClass && !heartbeatDue) return;
54268
- const ev = {
54269
- id: (0, node_crypto.randomUUID)(),
54909
+ await this.driveAudioEpisode({
54270
54910
  deviceId,
54271
54911
  timestamp,
54272
- kind: "audio",
54912
+ className: route.className,
54913
+ score: topClassification.score,
54914
+ originalClass: topClassification.debug?.originalClass,
54273
54915
  rms: level?.rms ?? 0,
54274
54916
  dbfs: level?.dbfs ?? 0,
54275
- classification: {
54276
- className: topClassification.macroClass,
54277
- ...topClassification.debug?.originalClass !== void 0 ? { originalClass: topClassification.debug.originalClass } : {},
54278
- score: topClassification.score
54279
- }
54280
- };
54281
- this.lastAudioInsertByDevice.set(deviceId, {
54282
- className: route.className,
54283
- atMs: timestamp
54284
- });
54285
- await this.eventStore.insertAudio(ev);
54286
- this.notificationCenter?.onAudioEventPersisted(ev);
54287
- this.trackStore?.addAudioLabelEpisode(deviceId, route.className, topClassification.score, timestamp);
54288
- this.ctx.eventBus.emit({
54289
- id: `pa-${ev.id}`,
54290
- timestamp: new Date(ev.timestamp),
54291
- source: {
54292
- type: "addon",
54293
- id: "pipeline-analytics",
54294
- addonId: "pipeline-analytics"
54295
- },
54296
- category: require_dist.EventCategory.PipelineAnalyticsDetectionEvent,
54297
- data: {
54298
- deviceId,
54299
- kind: "audio",
54300
- eventId: ev.id,
54301
- timestamp: ev.timestamp
54302
- }
54917
+ knobs: this.audioEpisodeKnobs(settings)
54303
54918
  });
54304
54919
  return;
54305
54920
  }
54921
+ await this.driveAudioEpisode({
54922
+ deviceId,
54923
+ timestamp,
54924
+ className: null,
54925
+ score: 0,
54926
+ rms: level?.rms ?? 0,
54927
+ dbfs: level?.dbfs ?? 0,
54928
+ knobs: this.audioEpisodeKnobs(settings)
54929
+ });
54306
54930
  const res = observeLevel(this.levelStateByDevice.get(deviceId) ?? emptyLevelState(), {
54307
54931
  timestampMs: timestamp,
54308
54932
  dbfs: level?.dbfs ?? -Infinity