@camstack/addon-post-analysis 1.2.70 → 1.2.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { $ as readDeviceStateFrom, A as TimelapseRuleInputSchema, B as customAction, C as NcSnoozeSchema, Ct as unknown, D as RECORDING_EXPORT_MAX_READ_BYTES, E as OpsLogEntrySchema, F as alarmPanelCapability, G as faceGalleryCapability, H as deriveRecordingMode, I as assertTimelapseCadences, J as isScheduleActive, L as audioMetricsCapability, M as TimelapseRuleSchema, N as TrackSourceSchema, O as RetrainStatusSchema, P as addonWidgetsSourceCapability, Q as plateGalleryCapability, R as buildEventKindDescriptor, S as NcSnoozeInputSchema, St as string, T as NcTaxonomySchema, V as defineCustomActions, W as encodeVectorBase64, X as notificationRulesCapability, Y as kebabToCamel, Z as pipelineAnalyticsCapability, _ as NcRuleInputSchema, _t as literal, at as errMsg, b as NcRuleTargetSchema, bt as partialRecord, c as EVENT_PAD_MS, ct as createEvent, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as nodePin, et as readTimelapseGeneratedAt, f as NC_CONDITION_CATALOG, ft as sleep, g as NcConditionDescriptorSchema, gt as discriminatedUnion, h as NC_TAXONOMY, ht as boolean, i as DETECTION_MACRO_CLASSES, it as zoneAnalyticsCapability, j as TimelapseRulePatchSchema, k as TIMELAPSE_DENSE_FLOOR_SEC, l as LabelAttributionSchema, lt as hydrateSchema, mt as array, n as DEFAULT_EVENT_COLOR, nt as vectorDimFromBase64, o as DeclaredDevices, ot as BaseAddon, p as NC_DEFAULT_SNOOZE_MINUTES, pt as _enum, q as isDetectionMacroClass, rt as videoclipsCapability, s as EVENT_KIND_BY_CAP, st as DeviceType, t as BaseDevice, tt as subKindsOf, u as MACRO_LABELS, ut as isDeviceScopedCap, v as NcRulePatchSchema, vt as number, w as NcSnoozeSuppressedSchema, wt as EventCategory, x as NcScheduleSchema, xt as record, y as NcRuleSchema, yt as object, z as cosineSimilarity } from "../dist-6T-F0YJO.mjs";
1
+ import { $ as readDeviceStateFrom, A as TimelapseRuleInputSchema, B as customAction, C as NcSnoozeSchema, Ct as unknown, D as RECORDING_EXPORT_MAX_READ_BYTES, E as OpsLogEntrySchema, F as alarmPanelCapability, G as faceGalleryCapability, H as deriveRecordingMode, I as assertTimelapseCadences, J as isScheduleActive, L as audioMetricsCapability, M as TimelapseRuleSchema, N as TrackSourceSchema, O as RetrainStatusSchema, P as addonWidgetsSourceCapability, Q as plateGalleryCapability, R as buildEventKindDescriptor, S as NcSnoozeInputSchema, St as string, T as NcTaxonomySchema, V as defineCustomActions, W as encodeVectorBase64, X as notificationRulesCapability, Y as kebabToCamel, Z as pipelineAnalyticsCapability, _ as NcRuleInputSchema, _t as literal, at as errMsg, b as NcRuleTargetSchema, bt as partialRecord, c as EVENT_PAD_MS, ct as createEvent, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as nodePin, et as readTimelapseGeneratedAt, f as NC_CONDITION_CATALOG, ft as sleep, g as NcConditionDescriptorSchema, gt as discriminatedUnion, h as NC_TAXONOMY, ht as boolean, i as DETECTION_MACRO_CLASSES, it as zoneAnalyticsCapability, j as TimelapseRulePatchSchema, k as TIMELAPSE_DENSE_FLOOR_SEC, l as LabelAttributionSchema, lt as hydrateSchema, mt as array, n as DEFAULT_EVENT_COLOR, nt as vectorDimFromBase64, o as DeclaredDevices, ot as BaseAddon, p as NC_DEFAULT_SNOOZE_MINUTES, pt as _enum, q as isDetectionMacroClass, rt as videoclipsCapability, s as EVENT_KIND_BY_CAP, st as DeviceType, t as BaseDevice, tt as subKindsOf, u as MACRO_LABELS, ut as isDeviceScopedCap, v as NcRulePatchSchema, vt as number, w as NcSnoozeSuppressedSchema, wt as EventCategory, x as NcScheduleSchema, xt as record, y as NcRuleSchema, yt as object, z as cosineSimilarity } from "../dist-6te1200B.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -549,7 +549,14 @@ function alarmCoverageGap(coverage) {
549
549
  return "all-skipped";
550
550
  }
551
551
  /**
552
- * "Away armed — 3 cameras: Front door, Garage, Gate."
552
+ * "Attivi: Cancello, Garage — Esclusi: Porta retro."
553
+ *
554
+ * A LIST, not a sentence. The mode is in the title and the body is one
555
+ * `Label: names` line per device group, because this is read on a lock screen
556
+ * and the question is always "which one" — the prose version ("3 dispositivi
557
+ * attivi: …. Esclusi: ….") made the operator parse a paragraph to find a
558
+ * window. Each variant still owns its own catalog key, so the Texts tab can
559
+ * re-word "nothing is armed" without touching "these are armed".
553
560
  *
554
561
  * Names, not ids: this is read by a person standing at a door. An id that has
555
562
  * no name falls back to a NAMED, translated placeholder rather than being
@@ -628,7 +635,7 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
628
635
  };
629
636
  }
630
637
  /**
631
- * "Home armed — with the veranda window open, excluded."
638
+ * "Attivi: 2 — Bypassati: Finestra veranda."
632
639
  *
633
640
  * The bypassed sensors are named and the armed ones are COUNTED, not listed:
634
641
  * this sentence's subject is the exception, and burying it under thirteen
@@ -651,13 +658,13 @@ function armedWithBypass(coverage, bypassed, deviceName) {
651
658
  };
652
659
  }
653
660
  /**
654
- * "The alarm was NOT armed: the veranda window is open."
661
+ * "Aperti: Finestra veranda, Porta retro" + how to arm anyway.
655
662
  *
656
663
  * The one alarm sentence about something that did not happen, so it has to
657
664
  * carry both halves an operator needs to act: WHICH sensors are open, and that
658
665
  * pressing arm again arms without them. Naming them is the whole message — a
659
666
  * refusal that says only "cannot arm" is a panel the operator will conclude is
660
- * broken.
667
+ * broken — so the names are the FIRST line and the retry hint is the second.
661
668
  */
662
669
  function buildArmRefusedAnnouncement(mode, names, retrySeconds) {
663
670
  return {
@@ -672,7 +679,7 @@ function buildArmRefusedAnnouncement(mode, names, retrySeconds) {
672
679
  };
673
680
  }
674
681
  /**
675
- * The same coverage, said 30 seconds earlier: "Armed in 30 seconds."
682
+ * The EXCEPTIONS to the same coverage, said 30 seconds earlier.
676
683
  *
677
684
  * The exit-delay sentence is deliberately SHORTER than the arm one — it is read
678
685
  * on the way out of a door — and it carries exactly one thing the arm sentence
@@ -680,8 +687,15 @@ function buildArmRefusedAnnouncement(mode, names, retrySeconds) {
680
687
  * A mode that arms nothing is worth interrupting somebody for at second 0 and
681
688
  * merely worth recording at second 30.
682
689
  *
683
- * Pluralises on the SECONDS, not on the devices: this sentence's only number is
684
- * the countdown, and Italian needs "1 secondo" / "30 secondi".
690
+ * **It never repeats the device list.** The countdown lives in the TITLE
691
+ * (`system.alarm-arming.title.<mode>` renders `{{seconds}}`), and the armed
692
+ * list arrives thirty seconds later in the arm announcement — printing it
693
+ * twice is the verbosity that made the operator stop reading both. So the body
694
+ * is what is WRONG: what was bypassed, what is excluded, or that nothing is.
695
+ *
696
+ * `seconds` stays in the params because the title asks for it, and `textCount`
697
+ * stays with it: a row enqueued before this deploy still pluralises on the
698
+ * countdown it was written for.
685
699
  */
686
700
  function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
687
701
  const textParams = {
@@ -696,6 +710,7 @@ function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
696
710
  },
697
711
  textCount: seconds
698
712
  };
713
+ const skippedNames = coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ");
699
714
  const gap = alarmCoverageGap(coverage);
700
715
  if (gap === "uncovered") return {
701
716
  bodyKey: "alarm.arming.body.uncovered",
@@ -706,10 +721,18 @@ function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
706
721
  bodyKey: "alarm.arming.body.allSkipped",
707
722
  textParams: {
708
723
  ...textParams,
709
- names: coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ")
724
+ names: skippedNames
710
725
  },
711
726
  textCount: seconds
712
727
  };
728
+ if (coverage.skippedDevices.length > 0) return {
729
+ bodyKey: "alarm.arming.body.skipped",
730
+ textParams: {
731
+ ...textParams,
732
+ names: skippedNames
733
+ },
734
+ textCount: coverage.skippedDevices.length
735
+ };
713
736
  return {
714
737
  bodyKey: "alarm.arming.body",
715
738
  textParams,
@@ -1550,6 +1573,401 @@ var NC_ALARM_DECLARATION = {
1550
1573
  DeviceClass: NcAlarmPanelDevice
1551
1574
  };
1552
1575
  //#endregion
1576
+ //#region src/notification-center/artifact-index.ts
1577
+ var NC_ARTIFACTS_COLLECTION = "notification-center:artifacts";
1578
+ var NcArtifactKindSchema = _enum([
1579
+ "timelapse",
1580
+ "timelapse-preview",
1581
+ "summary-mosaic"
1582
+ ]);
1583
+ /**
1584
+ * How the viewer should DRAW it. Derived from the mime once, here, so no client
1585
+ * has to carry a mime table — and so a GIF that degraded to a JPEG is drawn as
1586
+ * the JPEG it actually is.
1587
+ */
1588
+ var NcArtifactMediaTypeSchema = _enum([
1589
+ "image",
1590
+ "gif",
1591
+ "video"
1592
+ ]);
1593
+ /** The relative hub path of the authenticated bytes route for an artefact id. */
1594
+ var NC_ARTIFACT_READ_PREFIX = "nc-artifact-read";
1595
+ var NcArtifactRecordSchema = object({
1596
+ /** The `NcArtifactStore` id — the same id the bytes route resolves. */
1597
+ id: string().min(1),
1598
+ kind: NcArtifactKindSchema,
1599
+ mediaType: NcArtifactMediaTypeSchema,
1600
+ mime: string().min(1),
1601
+ sizeBytes: number().int().nonnegative(),
1602
+ /** When the artefact was PRODUCED. */
1603
+ createdAt: number().int(),
1604
+ /** What the content is ABOUT (window end / event time). The sort key: a
1605
+ * timelapse rendered at 06:05 is about the night before, and a list ordered
1606
+ * by production time puts it in the wrong morning. */
1607
+ capturedAt: number().int(),
1608
+ /** After this the bytes are gone. Derived from the retained shelf's own age
1609
+ * bound — never a second retention policy. */
1610
+ expiresAt: number().int(),
1611
+ /** The camera the artefact is FILED under. Null only if it belongs to none. */
1612
+ deviceId: number().int().nullable(),
1613
+ /** Every camera the artefact covers, for display. */
1614
+ deviceIds: array(number().int()),
1615
+ ruleId: string().optional(),
1616
+ ruleName: string().optional(),
1617
+ title: string().optional(),
1618
+ /** A companion still for a video artefact (a timelapse's preview frame) —
1619
+ * what a list renders as the thumbnail. */
1620
+ posterArtifactId: string().optional(),
1621
+ /** RELATIVE hub path of the authenticated bytes route. Relative on purpose:
1622
+ * the viewer already holds the hub origin it is authenticated against, and
1623
+ * only the SIGNED plane needs to guess which host a stranger can reach. */
1624
+ downloadPath: string().min(1)
1625
+ });
1626
+ var NcArtifactQuerySchema = object({
1627
+ kinds: array(NcArtifactKindSchema).optional(),
1628
+ deviceIds: array(number().int()).optional(),
1629
+ ruleId: string().optional(),
1630
+ /** Inclusive `capturedAt` window. */
1631
+ fromMs: number().int().optional(),
1632
+ toMs: number().int().optional(),
1633
+ limit: number().int().min(1).max(200).default(50),
1634
+ offset: number().int().min(0).default(0)
1635
+ });
1636
+ var NC_ARTIFACTS_COLUMNS = [
1637
+ {
1638
+ name: "id",
1639
+ type: "TEXT",
1640
+ primaryKey: true,
1641
+ notNull: true
1642
+ },
1643
+ {
1644
+ name: "kind",
1645
+ type: "TEXT",
1646
+ notNull: true
1647
+ },
1648
+ {
1649
+ name: "deviceId",
1650
+ type: "INTEGER"
1651
+ },
1652
+ {
1653
+ name: "ruleId",
1654
+ type: "TEXT"
1655
+ },
1656
+ (
1657
+ /** `''` for a global artefact — see the header note on visibility. */
1658
+ {
1659
+ name: "ownerUserId",
1660
+ type: "TEXT",
1661
+ notNull: true
1662
+ }),
1663
+ {
1664
+ name: "capturedAt",
1665
+ type: "INTEGER",
1666
+ notNull: true
1667
+ },
1668
+ {
1669
+ name: "expiresAt",
1670
+ type: "INTEGER",
1671
+ notNull: true
1672
+ },
1673
+ (
1674
+ /** The FULL record (Zod-validated on read) — the scalars above are indexed
1675
+ * projections only. */
1676
+ {
1677
+ name: "record",
1678
+ type: "JSON",
1679
+ notNull: true
1680
+ })
1681
+ ];
1682
+ var NC_ARTIFACTS_INDEXES = [
1683
+ {
1684
+ name: "idx_nc_artifacts_captured",
1685
+ columns: ["capturedAt"]
1686
+ },
1687
+ {
1688
+ name: "idx_nc_artifacts_device",
1689
+ columns: ["deviceId", "capturedAt"]
1690
+ },
1691
+ {
1692
+ name: "idx_nc_artifacts_kind",
1693
+ columns: ["kind", "capturedAt"]
1694
+ },
1695
+ {
1696
+ name: "idx_nc_artifacts_expires",
1697
+ columns: ["expiresAt"]
1698
+ }
1699
+ ];
1700
+ /** Upper bound for a half-open `whereBetween` — the store has no `>=`. */
1701
+ var TIME_MAX = 864e13;
1702
+ var TIME_MIN = -864e13;
1703
+ /** Prune scan ceiling — the index is bounded by the shelf it mirrors (2000
1704
+ * entries by default), so this only has to be comfortably above it. */
1705
+ var NC_ARTIFACT_PRUNE_SCAN_LIMIT = 2e4;
1706
+ /** `image/gif` → `gif`. Keyed on what the bytes ARE, never on what was asked
1707
+ * for: a GIF that degraded to a still must be drawn as a still. */
1708
+ function mediaTypeForMime(mime) {
1709
+ if (mime === "image/gif") return "gif";
1710
+ if (mime.startsWith("video/")) return "video";
1711
+ return "image";
1712
+ }
1713
+ /** The relative hub path an authenticated client fetches the bytes from. */
1714
+ function artifactDownloadPath(addonId, artifactId) {
1715
+ return `/addon/${addonId}/${NC_ARTIFACT_READ_PREFIX}/${artifactId}`;
1716
+ }
1717
+ var NcArtifactIndex = class {
1718
+ store;
1719
+ logger;
1720
+ addonId;
1721
+ retentionMs;
1722
+ now;
1723
+ constructor(deps) {
1724
+ this.store = deps.store;
1725
+ this.logger = deps.logger;
1726
+ this.addonId = deps.addonId;
1727
+ this.retentionMs = deps.retentionMs;
1728
+ this.now = deps.now ?? (() => Date.now());
1729
+ }
1730
+ static async declare(store) {
1731
+ await store.declareCollection.mutate({
1732
+ collection: NC_ARTIFACTS_COLLECTION,
1733
+ columns: [...NC_ARTIFACTS_COLUMNS],
1734
+ indexes: [...NC_ARTIFACTS_INDEXES]
1735
+ });
1736
+ }
1737
+ /**
1738
+ * Record one retained artefact. Returns the row as the viewer will see it, or
1739
+ * null when the write failed — and a failure is LOGGED, per camera: an
1740
+ * artefact silently absent from the index reads to an operator as an artefact
1741
+ * that was never produced.
1742
+ */
1743
+ async record(input) {
1744
+ const createdAt = this.now();
1745
+ const record = NcArtifactRecordSchema.parse({
1746
+ id: input.id,
1747
+ kind: input.kind,
1748
+ mediaType: mediaTypeForMime(input.mime),
1749
+ mime: input.mime,
1750
+ sizeBytes: input.sizeBytes,
1751
+ createdAt,
1752
+ capturedAt: input.capturedAt,
1753
+ expiresAt: createdAt + this.retentionMs,
1754
+ deviceId: input.deviceId,
1755
+ deviceIds: [...input.deviceIds],
1756
+ ...input.ruleId !== void 0 ? { ruleId: input.ruleId } : {},
1757
+ ...input.ruleName !== void 0 ? { ruleName: input.ruleName } : {},
1758
+ ...input.title !== void 0 ? { title: input.title } : {},
1759
+ ...input.posterArtifactId !== void 0 ? { posterArtifactId: input.posterArtifactId } : {},
1760
+ downloadPath: artifactDownloadPath(this.addonId, input.id)
1761
+ });
1762
+ try {
1763
+ await this.store.set.mutate({
1764
+ collection: NC_ARTIFACTS_COLLECTION,
1765
+ key: record.id,
1766
+ value: {
1767
+ kind: record.kind,
1768
+ deviceId: record.deviceId,
1769
+ ruleId: record.ruleId ?? "",
1770
+ ownerUserId: input.ownerUserId ?? "",
1771
+ capturedAt: record.capturedAt,
1772
+ expiresAt: record.expiresAt,
1773
+ record
1774
+ }
1775
+ });
1776
+ return record;
1777
+ } catch (err) {
1778
+ this.logger.warn("artifact not indexed — it will not appear in the viewer", {
1779
+ ...record.deviceId !== null ? { tags: { deviceId: record.deviceId } } : {},
1780
+ meta: {
1781
+ artifactId: record.id,
1782
+ kind: record.kind,
1783
+ error: String(err)
1784
+ }
1785
+ });
1786
+ return null;
1787
+ }
1788
+ }
1789
+ /**
1790
+ * One page, newest `capturedAt` first.
1791
+ *
1792
+ * Expiry is a PREDICATE, not a post-filter: a row whose bytes the shelf has
1793
+ * already swept must not consume a slot of the page the caller asked for.
1794
+ */
1795
+ async list(query, viewer) {
1796
+ const filter = this.buildFilter(query, viewer);
1797
+ try {
1798
+ const [rows, total] = await Promise.all([this.store.query.query({
1799
+ collection: NC_ARTIFACTS_COLLECTION,
1800
+ filter: {
1801
+ ...filter,
1802
+ orderBy: {
1803
+ field: "capturedAt",
1804
+ direction: "desc"
1805
+ },
1806
+ limit: query.limit,
1807
+ offset: query.offset
1808
+ }
1809
+ }), this.store.count.query({
1810
+ collection: NC_ARTIFACTS_COLLECTION,
1811
+ filter
1812
+ })]);
1813
+ const artifacts = this.parseRows(rows);
1814
+ return {
1815
+ artifacts,
1816
+ total,
1817
+ hasMore: query.offset + artifacts.length < total
1818
+ };
1819
+ } catch (err) {
1820
+ this.logger.warn("artifact index query failed", { meta: { error: String(err) } });
1821
+ return {
1822
+ artifacts: [],
1823
+ total: 0,
1824
+ hasMore: false
1825
+ };
1826
+ }
1827
+ }
1828
+ /**
1829
+ * One artefact by id, or null when it does not exist, has expired, or belongs
1830
+ * to another user. All three answer the same way ON PURPOSE — a surface that
1831
+ * distinguishes "not yours" from "not there" tells a caller what other people
1832
+ * own.
1833
+ */
1834
+ async get(artifactId, viewer) {
1835
+ const row = await this.readRow(artifactId);
1836
+ if (row === null) return null;
1837
+ if (row.record.expiresAt <= this.now()) return null;
1838
+ if (!viewer.isAdmin && row.ownerUserId !== "" && row.ownerUserId !== viewer.userId) return null;
1839
+ return row.record;
1840
+ }
1841
+ /**
1842
+ * Resolve an id for the BYTES route — visibility-free by design.
1843
+ *
1844
+ * The route is served `authenticated`, so the hub has already established a
1845
+ * principal; what this answers is only "is this id an indexed artefact, and is
1846
+ * it still meant to exist". Per-user artefact scoping on the byte route would
1847
+ * be a second authorization model layered on an unguessable id, and this repo
1848
+ * has one of those already (`data-plane-access.ts`): a plane serving ONE item
1849
+ * the caller had to be told about is `authenticated`.
1850
+ */
1851
+ async resolve(artifactId) {
1852
+ return (await this.readRow(artifactId))?.record ?? null;
1853
+ }
1854
+ /** One row by primary key, parsed. Null when absent or no longer valid. */
1855
+ async readRow(artifactId) {
1856
+ let raw;
1857
+ try {
1858
+ raw = await this.store.get.query({
1859
+ collection: NC_ARTIFACTS_COLLECTION,
1860
+ key: artifactId
1861
+ });
1862
+ } catch (err) {
1863
+ this.logger.warn("artifact index lookup failed", { meta: {
1864
+ artifactId,
1865
+ error: String(err)
1866
+ } });
1867
+ return null;
1868
+ }
1869
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
1870
+ const row = { ...raw };
1871
+ const parsed = NcArtifactRecordSchema.safeParse(row["record"]);
1872
+ if (!parsed.success) return null;
1873
+ const owner = row["ownerUserId"];
1874
+ return {
1875
+ record: parsed.data,
1876
+ ownerUserId: typeof owner === "string" ? owner : ""
1877
+ };
1878
+ }
1879
+ /**
1880
+ * Drop rows whose bytes are gone, in ONE `deleteWhere` rather than an N+1
1881
+ * drain.
1882
+ *
1883
+ * TWO halves, and only one of them is safe everywhere:
1884
+ *
1885
+ * - **Expired rows** are time-based and node-independent. Always swept.
1886
+ * - **Orphans** — rows whose file a byte/count bound took before the age bound
1887
+ * did — are found by reconciling against the shelf. That reconcile is only
1888
+ * valid for a caller whose shelf holds the bytes for the WHOLE index, and
1889
+ * the index is CENTRAL while the shelf is local: a second node running this
1890
+ * with its own (empty) shelf would read every row as an orphan and delete
1891
+ * the entire library. Hence `keptIds: null` — a caller that is not the
1892
+ * producing node states so, and the destructive half becomes unreachable
1893
+ * rather than merely un-called.
1894
+ *
1895
+ * @param keptIds the ids the caller's shelf holds, or `null` when this node is
1896
+ * not the one that produces artefacts.
1897
+ */
1898
+ async prune(keptIds) {
1899
+ let removed = 0;
1900
+ try {
1901
+ const expired = await this.store.deleteWhere.mutate({
1902
+ collection: NC_ARTIFACTS_COLLECTION,
1903
+ filter: { whereBetween: { expiresAt: [TIME_MIN, this.now() - 1] } }
1904
+ });
1905
+ removed += expired.deleted;
1906
+ if (keptIds === null) {
1907
+ if (removed > 0) this.logger.debug("artifact index pruned (expired only)", { meta: { removed } });
1908
+ return removed;
1909
+ }
1910
+ const alive = new Set(keptIds);
1911
+ const orphans = (await this.store.query.query({
1912
+ collection: NC_ARTIFACTS_COLLECTION,
1913
+ filter: { limit: NC_ARTIFACT_PRUNE_SCAN_LIMIT }
1914
+ })).map((r) => r.id).filter((id) => !alive.has(id));
1915
+ for (const id of orphans) {
1916
+ await this.store.delete.mutate({
1917
+ collection: NC_ARTIFACTS_COLLECTION,
1918
+ key: id
1919
+ });
1920
+ removed += 1;
1921
+ }
1922
+ if (removed > 0) this.logger.debug("artifact index pruned", { meta: {
1923
+ removed,
1924
+ expired: expired.deleted,
1925
+ orphaned: orphans.length
1926
+ } });
1927
+ return removed;
1928
+ } catch (err) {
1929
+ this.logger.warn("artifact index prune failed", { meta: { error: String(err) } });
1930
+ return removed;
1931
+ }
1932
+ }
1933
+ /** The visibility predicate: a caller sees global artefacts plus his own; an
1934
+ * admin sees every one. */
1935
+ visibility(viewer) {
1936
+ if (viewer.isAdmin) return {};
1937
+ return { whereIn: { ownerUserId: ["", viewer.userId] } };
1938
+ }
1939
+ buildFilter(query, viewer) {
1940
+ const whereIn = { ...this.visibility(viewer).whereIn };
1941
+ if (query.kinds !== void 0 && query.kinds.length > 0) whereIn["kind"] = [...query.kinds];
1942
+ if (query.deviceIds !== void 0 && query.deviceIds.length > 0) whereIn["deviceId"] = [...query.deviceIds];
1943
+ const whereBetween = {
1944
+ capturedAt: [query.fromMs ?? TIME_MIN, query.toMs ?? TIME_MAX],
1945
+ expiresAt: [this.now(), TIME_MAX]
1946
+ };
1947
+ return {
1948
+ ...query.ruleId !== void 0 ? { where: { ruleId: query.ruleId } } : {},
1949
+ ...Object.keys(whereIn).length > 0 ? { whereIn } : {},
1950
+ whereBetween
1951
+ };
1952
+ }
1953
+ /** Parse the JSON column, skipping rows that no longer validate. A degraded
1954
+ * row must never cost the whole page. */
1955
+ parseRows(rows) {
1956
+ const out = [];
1957
+ let skipped = 0;
1958
+ for (const row of rows) {
1959
+ const parsed = NcArtifactRecordSchema.safeParse(row.data["record"]);
1960
+ if (!parsed.success) {
1961
+ skipped += 1;
1962
+ continue;
1963
+ }
1964
+ out.push(parsed.data);
1965
+ }
1966
+ if (skipped > 0) this.logger.warn("artifact index rows skipped as invalid", { meta: { skipped } });
1967
+ return out;
1968
+ }
1969
+ };
1970
+ //#endregion
1553
1971
  //#region ../types/dist/node.mjs
1554
1972
  var node_exports = /* @__PURE__ */ __exportAll({
1555
1973
  FilesystemStorageProvider: () => FilesystemStorageProvider,
@@ -1940,6 +2358,61 @@ var NcArtifactPlane = class {
1940
2358
  }
1941
2359
  }
1942
2360
  /**
2361
+ * Publish bytes AND file them in the viewer's browsable index (task #36).
2362
+ *
2363
+ * Two differences from {@link publish}, both deliberate:
2364
+ *
2365
+ * 1. **The bytes are stored FIRST, and stored even when no public base URL
2366
+ * exists.** `publish` returns early on a loopback-only install because a
2367
+ * signed link nobody can open is worth nothing — but the VIEWER reaches
2368
+ * the hub directly over its own session, so an install with no tunnel
2369
+ * still gets a full artifact library. Making the index depend on a
2370
+ * reachable public address would have made the feature absent exactly on
2371
+ * the installs that are LAN-only.
2372
+ * 2. **It answers the artefact id**, so a caller can link one artefact to
2373
+ * another — a timelapse's poster is its preview frame, and the video row
2374
+ * cannot name it without knowing the id the preview got.
2375
+ *
2376
+ * Never throws, for the same reason `publish` does not: an artefact library
2377
+ * is an enhancement and must never cost a notification.
2378
+ */
2379
+ async publishIndexed(bytes, mime, meta) {
2380
+ let artifactId = null;
2381
+ try {
2382
+ const index = this.deps.index;
2383
+ const stored = await this.deps.store.put(bytes, mime, { retain: index !== void 0 });
2384
+ if (index !== void 0) artifactId = (await index.record({
2385
+ ...meta,
2386
+ id: stored.id,
2387
+ mime,
2388
+ sizeBytes: stored.bytes
2389
+ }))?.id ?? null;
2390
+ const baseUrl = await this.resolveBaseUrl();
2391
+ return {
2392
+ url: baseUrl === null ? null : buildArtifactUrl({
2393
+ baseUrl,
2394
+ routePrefix: this.deps.routePrefix,
2395
+ id: stored.id,
2396
+ secret: this.deps.secret,
2397
+ expMs: this.now() + (this.deps.ttlMs ?? DEFAULT_TTL_MS)
2398
+ }),
2399
+ artifactId
2400
+ };
2401
+ } catch (err) {
2402
+ this.deps.logger.warn("indexed artifact publish failed — shipping bytes only", {
2403
+ ...meta.deviceId !== null ? { tags: { deviceId: meta.deviceId } } : {},
2404
+ meta: {
2405
+ kind: meta.kind,
2406
+ error: String(err)
2407
+ }
2408
+ });
2409
+ return {
2410
+ url: null,
2411
+ artifactId
2412
+ };
2413
+ }
2414
+ }
2415
+ /**
1943
2416
  * Absolutize a path against the SAME base URL an artifact link would use.
1944
2417
  *
1945
2418
  * For links this plane does not host but a RECIPIENT still has to open — a
@@ -2005,6 +2478,124 @@ var NcArtifactPlane = class {
2005
2478
  }
2006
2479
  };
2007
2480
  //#endregion
2481
+ //#region src/notification-center/artifact-read-plane.ts
2482
+ /**
2483
+ * Parse a single-range `Range` header against a known length.
2484
+ *
2485
+ * Returns `null` when there is nothing to honour (absent, malformed, multi-range
2486
+ * — all of which mean "send the whole thing"), and `'unsatisfiable'` when the
2487
+ * client asked for a range that cannot exist, which is a 416 and not a 200.
2488
+ */
2489
+ function parseByteRange(header, length) {
2490
+ if (header === void 0) return null;
2491
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
2492
+ if (match === null) return null;
2493
+ const rawStart = match[1] ?? "";
2494
+ const rawEnd = match[2] ?? "";
2495
+ if (rawStart === "" && rawEnd === "") return null;
2496
+ if (length === 0) return "unsatisfiable";
2497
+ if (rawStart === "") {
2498
+ const suffix = Number(rawEnd);
2499
+ if (suffix === 0) return "unsatisfiable";
2500
+ return {
2501
+ start: Math.max(0, length - suffix),
2502
+ end: length - 1
2503
+ };
2504
+ }
2505
+ const start = Number(rawStart);
2506
+ if (start >= length) return "unsatisfiable";
2507
+ const end = rawEnd === "" ? length - 1 : Math.min(Number(rawEnd), length - 1);
2508
+ if (end < start) return "unsatisfiable";
2509
+ return {
2510
+ start,
2511
+ end
2512
+ };
2513
+ }
2514
+ /** The last path segment, percent-decoded. */
2515
+ function artifactIdFrom(req) {
2516
+ const url = new URL(req.url ?? "/", "http://placeholder");
2517
+ try {
2518
+ return decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
2519
+ } catch {
2520
+ return "";
2521
+ }
2522
+ }
2523
+ var NcArtifactReadPlane = class {
2524
+ deps;
2525
+ constructor(deps) {
2526
+ this.deps = deps;
2527
+ }
2528
+ handler = async (req, res) => {
2529
+ const artifactId = artifactIdFrom(req);
2530
+ const record = await this.deps.index.resolve(artifactId);
2531
+ if (record === null) {
2532
+ this.refuse(res, 404, "not found");
2533
+ return;
2534
+ }
2535
+ const found = await this.deps.store.read(artifactId);
2536
+ if (found === null) {
2537
+ this.deps.logger.warn("artifact bytes gone — index row outlived the shelf", {
2538
+ ...record.deviceId !== null ? { tags: { deviceId: record.deviceId } } : {},
2539
+ meta: {
2540
+ artifactId,
2541
+ kind: record.kind,
2542
+ expiresAt: record.expiresAt
2543
+ }
2544
+ });
2545
+ this.refuse(res, 404, "not found");
2546
+ return;
2547
+ }
2548
+ if (req.method === "HEAD") {
2549
+ res.writeHead(200, this.baseHeaders(record, found.bytes.byteLength, found.mime));
2550
+ res.end();
2551
+ return;
2552
+ }
2553
+ const range = parseByteRange(headerOf(req, "range"), found.bytes.byteLength);
2554
+ if (range === "unsatisfiable") {
2555
+ res.writeHead(416, {
2556
+ "content-range": `bytes */${found.bytes.byteLength}`,
2557
+ "content-type": "text/plain"
2558
+ });
2559
+ res.end("range not satisfiable");
2560
+ return;
2561
+ }
2562
+ if (range === null) {
2563
+ res.writeHead(200, this.baseHeaders(record, found.bytes.byteLength, found.mime));
2564
+ res.end(found.bytes);
2565
+ return;
2566
+ }
2567
+ const slice = found.bytes.subarray(range.start, range.end + 1);
2568
+ res.writeHead(206, {
2569
+ ...this.baseHeaders(record, slice.byteLength, found.mime),
2570
+ "content-range": `bytes ${range.start}-${range.end}/${found.bytes.byteLength}`
2571
+ });
2572
+ res.end(slice);
2573
+ };
2574
+ baseHeaders(record, length, mime) {
2575
+ return {
2576
+ "content-type": mime,
2577
+ "content-length": String(length),
2578
+ "accept-ranges": "bytes",
2579
+ "cache-control": "private, max-age=3600",
2580
+ "content-disposition": `inline; filename="${filenameFor(record)}"`
2581
+ };
2582
+ }
2583
+ refuse(res, status, body) {
2584
+ res.writeHead(status, { "content-type": "text/plain" });
2585
+ res.end(body);
2586
+ }
2587
+ };
2588
+ /** A name a client will accept, from what the artefact IS. */
2589
+ function filenameFor(record) {
2590
+ const ext = record.mime === "video/mp4" ? "mp4" : record.mime === "image/gif" ? "gif" : record.mime === "image/png" ? "png" : "jpg";
2591
+ return `${record.kind}-${record.capturedAt}.${ext}`;
2592
+ }
2593
+ function headerOf(req, name) {
2594
+ const value = req.headers[name];
2595
+ if (Array.isArray(value)) return value[0];
2596
+ return value;
2597
+ }
2598
+ //#endregion
2008
2599
  //#region src/notification-center/artifact-store.ts
2009
2600
  /**
2010
2601
  * NcArtifactStore — the bytes behind a signed notification-artifact URL.
@@ -2019,9 +2610,25 @@ var NcArtifactPlane = class {
2019
2610
  * bites first, the oldest artifacts go. A sweep on boot clears whatever a crash
2020
2611
  * left behind — the directory is disposable by construction, so a lost file is
2021
2612
  * a 404 on one link, never a corrupt state.
2613
+ *
2614
+ * ## The retained shelf (`kept/`)
2615
+ *
2616
+ * Task #36 gave the viewer a browsable artifact index, and an index whose bytes
2617
+ * evaporate in six hours is an index of 404s. So a `put` may ask to be RETAINED:
2618
+ * the bytes then land on a second shelf inside the same directory with its OWN
2619
+ * three bounds (age, count, and — because a timelapse mp4 is megabytes — total
2620
+ * BYTES). It is still a scratch, just a longer one: the index row carries an
2621
+ * `expiresAt` derived from the same age bound, so "gone" is a state the viewer
2622
+ * is told about rather than a surprise 404.
2623
+ *
2624
+ * The shelf is a subdirectory ON PURPOSE. The scratch's `list()` skips
2625
+ * non-files, so the two shelves cannot sweep each other, and neither bound had
2626
+ * to learn about the other's entries.
2022
2627
  */
2023
2628
  var DEFAULT_MAX_AGE_MS = 6 * 36e5;
2024
2629
  var DEFAULT_MAX_ENTRIES = 500;
2630
+ /** The retained shelf's subdirectory name, inside the scratch directory. */
2631
+ var KEPT_SUBDIR = "kept";
2025
2632
  /** `image/jpeg` → `jpg`, so a fetched link has a name a client will accept. */
2026
2633
  function extensionFor(mime) {
2027
2634
  if (mime === "image/jpeg") return "jpg";
@@ -2035,6 +2642,10 @@ var NcArtifactStore = class {
2035
2642
  now;
2036
2643
  maxAgeMs;
2037
2644
  maxEntries;
2645
+ keptMaxAgeMs;
2646
+ keptMaxEntries;
2647
+ keptMaxBytes;
2648
+ keptDir;
2038
2649
  /** id → mime, so the route can answer with the right content-type without
2039
2650
  * re-deriving it from the extension on every request. */
2040
2651
  mimeById = /* @__PURE__ */ new Map();
@@ -2043,17 +2654,32 @@ var NcArtifactStore = class {
2043
2654
  this.now = options.now ?? (() => Date.now());
2044
2655
  this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
2045
2656
  this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
2657
+ this.keptMaxAgeMs = options.keptMaxAgeMs ?? 12096e5;
2658
+ this.keptMaxEntries = options.keptMaxEntries ?? 2e3;
2659
+ this.keptMaxBytes = options.keptMaxBytes ?? 2147483648;
2660
+ this.keptDir = path.join(options.dir, KEPT_SUBDIR);
2661
+ }
2662
+ /** How long a retained artifact stays fetchable — the index's `expiresAt`
2663
+ * is derived from this, so the two can never disagree. */
2664
+ get retentionMs() {
2665
+ return this.keptMaxAgeMs;
2046
2666
  }
2047
2667
  /** Create the directory and sweep whatever a previous run left behind. */
2048
2668
  async start() {
2049
2669
  await promises.mkdir(this.options.dir, { recursive: true });
2050
2670
  await this.sweep();
2051
2671
  }
2052
- /** Persist bytes and return the artifact handle (never throws on sweep). */
2053
- async put(bytes, mime) {
2672
+ /**
2673
+ * Persist bytes and return the artifact handle (never throws on sweep).
2674
+ *
2675
+ * The retained shelf is created LAZILY: an install that never produces a
2676
+ * browsable artifact keeps a directory that is empty when it is empty.
2677
+ */
2678
+ async put(bytes, mime, options = {}) {
2054
2679
  const id = `${this.now()}-${randomUUID()}`;
2055
- const file = path.join(this.options.dir, `${id}.${extensionFor(mime)}`);
2056
- await promises.mkdir(this.options.dir, { recursive: true });
2680
+ const dir = options.retain === true ? this.keptDir : this.options.dir;
2681
+ const file = path.join(dir, `${id}.${extensionFor(mime)}`);
2682
+ await promises.mkdir(dir, { recursive: true });
2057
2683
  await promises.writeFile(file, bytes);
2058
2684
  this.mimeById.set(id, mime);
2059
2685
  await this.sweep();
@@ -2066,7 +2692,7 @@ var NcArtifactStore = class {
2066
2692
  /** Read an artifact's bytes + mime, or null when it is gone (expired/swept). */
2067
2693
  async read(id) {
2068
2694
  if (!/^[0-9]+-[0-9a-f-]{36}$/i.test(id)) return null;
2069
- const found = (await this.list()).find((e) => e.id === id);
2695
+ const found = [...await this.list(this.options.dir), ...await this.list(this.keptDir)].find((e) => e.id === id);
2070
2696
  if (!found) return null;
2071
2697
  try {
2072
2698
  return {
@@ -2077,6 +2703,10 @@ var NcArtifactStore = class {
2077
2703
  return null;
2078
2704
  }
2079
2705
  }
2706
+ /** The ids still on the retained shelf — what the index reconciles against. */
2707
+ async keptIds() {
2708
+ return (await this.list(this.keptDir)).map((e) => e.id);
2709
+ }
2080
2710
  /** Delete every artifact (operator cleanup / shutdown). */
2081
2711
  async clear() {
2082
2712
  await promises.rm(this.options.dir, {
@@ -2085,13 +2715,39 @@ var NcArtifactStore = class {
2085
2715
  }).catch(() => {});
2086
2716
  this.mimeById.clear();
2087
2717
  }
2088
- /** Age- then count-bounded prune. Best-effort: a failed unlink is logged once. */
2718
+ /** Prune both shelves under their own bounds. Best-effort: a failed unlink is
2719
+ * logged once and the rest of the sweep still runs. */
2089
2720
  async sweep() {
2090
- const entries = await this.list();
2091
- const cutoff = this.now() - this.maxAgeMs;
2721
+ await this.sweepShelf(await this.list(this.options.dir), {
2722
+ maxAgeMs: this.maxAgeMs,
2723
+ maxEntries: this.maxEntries
2724
+ });
2725
+ await this.sweepShelf(await this.list(this.keptDir), {
2726
+ maxAgeMs: this.keptMaxAgeMs,
2727
+ maxEntries: this.keptMaxEntries,
2728
+ maxBytes: this.keptMaxBytes
2729
+ });
2730
+ }
2731
+ /**
2732
+ * Age first, then count, then total bytes — newest always survives.
2733
+ *
2734
+ * The newest entry is never swept by the byte bound even when it alone
2735
+ * exceeds it: a shelf that deletes what it has just written can serve
2736
+ * nothing, and the caller has already paid for the render.
2737
+ */
2738
+ async sweepShelf(entries, bounds) {
2739
+ const cutoff = this.now() - bounds.maxAgeMs;
2092
2740
  const doomed = entries.filter((e) => e.mtimeMs < cutoff);
2093
- const survivors = entries.filter((e) => e.mtimeMs >= cutoff).toSorted((a, b) => b.mtimeMs - a.mtimeMs);
2094
- doomed.push(...survivors.slice(this.maxEntries));
2741
+ const fresh = entries.filter((e) => e.mtimeMs >= cutoff).toSorted((a, b) => b.mtimeMs - a.mtimeMs);
2742
+ doomed.push(...fresh.slice(bounds.maxEntries));
2743
+ const maxBytes = bounds.maxBytes;
2744
+ if (maxBytes !== void 0) {
2745
+ let running = 0;
2746
+ for (const [i, e] of fresh.slice(0, bounds.maxEntries).entries()) {
2747
+ running += e.sizeBytes;
2748
+ if (running > maxBytes && i > 0) doomed.push(e);
2749
+ }
2750
+ }
2095
2751
  for (const e of doomed) try {
2096
2752
  await promises.rm(e.file, { force: true });
2097
2753
  this.mimeById.delete(e.id);
@@ -2102,23 +2758,24 @@ var NcArtifactStore = class {
2102
2758
  } });
2103
2759
  }
2104
2760
  }
2105
- async list() {
2761
+ async list(dir) {
2106
2762
  let names;
2107
2763
  try {
2108
- names = await promises.readdir(this.options.dir);
2764
+ names = await promises.readdir(dir);
2109
2765
  } catch {
2110
2766
  return [];
2111
2767
  }
2112
2768
  const out = [];
2113
2769
  for (const name of names) {
2114
- const file = path.join(this.options.dir, name);
2770
+ const file = path.join(dir, name);
2115
2771
  try {
2116
2772
  const st = await promises.stat(file);
2117
2773
  if (!st.isFile()) continue;
2118
2774
  out.push({
2119
2775
  id: name.slice(0, name.lastIndexOf(".")),
2120
2776
  file,
2121
- mtimeMs: st.mtimeMs
2777
+ mtimeMs: st.mtimeMs,
2778
+ sizeBytes: st.size
2122
2779
  });
2123
2780
  } catch {}
2124
2781
  }
@@ -4336,69 +4993,38 @@ var en_default = {
4336
4993
  "snooze.scope.anyDevice": "A camera",
4337
4994
  "snooze.scope.anyRule": "A rule",
4338
4995
  "snooze.scope.rule": "“{{rule}}”",
4339
- "alarm.arm.body.allDevices": {
4340
- "one": "Every device is armed ({{rules}} rule).",
4341
- "other": "Every device is armed ({{rules}} rules)."
4342
- },
4343
- "alarm.arm.body.noDevices": {
4344
- "one": "{{rules}} rule, no device restriction.",
4345
- "other": "{{rules}} rules, no device restriction."
4346
- },
4347
- "alarm.arm.body.devices": {
4348
- "one": "{{devices}} device armed: {{names}}.",
4349
- "other": "{{devices}} devices armed: {{names}}."
4350
- },
4351
- "alarm.arm.body.devicesSkipped": {
4352
- "one": "{{devices}} device armed: {{names}}. Excluded: {{skippedNames}}.",
4353
- "other": "{{devices}} devices armed: {{names}}. Excluded: {{skippedNames}}."
4354
- },
4355
- "alarm.arm.body.uncovered": "Nothing is armed — no enabled rule is gated on this mode.",
4356
- "alarm.arm.body.allSkipped": {
4357
- "one": "Nothing is armed: the {{devices}} device this mode covers is excluded ({{names}}).",
4358
- "other": "Nothing is armed: all {{devices}} devices this mode covers are excluded ({{names}})."
4359
- },
4360
- "alarm.arming.body": {
4361
- "one": "Armed in {{seconds}} second.",
4362
- "other": "Armed in {{seconds}} seconds."
4363
- },
4364
- "alarm.arming.body.uncovered": {
4365
- "one": "Armed in {{seconds}} second — but no enabled rule is gated on this mode, so it will protect nothing.",
4366
- "other": "Armed in {{seconds}} seconds — but no enabled rule is gated on this mode, so it will protect nothing."
4367
- },
4368
- "alarm.arming.body.allSkipped": {
4369
- "one": "Armed in {{seconds}} second — but every device this mode covers is excluded ({{names}}), so it will protect nothing.",
4370
- "other": "Armed in {{seconds}} seconds — but every device this mode covers is excluded ({{names}}), so it will protect nothing."
4371
- },
4372
- "alarm.arm.body.bypassed": {
4373
- "one": "{{devices}} device armed — open and excluded: {{bypassedNames}}.",
4374
- "other": "{{devices}} devices armed — open and excluded: {{bypassedNames}}."
4375
- },
4376
- "alarm.arming.body.bypassed": {
4377
- "one": "Armed in {{seconds}} second, excluding what is open: {{bypassedNames}}.",
4378
- "other": "Armed in {{seconds}} seconds, excluding what is open: {{bypassedNames}}."
4379
- },
4380
- "alarm.armRefused.body": {
4381
- "one": "{{names}} is open. Press arm again within {{seconds}}s to arm without it.",
4382
- "other": "These are open: {{names}}. Press arm again within {{seconds}}s to arm without them."
4383
- },
4996
+ "alarm.arm.body.allDevices": "Active: all devices",
4997
+ "alarm.arm.body.noDevices": "Active: no device restriction",
4998
+ "alarm.arm.body.devices": "Active: {{names}}",
4999
+ "alarm.arm.body.devicesSkipped": "Active: {{names}}\nExcluded: {{skippedNames}}",
5000
+ "alarm.arm.body.uncovered": "Active: none — no rule uses this mode",
5001
+ "alarm.arm.body.allSkipped": "Active: none\nExcluded: {{names}}",
5002
+ "alarm.arming.body": "No exclusions",
5003
+ "alarm.arming.body.uncovered": "Active: none — no rule uses this mode",
5004
+ "alarm.arming.body.allSkipped": "Active: none\nExcluded: {{names}}",
5005
+ "alarm.arming.body.skipped": "Excluded: {{names}}",
5006
+ "alarm.arm.body.bypassed": "Active: {{devices}}\nBypassed: {{bypassedNames}}",
5007
+ "alarm.arming.body.bypassed": "Bypassed: {{bypassedNames}}",
5008
+ "alarm.armRefused.body": "Open: {{names}}\nRepeat within {{seconds}}s to arm with bypass.",
4384
5009
  "alarm.device.unnamed": "Device {{id}}",
4385
- "system.alarm-arm-refused.title.home": "Home NOT armed",
4386
- "system.alarm-arm-refused.title.away": "Away NOT armed",
4387
- "system.alarm-arm-refused.title.night": "Night NOT armed",
4388
- "system.alarm-arm-refused.body": "The alarm could not be armed — something it covers is open.",
4389
- "system.alarm-arming.title.home": "Arming home",
4390
- "system.alarm-arming.title.away": "Arming away",
4391
- "system.alarm-arming.title.night": "Arming night",
4392
- "system.alarm-arming.body": "The alarm is arming",
4393
- "system.alarm-armed.title.home": "Home armed",
4394
- "system.alarm-armed.title.away": "Away armed",
4395
- "system.alarm-armed.title.night": "Night armed",
4396
- "system.alarm-armed.body": "The alarm was armed",
4397
- "system.alarm-disarmed.title": "Alarm disarmed",
4398
- "system.alarm-disarmed.body": "The alarm was disarmed",
5010
+ "alarm.combined.title": "Alarm: {{deviceName}}",
5011
+ "system.alarm-arm-refused.title.home": "Arming home refused",
5012
+ "system.alarm-arm-refused.title.away": "Arming away refused",
5013
+ "system.alarm-arm-refused.title.night": "Arming night refused",
5014
+ "system.alarm-arm-refused.body": "Arming refused",
5015
+ "system.alarm-arming.title.home": "Arming home — {{seconds}}s",
5016
+ "system.alarm-arming.title.away": "Arming away — {{seconds}}s",
5017
+ "system.alarm-arming.title.night": "Arming night — {{seconds}}s",
5018
+ "system.alarm-arming.body": "Arming",
5019
+ "system.alarm-armed.title.home": "Armed: home",
5020
+ "system.alarm-armed.title.away": "Armed: away",
5021
+ "system.alarm-armed.title.night": "Armed: night",
5022
+ "system.alarm-armed.body": "Alarm armed",
5023
+ "system.alarm-disarmed.title": "Disarmed",
5024
+ "system.alarm-disarmed.body": "Alarm disarmed",
4399
5025
  "system.alarm-triggered.title": "ALARM",
4400
- "system.alarm-triggered.body": "The alarm was triggered",
4401
- "system.alarm-triggered.body.rule": "The alarm was triggered by {{rule}} on {{deviceName}}",
5026
+ "system.alarm-triggered.body": "Alarm triggered",
5027
+ "system.alarm-triggered.body.rule": "Triggered by {{rule}} on {{deviceName}}",
4402
5028
  "button.snooze.minutes": "Snooze {{minutes}} min",
4403
5029
  "button.snooze.hours": {
4404
5030
  "one": "Snooze {{hours}} hour",
@@ -4530,69 +5156,38 @@ var it_default = {
4530
5156
  "snooze.scope.anyDevice": "Una camera",
4531
5157
  "snooze.scope.anyRule": "Una regola",
4532
5158
  "snooze.scope.rule": "“{{rule}}”",
4533
- "alarm.arm.body.allDevices": {
4534
- "one": "Ogni dispositivo è attivo ({{rules}} regola).",
4535
- "other": "Ogni dispositivo è attivo ({{rules}} regole)."
4536
- },
4537
- "alarm.arm.body.noDevices": {
4538
- "one": "{{rules}} regola, nessuna restrizione sui dispositivi.",
4539
- "other": "{{rules}} regole, nessuna restrizione sui dispositivi."
4540
- },
4541
- "alarm.arm.body.devices": {
4542
- "one": "{{devices}} dispositivo attivo: {{names}}.",
4543
- "other": "{{devices}} dispositivi attivi: {{names}}."
4544
- },
4545
- "alarm.arm.body.devicesSkipped": {
4546
- "one": "{{devices}} dispositivo attivo: {{names}}. Esclusi: {{skippedNames}}.",
4547
- "other": "{{devices}} dispositivi attivi: {{names}}. Esclusi: {{skippedNames}}."
4548
- },
4549
- "alarm.arm.body.uncovered": "Non è attivo nulla — nessuna regola abilitata è legata a questa modalità.",
4550
- "alarm.arm.body.allSkipped": {
4551
- "one": "Non è attivo nulla: l'unico dispositivo ({{devices}}) coperto da questa modalità è escluso ({{names}}).",
4552
- "other": "Non è attivo nulla: tutti i {{devices}} dispositivi coperti da questa modalità sono esclusi ({{names}})."
4553
- },
4554
- "alarm.arming.body": {
4555
- "one": "Attivo tra {{seconds}} secondo.",
4556
- "other": "Attivo tra {{seconds}} secondi."
4557
- },
4558
- "alarm.arming.body.uncovered": {
4559
- "one": "Attivo tra {{seconds}} secondo — ma nessuna regola abilitata è legata a questa modalità: non proteggerà nulla.",
4560
- "other": "Attivo tra {{seconds}} secondi — ma nessuna regola abilitata è legata a questa modalità: non proteggerà nulla."
4561
- },
4562
- "alarm.arming.body.allSkipped": {
4563
- "one": "Attivo tra {{seconds}} secondo — ma ogni dispositivo coperto da questa modalità è escluso ({{names}}): non proteggerà nulla.",
4564
- "other": "Attivo tra {{seconds}} secondi — ma ogni dispositivo coperto da questa modalità è escluso ({{names}}): non proteggerà nulla."
4565
- },
4566
- "alarm.arm.body.bypassed": {
4567
- "one": "{{devices}} dispositivo attivo — aperto ed escluso: {{bypassedNames}}.",
4568
- "other": "{{devices}} dispositivi attivi — aperti ed esclusi: {{bypassedNames}}."
4569
- },
4570
- "alarm.arming.body.bypassed": {
4571
- "one": "Attivo tra {{seconds}} secondo, escludendo ciò che è aperto: {{bypassedNames}}.",
4572
- "other": "Attivo tra {{seconds}} secondi, escludendo ciò che è aperto: {{bypassedNames}}."
4573
- },
4574
- "alarm.armRefused.body": {
4575
- "one": "{{names}} è aperto. Premi di nuovo entro {{seconds}}s per inserire senza di esso.",
4576
- "other": "Sono aperti: {{names}}. Premi di nuovo entro {{seconds}}s per inserire senza di essi."
4577
- },
5159
+ "alarm.arm.body.allDevices": "Attivi: tutti i dispositivi",
5160
+ "alarm.arm.body.noDevices": "Attivi: nessuna restrizione",
5161
+ "alarm.arm.body.devices": "Attivi: {{names}}",
5162
+ "alarm.arm.body.devicesSkipped": "Attivi: {{names}}\nEsclusi: {{skippedNames}}",
5163
+ "alarm.arm.body.uncovered": "Attivi: nessuno — nessuna regola usa questa modalità",
5164
+ "alarm.arm.body.allSkipped": "Attivi: nessuno\nEsclusi: {{names}}",
5165
+ "alarm.arming.body": "Nessuna esclusione",
5166
+ "alarm.arming.body.uncovered": "Attivi: nessuno — nessuna regola usa questa modalità",
5167
+ "alarm.arming.body.allSkipped": "Attivi: nessuno\nEsclusi: {{names}}",
5168
+ "alarm.arming.body.skipped": "Esclusi: {{names}}",
5169
+ "alarm.arm.body.bypassed": "Attivi: {{devices}}\nBypassati: {{bypassedNames}}",
5170
+ "alarm.arming.body.bypassed": "Bypassati: {{bypassedNames}}",
5171
+ "alarm.armRefused.body": "Aperti: {{names}}\nRipeti entro {{seconds}}s per inserire con bypass.",
4578
5172
  "alarm.device.unnamed": "Dispositivo {{id}}",
5173
+ "alarm.combined.title": "Allarme: {{deviceName}}",
4579
5174
  "system.alarm-arm-refused.title.home": "Inserimento Casa rifiutato",
4580
5175
  "system.alarm-arm-refused.title.away": "Inserimento Fuori rifiutato",
4581
5176
  "system.alarm-arm-refused.title.night": "Inserimento Notte rifiutato",
4582
- "system.alarm-arm-refused.body": "L'allarme non è stato inserito — qualcosa che copre è aperto.",
4583
- "system.alarm-arming.title.home": "Inserimento Casa",
4584
- "system.alarm-arming.title.away": "Inserimento Fuori",
4585
- "system.alarm-arming.title.night": "Inserimento Notte",
4586
- "system.alarm-arming.body": "L'allarme si sta inserendo",
4587
- "system.alarm-armed.title.home": "Casa attivato",
4588
- "system.alarm-armed.title.away": "Fuori attivato",
4589
- "system.alarm-armed.title.night": "Notte attivato",
4590
- "system.alarm-armed.body": "L'allarme è stato inserito",
4591
- "system.alarm-disarmed.title": "Allarme disattivato",
4592
- "system.alarm-disarmed.body": "L'allarme è stato disattivato",
5177
+ "system.alarm-arm-refused.body": "Inserimento rifiutato",
5178
+ "system.alarm-arming.title.home": "Inserimento Casa — {{seconds}}s",
5179
+ "system.alarm-arming.title.away": "Inserimento Fuori — {{seconds}}s",
5180
+ "system.alarm-arming.title.night": "Inserimento Notte — {{seconds}}s",
5181
+ "system.alarm-arming.body": "Inserimento in corso",
5182
+ "system.alarm-armed.title.home": "Inserito: Casa",
5183
+ "system.alarm-armed.title.away": "Inserito: Fuori",
5184
+ "system.alarm-armed.title.night": "Inserito: Notte",
5185
+ "system.alarm-armed.body": "Allarme inserito",
5186
+ "system.alarm-disarmed.title": "Disinserito",
5187
+ "system.alarm-disarmed.body": "Allarme disinserito",
4593
5188
  "system.alarm-triggered.title": "ALLARME",
4594
- "system.alarm-triggered.body": "L'allarme è scattato",
4595
- "system.alarm-triggered.body.rule": "L'allarme è scattato per {{rule}} su {{deviceName}}",
5189
+ "system.alarm-triggered.body": "Allarme scattato",
5190
+ "system.alarm-triggered.body.rule": "Scattato da {{rule}} su {{deviceName}}",
4596
5191
  "button.snooze.minutes": "Silenzia {{minutes}} min",
4597
5192
  "button.snooze.hours": {
4598
5193
  "one": "Silenzia {{hours}} ora",
@@ -5311,7 +5906,8 @@ var NcDispatcher = class {
5311
5906
  const systemEvent = subject.systemEvent;
5312
5907
  const displayDeviceId = systemEvent?.deviceId ?? subject.deviceId;
5313
5908
  const deviceName = systemEvent !== void 0 && systemEvent.deviceId === void 0 ? "system" : await this.deps.getDeviceName(displayDeviceId).catch(() => null) ?? `camera ${displayDeviceId}`;
5314
- const zoneLabels = systemEvent !== void 0 ? [] : await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
5909
+ const alarmSource = systemEvent?.alarmSource;
5910
+ const zoneLabels = systemEvent === void 0 ? await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones) : alarmSource?.zones !== void 0 && alarmSource.zones.length > 0 ? await resolveZoneLabels(this.deps.getZoneNames, alarmSource.deviceId, alarmSource.zones) : [];
5315
5911
  const vars = buildTemplateVars(this.texts, entry, deviceName, zoneLabels);
5316
5912
  const systemCount = systemEvent?.textCount !== void 0 ? { count: systemEvent.textCount } : {};
5317
5913
  const title = this.renderText({
@@ -5654,6 +6250,10 @@ async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
5654
6250
  return [...zoneIds];
5655
6251
  }
5656
6252
  }
6253
+ /** `{{confidence}}` as a whole percent, or empty when nothing measured one. */
6254
+ function percentOf(confidence) {
6255
+ return confidence !== void 0 ? `${Math.round(confidence * 100)}%` : "";
6256
+ }
5657
6257
  /**
5658
6258
  * The variable map ONE render pass sees: scalars, then the composites built
5659
6259
  * from them, then the params a system-event row froze at intake.
@@ -5669,19 +6269,23 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
5669
6269
  const subject = entry.payload.subject;
5670
6270
  const occupancy = subject.occupancy;
5671
6271
  const systemEvent = subject.systemEvent;
6272
+ const detection = systemEvent?.alarmSource?.className !== void 0 ? systemEvent.alarmSource : void 0;
5672
6273
  return {
5673
6274
  camera: deviceName,
5674
- class: subject.className,
5675
- label: subject.label ?? "",
6275
+ class: detection?.className ?? subject.className,
6276
+ label: detection?.label ?? subject.label ?? "",
5676
6277
  zones: zoneLabels.join(", "),
5677
- subject: systemEvent?.subject ?? ncSubjectText(texts, {
6278
+ subject: detection?.className !== void 0 ? ncSubjectText(texts, {
6279
+ className: detection.className,
6280
+ ...detection.label !== void 0 ? { label: detection.label } : {}
6281
+ }) : systemEvent?.subject ?? ncSubjectText(texts, {
5678
6282
  className: subject.className,
5679
6283
  ...subject.label !== void 0 ? { label: subject.label } : {}
5680
6284
  }),
5681
6285
  inZones: ncInZonesText(texts, zoneLabels),
5682
6286
  scope: occupancy?.zone ?? deviceName,
5683
6287
  zone: occupancy?.zone ?? zoneLabels[0] ?? "",
5684
- confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
6288
+ confidence: percentOf(detection?.confidence ?? subject.confidence),
5685
6289
  time: new Date(subject.timestamp).toLocaleTimeString(),
5686
6290
  rule: entry.payload.ruleName,
5687
6291
  count: occupancy !== void 0 ? `${occupancy.count}` : "",
@@ -6221,6 +6825,35 @@ function incomingFromSystemEvent(event, lookup) {
6221
6825
  }
6222
6826
  }
6223
6827
  /**
6828
+ * The COMBINED row's two keys, or null when this transition is not one.
6829
+ *
6830
+ * A rule that trips the alarm produces ONE notification, and the operator has
6831
+ * to be able to read it: the title is the severity marker over the camera
6832
+ * (`alarm.combined.title`, shipped as `Alarm: {{deviceName}}` and re-wordable
6833
+ * per rule or in the Texts tab), and the body is `detection.body` — the exact
6834
+ * sentence that rule's ordinary notification would have carried, with its own
6835
+ * class, identity and zones.
6836
+ *
6837
+ * The alarm used to write its own prose here ("The alarm was triggered by
6838
+ * Person at gate on Ingresso"), which made the one notification that matters
6839
+ * the only one in the system that did not look like the rule's others: no
6840
+ * identity, no zone, a different subject. The prefix carries the severity; the
6841
+ * familiar line carries the meaning.
6842
+ *
6843
+ * **Gated on the detection vocabulary, not on the source.** `detection.body`
6844
+ * renders `{{subject}}`, and a fire ref with no class composes it from the
6845
+ * system event's dedup slot — "alarm:42 su Ingresso". A source without a class
6846
+ * therefore keeps the alarm's own sentence, which is also what every row
6847
+ * enqueued before this deploy carries.
6848
+ */
6849
+ function combinedAlarmText(source) {
6850
+ if (source?.className === void 0) return null;
6851
+ return {
6852
+ titleKey: "alarm.combined.title",
6853
+ bodyKey: "detection.body"
6854
+ };
6855
+ }
6856
+ /**
6224
6857
  * The panel's own transition, as a system-event subject.
6225
6858
  *
6226
6859
  * IN-PROCESS, not off the bus. The alarm and the Notification Center live in
@@ -6243,6 +6876,7 @@ function incomingFromAlarmTransition(input) {
6243
6876
  const deviceId = source?.deviceId ?? panelDeviceId;
6244
6877
  const identity = input.lookup(deviceId);
6245
6878
  const kind = transition.kind;
6879
+ const combined = combinedAlarmText(source);
6246
6880
  return systemSubject({
6247
6881
  facts: {
6248
6882
  eventId: `alarm:${kind}:${String(transition.at)}`,
@@ -6258,12 +6892,13 @@ function incomingFromAlarmTransition(input) {
6258
6892
  },
6259
6893
  kind,
6260
6894
  subject: `alarm:${String(panelDeviceId)}`,
6261
- titleKey: transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`,
6262
- bodyKey: input.body?.bodyKey ?? (source !== void 0 ? `system.${kind}.body.rule` : `system.${kind}.body`),
6895
+ titleKey: combined !== null ? combined.titleKey : transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`,
6896
+ bodyKey: combined?.bodyKey ?? input.body?.bodyKey ?? (source !== void 0 ? `system.${kind}.body.rule` : `system.${kind}.body`),
6263
6897
  textParams: {
6264
6898
  ...deviceTextParams(deviceId, identity),
6265
6899
  ...transition.mode !== void 0 ? { mode: transition.mode } : {},
6266
6900
  ...source !== void 0 ? { rule: source.ruleName } : {},
6901
+ ...kind === "alarm-arming" ? { seconds: `${transition.seconds ?? 0}` } : {},
6267
6902
  ...input.body?.textParams
6268
6903
  },
6269
6904
  deviceId,
@@ -10004,7 +10639,15 @@ var NcSummaryProducer = class {
10004
10639
  }
10005
10640
  const bytes = new Uint8Array(rendered.jpeg.byteLength);
10006
10641
  bytes.set(rendered.jpeg);
10007
- const url = await this.deps.publish(bytes, "image/jpeg");
10642
+ const { url } = await this.deps.publish(bytes, "image/jpeg", {
10643
+ kind: "summary-mosaic",
10644
+ deviceId: rule.deviceIds[0] ?? null,
10645
+ deviceIds: [...new Set(tiles.map((t) => t.candidate.deviceId))],
10646
+ capturedAt: window.endMs,
10647
+ ruleId: rule.id,
10648
+ ruleName: rule.name,
10649
+ ...rule.ownerUserId !== void 0 ? { ownerUserId: rule.ownerUserId } : {}
10650
+ });
10008
10651
  if (url === null) {
10009
10652
  this.deps.logger.warn("summary mosaic not published: no reachable public endpoint", { meta: { ruleId: rule.id } });
10010
10653
  return {
@@ -10998,7 +11641,7 @@ function unknownNcTextKeys(keys, bundles = NC_TEXT_BUNDLES) {
10998
11641
  * durable document stays the sparse map its schema describes.
10999
11642
  */
11000
11643
  function applyNcTextOverridePatch(current, language, patch) {
11001
- const next = { ...current[language] ?? {} };
11644
+ const next = { ...current[language] };
11002
11645
  for (const [key, value] of Object.entries(patch)) {
11003
11646
  if (value === null || value.trim().length === 0) {
11004
11647
  delete next[key];
@@ -11566,7 +12209,7 @@ var TimelapseRenderer = class {
11566
12209
  }
11567
12210
  });
11568
12211
  const finished = await this.awaitReady(queued, tags);
11569
- const published = await this.publish(finished.id, deviceId, tags, request.previewText, request.previewMode ?? "image");
12212
+ const published = await this.publish(finished.id, request, tags);
11570
12213
  const downloadUrl = await this.ports.resolveDownloadUrl?.({ exportId: finished.id }).catch(() => null);
11571
12214
  return {
11572
12215
  exportId: finished.id,
@@ -11618,7 +12261,9 @@ var TimelapseRenderer = class {
11618
12261
  * notification, not a failed window — and failing here would leave the pair
11619
12262
  * unstamped and re-render the same eight hours on the next tick.
11620
12263
  */
11621
- async publish(exportId, deviceId, tags, previewText, previewMode) {
12264
+ async publish(exportId, request, tags) {
12265
+ const { deviceId, previewText } = request;
12266
+ const previewMode = request.previewMode ?? "image";
11622
12267
  const publish = this.ports.publishArtifact;
11623
12268
  const none = {
11624
12269
  video: null,
@@ -11638,7 +12283,11 @@ var TimelapseRenderer = class {
11638
12283
  ...previewText !== void 0 ? {
11639
12284
  previewText,
11640
12285
  previewMode
11641
- } : {}
12286
+ } : {},
12287
+ capturedAt: request.window.endMs,
12288
+ ruleId: request.ruleId,
12289
+ ruleName: request.ruleName,
12290
+ ...request.ownerUserId !== void 0 ? { ownerUserId: request.ownerUserId } : {}
11642
12291
  });
11643
12292
  if (set.video === null) this.ports.logger.warn("timelapse artifact could not be published — link-less delivery", {
11644
12293
  tags,
@@ -11994,7 +12643,8 @@ var TimelapseScheduler = class {
11994
12643
  ...rule.previewText !== void 0 ? { text: rule.previewText } : {}
11995
12644
  }),
11996
12645
  previewMode: rule.previewMode,
11997
- ...rule.reportClasses !== void 0 ? { reportClasses: rule.reportClasses } : {}
12646
+ ...rule.reportClasses !== void 0 ? { reportClasses: rule.reportClasses } : {},
12647
+ ...rule.ownerUserId !== void 0 ? { ownerUserId: rule.ownerUserId } : {}
11998
12648
  });
11999
12649
  const rows = buildTimelapseOutboxInputs({
12000
12650
  texts: this.texts,
@@ -12570,6 +13220,7 @@ function frozenOccupancy(rule, occ) {
12570
13220
  */
12571
13221
  function fireRefFor(rule, subject, kind) {
12572
13222
  const hasEventMedia = kind === "object-event" || kind === "package-event";
13223
+ const className = subject.classNames[0];
12573
13224
  return {
12574
13225
  ruleId: rule.id,
12575
13226
  ruleName: rule.name,
@@ -12578,7 +13229,11 @@ function fireRefFor(rule, subject, kind) {
12578
13229
  recordId: subject.recordId,
12579
13230
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
12580
13231
  ...hasEventMedia ? { eventId: subject.recordId } : {},
12581
- timestamp: subject.timestamp
13232
+ timestamp: subject.timestamp,
13233
+ ...className !== void 0 ? { className } : {},
13234
+ ...subject.label !== void 0 ? { label: subject.label } : {},
13235
+ zones: notificationZones(rule, subject),
13236
+ ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {}
12582
13237
  };
12583
13238
  }
12584
13239
  function notificationZones(rule, subject) {
@@ -13305,6 +13960,7 @@ var NotificationCenter = class NotificationCenter {
13305
13960
  await TimelapseStore.declare(store);
13306
13961
  await NcSummaryStore.declare(store);
13307
13962
  await NcTextCatalogStore.declare(store);
13963
+ await NcArtifactIndex.declare(store);
13308
13964
  }
13309
13965
  /**
13310
13966
  * Load rules (every node — the cap provider serves CRUD from any node).
@@ -15362,7 +16018,23 @@ var ncActions = defineCustomActions({
15362
16018
  }), NcTextCatalogViewSchema, {
15363
16019
  kind: "mutation",
15364
16020
  auth: "admin"
15365
- })
16021
+ }),
16022
+ /**
16023
+ * One page of artefacts, newest `capturedAt` first.
16024
+ *
16025
+ * `deviceIds` filters on the camera an artefact is FILED under. A digest
16026
+ * spans cameras and is filed under its rule's first one (the same camera its
16027
+ * outbox row uses); to ask "everything this digest produced" use `ruleId`.
16028
+ */
16029
+ "nc.listArtifacts": customAction(NcArtifactQuerySchema, object({
16030
+ artifacts: array(NcArtifactRecordSchema),
16031
+ /** Rows matching the filter, not rows on this page. */
16032
+ total: number().int().nonnegative(),
16033
+ hasMore: boolean()
16034
+ }), { caller: "required" }),
16035
+ /** One artefact's metadata. `null` for unknown, expired AND not-yours alike —
16036
+ * a surface that distinguishes them reports what other people own. */
16037
+ "nc.getArtifact": customAction(object({ artifactId: string().min(1) }), object({ artifact: NcArtifactRecordSchema.nullable() }), { caller: "required" })
15366
16038
  });
15367
16039
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
15368
16040
  function requireCaller(caller) {
@@ -15412,6 +16084,12 @@ function makeNcActionHandlers(deps) {
15412
16084
  if (sum === void 0) throw new Error("this node does not host the summary producer — use the hub");
15413
16085
  return sum;
15414
16086
  };
16087
+ /** The artefact index, or an honest refusal. */
16088
+ const requireArtifacts = () => {
16089
+ const index = deps.artifacts;
16090
+ if (index === void 0) throw new Error("this node does not host the notification artifact index — use the hub");
16091
+ return index;
16092
+ };
15415
16093
  /** The text catalog editor, or an honest refusal. */
15416
16094
  const requireTexts = () => {
15417
16095
  const editor = deps.texts;
@@ -15723,7 +16401,26 @@ function makeNcActionHandlers(deps) {
15723
16401
  return input.language === void 0 ? texts.view() : texts.view(input.language);
15724
16402
  },
15725
16403
  "nc.setLanguage": async (input) => requireTexts().setLanguage(input.language),
15726
- "nc.setTextOverrides": async (input) => requireTexts().setOverrides(input.language, input.overrides)
16404
+ "nc.setTextOverrides": async (input) => requireTexts().setOverrides(input.language, input.overrides),
16405
+ "nc.listArtifacts": async (input, caller) => {
16406
+ const c = requireCaller(caller);
16407
+ const page = await requireArtifacts().list(input, {
16408
+ userId: c.userId,
16409
+ isAdmin: c.isAdmin
16410
+ });
16411
+ return {
16412
+ artifacts: [...page.artifacts],
16413
+ total: page.total,
16414
+ hasMore: page.hasMore
16415
+ };
16416
+ },
16417
+ "nc.getArtifact": async (input, caller) => {
16418
+ const c = requireCaller(caller);
16419
+ return { artifact: await requireArtifacts().get(input.artifactId, {
16420
+ userId: c.userId,
16421
+ isAdmin: c.isAdmin
16422
+ }) };
16423
+ }
15727
16424
  };
15728
16425
  }
15729
16426
  //#endregion
@@ -15777,7 +16474,8 @@ var PREVIEW_FALLBACK_NAME = "timelapse-preview.jpg";
15777
16474
  * member has a logged reason. It never rejects.
15778
16475
  */
15779
16476
  function buildTimelapseArtifactPublisher(ports) {
15780
- return async ({ exportId, deviceId, previewText, previewMode }) => {
16477
+ return async (request) => {
16478
+ const { exportId, deviceId, previewText, previewMode } = request;
15781
16479
  const tags = { deviceId };
15782
16480
  const none = {
15783
16481
  video: null,
@@ -15808,15 +16506,32 @@ function buildTimelapseArtifactPublisher(ports) {
15808
16506
  return none;
15809
16507
  }
15810
16508
  const mp4 = Buffer$1.from(read.base64, "base64");
16509
+ const capturedAt = request.capturedAt ?? Date.now();
16510
+ const common = {
16511
+ deviceId,
16512
+ deviceIds: [deviceId],
16513
+ capturedAt,
16514
+ ...request.ruleId !== void 0 ? { ruleId: request.ruleId } : {},
16515
+ ...request.ruleName !== void 0 ? { ruleName: request.ruleName } : {},
16516
+ ...request.ownerUserId !== void 0 ? { ownerUserId: request.ownerUserId } : {}
16517
+ };
15811
16518
  const preview = await publishPreview(ports, {
15812
16519
  mp4,
15813
16520
  deviceId,
15814
16521
  tags,
15815
16522
  previewText,
15816
- previewMode: previewMode ?? "image"
16523
+ previewMode: previewMode ?? "image",
16524
+ meta: {
16525
+ ...common,
16526
+ kind: "timelapse-preview"
16527
+ }
15817
16528
  });
15818
16529
  try {
15819
- const url = await ports.publish(mp4, read.contentType);
16530
+ const { url } = await ports.publish(mp4, read.contentType, {
16531
+ ...common,
16532
+ kind: "timelapse",
16533
+ ...preview.artifactId !== null ? { posterArtifactId: preview.artifactId } : {}
16534
+ });
15820
16535
  if (url === null) {
15821
16536
  ports.logger.warn("timelapse artifact not published: no reachable public endpoint", {
15822
16537
  tags,
@@ -15824,7 +16539,7 @@ function buildTimelapseArtifactPublisher(ports) {
15824
16539
  });
15825
16540
  return {
15826
16541
  video: null,
15827
- preview
16542
+ preview: preview.ref
15828
16543
  };
15829
16544
  }
15830
16545
  ports.logger.info("timelapse artifact published", {
@@ -15833,7 +16548,7 @@ function buildTimelapseArtifactPublisher(ports) {
15833
16548
  exportId,
15834
16549
  bytes: read.bytes,
15835
16550
  name: read.name,
15836
- preview: preview !== null
16551
+ preview: preview.ref !== null
15837
16552
  }
15838
16553
  });
15839
16554
  return {
@@ -15842,7 +16557,7 @@ function buildTimelapseArtifactPublisher(ports) {
15842
16557
  mime: read.contentType,
15843
16558
  name: read.name
15844
16559
  },
15845
- preview
16560
+ preview: preview.ref
15846
16561
  };
15847
16562
  } catch (err) {
15848
16563
  ports.logger.warn("timelapse artifact not published: artifact plane refused the bytes", {
@@ -15854,18 +16569,22 @@ function buildTimelapseArtifactPublisher(ports) {
15854
16569
  });
15855
16570
  return {
15856
16571
  video: null,
15857
- preview
16572
+ preview: preview.ref
15858
16573
  };
15859
16574
  }
15860
16575
  };
15861
16576
  }
16577
+ var NO_PREVIEW = {
16578
+ ref: null,
16579
+ artifactId: null
16580
+ };
15862
16581
  /**
15863
16582
  * The preview half. Best-effort at every step and never throws: the picture is
15864
16583
  * an enhancement, the video is the notification.
15865
16584
  */
15866
16585
  async function publishPreview(ports, input) {
15867
16586
  const render = ports.renderPreview;
15868
- if (render === void 0 || input.previewText === void 0) return null;
16587
+ if (render === void 0 || input.previewText === void 0) return NO_PREVIEW;
15869
16588
  try {
15870
16589
  const rendered = await render({
15871
16590
  mp4: input.mp4,
@@ -15873,23 +16592,29 @@ async function publishPreview(ports, input) {
15873
16592
  deviceId: input.deviceId,
15874
16593
  mode: input.previewMode
15875
16594
  });
15876
- if (rendered === null) return null;
15877
- const url = await ports.publish(rendered.bytes, rendered.mime);
16595
+ if (rendered === null) return NO_PREVIEW;
16596
+ const { url, artifactId } = await ports.publish(rendered.bytes, rendered.mime, input.meta);
15878
16597
  if (url === null) {
15879
16598
  ports.logger.warn("timelapse preview not published: no reachable public endpoint", { tags: input.tags });
15880
- return null;
16599
+ return {
16600
+ ref: null,
16601
+ artifactId
16602
+ };
15881
16603
  }
15882
16604
  return {
15883
- url,
15884
- mime: rendered.mime,
15885
- name: PREVIEW_NAME_BY_MIME[rendered.mime] ?? PREVIEW_FALLBACK_NAME
16605
+ ref: {
16606
+ url,
16607
+ mime: rendered.mime,
16608
+ name: PREVIEW_NAME_BY_MIME[rendered.mime] ?? PREVIEW_FALLBACK_NAME
16609
+ },
16610
+ artifactId
15886
16611
  };
15887
16612
  } catch (err) {
15888
16613
  ports.logger.warn("timelapse preview not published: publish threw", {
15889
16614
  tags: input.tags,
15890
16615
  meta: { error: String(err) }
15891
16616
  });
15892
- return null;
16617
+ return NO_PREVIEW;
15893
16618
  }
15894
16619
  }
15895
16620
  /** Title size as a fraction of the frame WIDTH — so it reads the same on a
@@ -16376,6 +17101,10 @@ function startAlarmPanelConvergence(ports) {
16376
17101
  * `data-plane-access.spec.ts` asserts they still match the constants their
16377
17102
  * handlers register with — a mirror nothing checks is a lie waiting to happen.
16378
17103
  */
17104
+ /** The artefact read route's prefix, imported rather than mirrored: it is
17105
+ * ALSO the prefix `artifactDownloadPath` writes into every index row, and two
17106
+ * copies of that string is a 404 nobody would find. */
17107
+ var NC_ARTIFACT_READ_PREFIX_REF = NC_ARTIFACT_READ_PREFIX;
16379
17108
  var TRAINING_EXPORT_PREFIX_REF = "training-export";
16380
17109
  var RETRAIN_EXPORT_PREFIX_REF = "retrain-export";
16381
17110
  var DEBUG_EXPORT_PREFIX_REF = "debug-export";
@@ -16391,6 +17120,14 @@ var PIPELINE_ANALYTICS_PLANE_ACCESS = {
16391
17120
  "nc-action": "public",
16392
17121
  /** Self-authenticating single-use artifact token (D47). */
16393
17122
  "nc-artifact": "public",
17123
+ /**
17124
+ * The VIEWER's read of the same bytes (task #36). One indexed artefact by an
17125
+ * unguessable id the caller was handed by `nc.listArtifacts` — the rule above,
17126
+ * applied unchanged. It is deliberately NOT the `public` signed plane: the
17127
+ * viewer holds a session, and pre-minting a 24 h signed link for every
17128
+ * artefact a user might scroll to is a link-leak surface for no gain.
17129
+ */
17130
+ [NC_ARTIFACT_READ_PREFIX_REF]: "authenticated",
16394
17131
  /** Every frame behind a marked selection — a corpus, not an item. */
16395
17132
  [TRAINING_EXPORT_PREFIX_REF]: "admin",
16396
17133
  [RETRAIN_EXPORT_PREFIX_REF]: "admin",
@@ -36056,6 +36793,138 @@ var BindingCache = class {
36056
36793
  }
36057
36794
  };
36058
36795
  //#endregion
36796
+ //#region src/pipeline-analytics/runtime/detection-coverage-cache.ts
36797
+ /**
36798
+ * DetectionCoverageCache — fleet-seeded, event-invalidated mirror of WHICH
36799
+ * devices carry a WRAPPED `detection-pipeline` binding. This is the alarm's
36800
+ * coverage authority ("Away armed, 12 cameras" is false if three of them are
36801
+ * offline or no longer wrapped), read by the notification centre's device
36802
+ * directory (`listDeviceIdentities`).
36803
+ *
36804
+ * WHY a mirror, not a per-tick sweep. The directory refreshes on the NC's 30 s
36805
+ * rule-reload tick. Answering "who still has detection?" with a fleet-wide
36806
+ * `getAllBindings` on EVERY tick cost 903 ms / 373 KB / ~505 `getBindings:
36807
+ * default wrappers skipped` log lines on the live hub (524 devices), forever —
36808
+ * ~3% of hub-main's main thread plus a permanent log-spam baseline. Same defect
36809
+ * and fix as `SystemMirror.refreshBinding` (639073e8): ONE fleet read to warm,
36810
+ * then per-device updates driven by `DeviceBindingsChanged` events. A wrapper
36811
+ * toggle emits that event carrying the device's id and `capName`
36812
+ * (`detection-pipeline`), so a single `getBindings(deviceId)` — 21 ms / 613 B —
36813
+ * keeps the mirror current without ever touching the fleet again.
36814
+ *
36815
+ * ── Destructive-direction discipline (D49) ──────────────────────────────────
36816
+ *
36817
+ * `detectionActive: false` reports a camera as UNCOVERED / switched-off (D62) —
36818
+ * the destructive answer, and the one an operator reads as "my alarm is not
36819
+ * watching that door". A transient `getBindings` blip must never flip a
36820
+ * known-covered camera to uncovered:
36821
+ * • a failed refresh KEEPS the previous value (a read that throws changes
36822
+ * nothing), and
36823
+ * • a single read that would flip a known `true → false` is confirmed by a
36824
+ * concordant SECOND read before it lands — an empty binding list is not
36825
+ * proof the wrapper is gone.
36826
+ * A COLD mirror (seed not yet landed, or a seed that failed) answers `undefined`
36827
+ * for every id — and unknown never reports a camera as excluded from coverage.
36828
+ */
36829
+ var DetectionCoverageCache = class {
36830
+ api;
36831
+ logger;
36832
+ /** deviceId → "has a WRAPPED detection-pipeline binding". Absent = unknown. */
36833
+ state = /* @__PURE__ */ new Map();
36834
+ warm = false;
36835
+ seeding = null;
36836
+ constructor(deps) {
36837
+ this.api = deps.api;
36838
+ this.logger = deps.logger;
36839
+ }
36840
+ /** Has the ONE fleet seed ever landed? A cold mirror knows nothing. */
36841
+ isWarm() {
36842
+ return this.warm;
36843
+ }
36844
+ /** Do we have an answer for this id at all? (`false` ⇒ absent ⇒ unknown.) */
36845
+ has(deviceId) {
36846
+ return this.state.has(deviceId);
36847
+ }
36848
+ /** The coverage answer, or `undefined` when this id is unknown. No I/O. */
36849
+ get(deviceId) {
36850
+ return this.state.get(deviceId);
36851
+ }
36852
+ /**
36853
+ * Seed the whole fleet ONCE with a single `getAllBindings`. Idempotent: a
36854
+ * warm cache is a no-op, and concurrent callers coalesce onto one read. A
36855
+ * failed seed leaves the mirror COLD so the next tick retries — during that
36856
+ * window every id answers unknown, which never reports a camera as excluded.
36857
+ */
36858
+ async ensureWarm() {
36859
+ if (this.warm) return;
36860
+ if (this.seeding) return this.seeding;
36861
+ this.seeding = this.seed();
36862
+ try {
36863
+ await this.seeding;
36864
+ } finally {
36865
+ this.seeding = null;
36866
+ }
36867
+ }
36868
+ async seed() {
36869
+ try {
36870
+ const rows = await this.api.deviceManager.getAllBindings.query({});
36871
+ for (const row of rows) {
36872
+ if (this.state.has(row.deviceId)) continue;
36873
+ this.state.set(row.deviceId, hasWrappedDetection(row.entries));
36874
+ }
36875
+ this.warm = true;
36876
+ this.logger.info("detection-coverage mirror warm", { meta: { devices: this.state.size } });
36877
+ } catch (err) {
36878
+ this.logger.warn("detection-coverage seed failed — coverage unknown until it lands", { meta: { error: String(err) } });
36879
+ }
36880
+ }
36881
+ /**
36882
+ * A `DeviceBindingsChanged` for the detection cap re-reads THAT ONE device
36883
+ * (never the fleet). Fire-and-forget from the synchronous event handler.
36884
+ */
36885
+ onBindingsChanged(event) {
36886
+ if (event.capName !== "detection-pipeline") return Promise.resolve();
36887
+ return this.refreshDevice(event.deviceId);
36888
+ }
36889
+ /** Drop a device the fleet no longer has (`device.unregistered`). */
36890
+ forget(deviceId) {
36891
+ this.state.delete(deviceId);
36892
+ }
36893
+ /** Return to cold — used on addon shutdown. */
36894
+ clearAll() {
36895
+ this.state.clear();
36896
+ this.warm = false;
36897
+ this.seeding = null;
36898
+ }
36899
+ async refreshDevice(deviceId) {
36900
+ try {
36901
+ const active = await this.readWrappedDetection(deviceId);
36902
+ const prev = this.state.get(deviceId);
36903
+ if (!active && prev === true) {
36904
+ if (await this.readWrappedDetection(deviceId)) {
36905
+ this.state.set(deviceId, true);
36906
+ return;
36907
+ }
36908
+ }
36909
+ this.state.set(deviceId, active);
36910
+ } catch (err) {
36911
+ this.logger.debug("detection-coverage refresh failed — keeping the previous value", {
36912
+ tags: { deviceId },
36913
+ meta: { error: String(err) }
36914
+ });
36915
+ }
36916
+ }
36917
+ async readWrappedDetection(deviceId) {
36918
+ return hasWrappedDetection((await this.api.deviceManager.getBindings.query({ deviceId })).entries);
36919
+ }
36920
+ };
36921
+ /** The one authority: a WRAPPED `detection-pipeline` binding is present. A
36922
+ * native binding of the same cap is NOT coverage (D62: absence of the WRAPPED
36923
+ * binding is how a deactivated wrapper is represented). */
36924
+ function hasWrappedDetection(entries) {
36925
+ return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
36926
+ }
36927
+ //#endregion
36059
36928
  //#region src/pipeline-analytics/runtime/capture-scheduler.ts
36060
36929
  /**
36061
36930
  * Kind-derived default priorities (0 = highest). Explicit `priority` wins.
@@ -41201,6 +42070,15 @@ var REBUILD_FALLBACK_NODE_ID = "hub";
41201
42070
  */
41202
42071
  var TIMELAPSE_TRACK_QUERY_LIMIT = 2e3;
41203
42072
  var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
42073
+ /** Artefact index ⇄ shelf reconcile cadence. Hourly: the two only drift on the
42074
+ * bounds' own timescale (14 days / 2000 entries / 2 GiB). */
42075
+ var NC_ARTIFACT_PRUNE_INTERVAL_MS = 36e5;
42076
+ /** What a publish answers on a node whose artifact plane never came up: no
42077
+ * link, no index row — and, exactly as before, bytes-only delivery. */
42078
+ var NO_PUBLICATION = {
42079
+ url: null,
42080
+ artifactId: null
42081
+ };
41204
42082
  /**
41205
42083
  * The FULL action catalog for this addon.
41206
42084
  *
@@ -41438,6 +42316,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
41438
42316
  * path). Null on non-post-processing nodes or when disabled. */
41439
42317
  embeddingDispatcher = null;
41440
42318
  bindingCache = null;
42319
+ /** Alarm-coverage mirror the NC device directory reads — WHICH devices carry
42320
+ * a wrapped `detection-pipeline` binding. Fleet-seeded ONCE, then per-device
42321
+ * updates off `DeviceBindingsChanged`, so the 30 s rule-reload tick stops
42322
+ * sweeping `getAllBindings` over the whole fleet. See
42323
+ * `runtime/detection-coverage-cache.ts`. */
42324
+ detectionCoverage = null;
41441
42325
  /** Per-device throttle for the inactive-binding frame-drop warn (5 min). */
41442
42326
  inactiveBindingDropWarnAt = /* @__PURE__ */ new Map();
41443
42327
  zoneAnalytics = null;
@@ -41476,6 +42360,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
41476
42360
  * data-plane handle. Null until served / when the facility is absent. */
41477
42361
  ncArtifactPlane = null;
41478
42362
  ncArtifactDataPlane = null;
42363
+ /** The viewer's artefact library (task #36): the index, its authenticated
42364
+ * bytes route, and the store the two share. Null until served. */
42365
+ ncArtifactIndex = null;
42366
+ ncArtifactStore = null;
42367
+ ncArtifactReadDataPlane = null;
42368
+ ncArtifactPruneTimer = null;
41479
42369
  /**
41480
42370
  * Roadmap 4b: ONE cut of an event, in every container the rule asked for.
41481
42371
  *
@@ -41662,6 +42552,31 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
41662
42552
  * Best-effort at every step: no facility, no secret or no reachable base URL
41663
42553
  * ⇒ no plane, and the dispatcher keeps shipping bytes exactly as before.
41664
42554
  */
42555
+ /**
42556
+ * Reconcile the artefact index against the shelf that holds the bytes.
42557
+ *
42558
+ * Hourly, because the two drift only on the bounds' timescale: a row expires
42559
+ * on the shelf's own age bound (14 d) or its file is evicted by the count/byte
42560
+ * bound. Without this, a list eventually serves rows whose bytes 404 — and a
42561
+ * broken tile is the one failure an operator DOES report.
42562
+ */
42563
+ startNcArtifactPrune() {
42564
+ if (this.ncArtifactPruneTimer !== null) return;
42565
+ const run = async () => {
42566
+ const index = this.ncArtifactIndex;
42567
+ const store = this.ncArtifactStore;
42568
+ if (index === null || store === null) return;
42569
+ try {
42570
+ await store.sweep();
42571
+ await index.prune(this.isPostProcessingNode ? await store.keptIds() : null);
42572
+ } catch (err) {
42573
+ this.ctx.logger.warn("nc artifact prune failed", { meta: { error: errMsg(err) } });
42574
+ }
42575
+ };
42576
+ run();
42577
+ this.ncArtifactPruneTimer = setInterval(() => void run(), NC_ARTIFACT_PRUNE_INTERVAL_MS);
42578
+ this.ncArtifactPruneTimer.unref?.();
42579
+ }
41665
42580
  async serveNcArtifactPlane() {
41666
42581
  try {
41667
42582
  const secretState = this.state("ncArtifactSecret", NcArtifactSecretSchema, "");
@@ -41675,9 +42590,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
41675
42590
  logger: this.ctx.logger.child("nc-artifacts")
41676
42591
  });
41677
42592
  await store.start();
42593
+ this.ncArtifactStore = store;
42594
+ const index = new NcArtifactIndex({
42595
+ store: this.ctx.api.settingsStore,
42596
+ logger: this.ctx.logger.child("nc-artifacts"),
42597
+ addonId: this.ctx.id,
42598
+ retentionMs: store.retentionMs
42599
+ });
42600
+ this.ncArtifactIndex = index;
41678
42601
  const plane = new NcArtifactPlane({
41679
42602
  store,
41680
42603
  secret,
42604
+ index,
41681
42605
  logger: this.ctx.logger.child("nc-artifacts"),
41682
42606
  routePrefix: `/addon/${this.ctx.id}/nc-artifact`,
41683
42607
  listEndpoints: () => this.ncEndpoints()
@@ -41688,7 +42612,23 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
41688
42612
  handler: plane.handler
41689
42613
  }) ?? null;
41690
42614
  this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
41691
- this.ctx.logger.info("nc-artifact data-plane served", { meta: { served: this.ncArtifactPlane !== null } });
42615
+ const readPlane = new NcArtifactReadPlane({
42616
+ index,
42617
+ store,
42618
+ logger: this.ctx.logger.child("nc-artifacts")
42619
+ });
42620
+ this.ncArtifactReadDataPlane = await this.ctx.dataPlane?.serve({
42621
+ prefix: NC_ARTIFACT_READ_PREFIX_REF,
42622
+ access: planeAccess(NC_ARTIFACT_READ_PREFIX_REF),
42623
+ handler: readPlane.handler
42624
+ }) ?? null;
42625
+ this.startNcArtifactPrune();
42626
+ this.ctx.logger.info("nc-artifact data-plane served", { meta: {
42627
+ served: this.ncArtifactPlane !== null,
42628
+ libraryServed: this.ncArtifactReadDataPlane !== null,
42629
+ libraryAccess: planeAccess(NC_ARTIFACT_READ_PREFIX_REF),
42630
+ retentionDays: Math.round(store.retentionMs / 864e5)
42631
+ } });
41692
42632
  } catch (err) {
41693
42633
  this.ctx.logger.warn("nc-artifact data-plane failed to serve", { meta: { error: errMsg(err) } });
41694
42634
  }
@@ -42136,7 +43076,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
42136
43076
  store: center.summaryStore,
42137
43077
  runNow: (input) => center.runSummaryNow(input)
42138
43078
  },
42139
- texts: center.textCatalogEditor
43079
+ texts: center.textCatalogEditor,
43080
+ ...this.ncArtifactIndex !== null ? { artifacts: this.ncArtifactIndex } : {}
42140
43081
  });
42141
43082
  }
42142
43083
  /** Declare typed collections up-front so the first insert doesn't race with
@@ -42621,6 +43562,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
42621
43562
  api,
42622
43563
  logger: logger.child("BindingCache")
42623
43564
  });
43565
+ this.detectionCoverage = new DetectionCoverageCache({
43566
+ api,
43567
+ logger: logger.child("DetectionCoverage")
43568
+ });
42624
43569
  const zoneAnalytics = new ZoneAnalyticsProvider({
42625
43570
  logger: logger.child("ZoneAnalytics"),
42626
43571
  onOccupancySample: (deviceId, count, at, zoneId) => this.lts?.note("occupancy", deviceId, count, at, zoneId ?? ""),
@@ -42702,17 +43647,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
42702
43647
  },
42703
43648
  listDeviceIdentities: async () => {
42704
43649
  const all = await api.deviceManager.listAll.query({ projection: "slim" });
42705
- const detection = await api.deviceManager.getAllBindings.query({}).then((rows) => new Map(rows.map((row) => [row.deviceId, row.entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped")]))).catch((err) => {
42706
- this.ctx.logger.debug("bindings read failed — alarm coverage cannot report skips", { meta: { error: String(err) } });
42707
- return /* @__PURE__ */ new Map();
42708
- });
43650
+ const coverage = this.detectionCoverage;
43651
+ if (coverage) await coverage.ensureWarm();
42709
43652
  return all.map((d) => ({
42710
43653
  deviceId: d.id,
42711
43654
  type: d.type,
42712
43655
  name: d.name,
42713
43656
  isCamera: d.isCamera,
42714
43657
  online: d.online && !d.disabled,
42715
- ...detection.has(d.id) ? { detectionActive: detection.get(d.id) === true } : {}
43658
+ ...coverage?.has(d.id) ? { detectionActive: coverage.get(d.id) === true } : {}
42716
43659
  }));
42717
43660
  },
42718
43661
  readDeviceZoneIds: async (ids) => {
@@ -42923,7 +43866,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
42923
43866
  timestamp,
42924
43867
  data: jpeg
42925
43868
  }),
42926
- publish: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null
43869
+ publish: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? NO_PUBLICATION
42927
43870
  };
42928
43871
  }
42929
43872
  /**
@@ -42996,7 +43939,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
42996
43939
  publishArtifact: buildTimelapseArtifactPublisher({
42997
43940
  logger: this.ctx.logger.child("Timelapse"),
42998
43941
  readExportBytes: ({ exportId }) => api.recordingExport.readExportBytes.query({ exportId }),
42999
- publish: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
43942
+ publish: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? NO_PUBLICATION,
43000
43943
  renderPreview: async ({ mode, ...input }) => {
43001
43944
  const previewPorts = {
43002
43945
  logger: this.ctx.logger.child("Timelapse"),
@@ -43420,6 +44363,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
43420
44363
  * `runtime/wire-subscriptions.ts`. */
43421
44364
  handleBindingsChangedEvent(data) {
43422
44365
  this.bindingCache?.onBindingsChanged(data);
44366
+ this.detectionCoverage?.onBindingsChanged(data);
43423
44367
  if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
43424
44368
  this.trackStore?.clearDevice(data.deviceId);
43425
44369
  this.residents.closeDevice(data.deviceId);
@@ -43445,6 +44389,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
43445
44389
  this.audioMarkers?.forgetDevice(deviceId);
43446
44390
  this.settingsCaches.clearDevice(deviceId);
43447
44391
  this.bindingCache?.invalidate(deviceId);
44392
+ this.detectionCoverage?.forget(deviceId);
43448
44393
  this.zoneAnalytics?.forgetDevice(deviceId);
43449
44394
  this.audioMetrics?.forgetDevice(deviceId);
43450
44395
  this.releaseProxy(deviceId);
@@ -43650,6 +44595,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
43650
44595
  this.trackStore?.clearAll();
43651
44596
  this.stationaryRegistry = null;
43652
44597
  this.bindingCache?.clearAll();
44598
+ this.detectionCoverage?.clearAll();
43653
44599
  await this.eventMediaDataPlane?.dispose();
43654
44600
  this.eventMediaDataPlane = null;
43655
44601
  this.eventMediaBaseUrl = null;
@@ -43660,6 +44606,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
43660
44606
  this.retrainExportDataPlane = null;
43661
44607
  await this.debugExportDataPlane?.dispose();
43662
44608
  this.debugExportDataPlane = null;
44609
+ if (this.ncArtifactPruneTimer !== null) {
44610
+ clearInterval(this.ncArtifactPruneTimer);
44611
+ this.ncArtifactPruneTimer = null;
44612
+ }
44613
+ await this.ncArtifactReadDataPlane?.dispose();
44614
+ this.ncArtifactReadDataPlane = null;
44615
+ this.ncArtifactIndex = null;
44616
+ this.ncArtifactStore = null;
43663
44617
  }
43664
44618
  async handleInferenceResult(data) {
43665
44619
  if (this.shuttingDown) return;