@camstack/addon-post-analysis 1.2.292 → 1.2.293

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (15) hide show
  1. package/dist/{clip-model-registry-CTY5co6P.js → clip-model-registry-BAuTHS_s.js} +2323 -2033
  2. package/dist/{clip-model-registry-Cudz--Ql.mjs → clip-model-registry-D2zqXamW.mjs} +2324 -2034
  3. package/dist/embedding-encoder/index.js +1 -1
  4. package/dist/embedding-encoder/index.mjs +1 -1
  5. package/dist/pipeline-analytics/_stub.js +2 -2
  6. package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CBHxwCSj.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DW6-zerK.mjs} +2 -2
  7. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Boh18P08.mjs +26 -0
  8. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Bs5QFgmh.mjs +26 -0
  9. package/dist/pipeline-analytics/{hostInit-DSKT2dE4.mjs → hostInit-CAJh3aX8.mjs} +2 -2
  10. package/dist/pipeline-analytics/index.js +1 -1
  11. package/dist/pipeline-analytics/index.mjs +1 -1
  12. package/dist/pipeline-analytics/remoteEntry.js +1 -1
  13. package/package.json +1 -1
  14. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-2OwTV_D2.mjs +0 -26
  15. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DAAKQ3sC.mjs +0 -26
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- //#region ../types/dist/event-category-BVDXG4tB.mjs
2
+ //#region ../types/dist/event-category-C5xZWqz6.mjs
3
3
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4
4
  EventCategory["SystemBoot"] = "system.boot";
5
5
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -363,6 +363,16 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
363
363
  */
364
364
  EventCategory["DeviceStateChanged"] = "device.state-changed";
365
365
  /**
366
+ * The NATIVE shadow of a cap a composition has claimed fields of moved
367
+ * (D663). Payload: `{ deviceId, capName, native }` — what the provider
368
+ * wrote, BEFORE the owned fields are overlaid. Owner-only (D224): it is for
369
+ * the composer's native self-reads, and the public event routers refuse it
370
+ * (`isOwnerOnlyEventCategory`) so no consumer surface ever shows a second
371
+ * truth beside the merged `DeviceStateChanged.slice`. Emitted only under a
372
+ * `replace` claim, one per native write of that cap.
373
+ */
374
+ EventCategory["DeviceNativeShadowChanged"] = "device.native-shadow-changed";
375
+ /**
366
376
  * Frame occupancy for a camera CHANGED — a tracked object was gained or
367
377
  * lost. Carries `{ deviceId, totalObjects, byClass, zones }`.
368
378
  *
@@ -5360,7 +5370,7 @@ var ZodIssueCode = {
5360
5370
  var ZodFirstPartyTypeKind;
5361
5371
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5362
5372
  //#endregion
5363
- //#region ../types/dist/sleep-DC-wdyeS.mjs
5373
+ //#region ../types/dist/sleep-jkRQgtSc.mjs
5364
5374
  /**
5365
5375
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5366
5376
  * window to float samples (D455).
@@ -7061,324 +7071,6 @@ function event(data) {
7061
7071
  var StaticDirOutputSchema$1 = object({ staticDir: string() });
7062
7072
  var VersionOutputSchema$1 = object({ version: string() });
7063
7073
  method(_void(), StaticDirOutputSchema$1, { auth: "admin" }), method(_void(), VersionOutputSchema$1, { auth: "admin" });
7064
- var DeviceType = /* @__PURE__ */ function(DeviceType) {
7065
- DeviceType["Camera"] = "camera";
7066
- DeviceType["Hub"] = "hub";
7067
- DeviceType["Light"] = "light";
7068
- DeviceType["Siren"] = "siren";
7069
- DeviceType["Switch"] = "switch";
7070
- DeviceType["Sensor"] = "sensor";
7071
- DeviceType["Thermostat"] = "thermostat";
7072
- /** Air-conditioner / heat-pump climate device (HVAC) — shares the
7073
- * `climate-control` cap surface with `Thermostat` but renders a
7074
- * dedicated AC-appropriate control UI (mode chips, fan speed,
7075
- * independent vertical/horizontal swing). Sources: native Gree, and
7076
- * reusable by other AC integrations. */
7077
- DeviceType["Climate"] = "climate";
7078
- DeviceType["Button"] = "button";
7079
- /** Generic stateless event emitter — carries a device's EXACT declared
7080
- * event vocabulary verbatim (no normalization). Installed with the
7081
- * `event-emitter` cap. Sources: HA `event.*` entities (structured) and
7082
- * HA bus events (e.g. `zha_event`, generic). */
7083
- DeviceType["EventEmitter"] = "event-emitter";
7084
- /** Firmware/software update entity — current vs available version,
7085
- * updatable flag, update state, and an install action. Installed with
7086
- * the `update` cap. Sources: Homematic firmware-update channels (and
7087
- * reusable by other providers, e.g. HA `update.*` entities). */
7088
- DeviceType["Update"] = "update";
7089
- DeviceType["Generic"] = "generic";
7090
- /** Generic notification delivery target (HA `notify.<service>`, future
7091
- * Telegram / Discord / ntfy / SMTP, …). One device per delivery
7092
- * endpoint; the `notifier` cap defines the send surface. */
7093
- DeviceType["Notifier"] = "notifier";
7094
- /** Pre-recorded action sequence with optional parameters
7095
- * (HA `script.*`). Runnable via `script-runner` cap. */
7096
- DeviceType["Script"] = "script";
7097
- /** Automation rule (HA `automation.*`) — enable/disable + manual
7098
- * trigger surface exposed via `automation-control` cap. */
7099
- DeviceType["Automation"] = "automation";
7100
- /** Door / smart lock device (HA `lock.*`). `lock-control` cap. */
7101
- DeviceType["Lock"] = "lock";
7102
- /** Window covering, blinds, garage door, valve, etc. (HA `cover.*`,
7103
- * `valve.*`). `cover` cap with sub-roles for variant. */
7104
- DeviceType["Cover"] = "cover";
7105
- /** Pipe / water / gas valve with open/close/stop and optional
7106
- * position (HA `valve.*`). `valve` cap — a cover-sibling actuator
7107
- * modelled on the same open/closed lifecycle. */
7108
- DeviceType["Valve"] = "valve";
7109
- /** Humidifier / dehumidifier with on/off + target humidity + mode
7110
- * (HA `humidifier.*`). `humidifier` cap — a climate-family actuator
7111
- * modelled on the same target / mode lifecycle. */
7112
- DeviceType["Humidifier"] = "humidifier";
7113
- /** Water heater / boiler with target temperature + operation mode +
7114
- * away mode (HA `water_heater.*`). `water-heater` cap — a
7115
- * climate-family actuator. */
7116
- DeviceType["WaterHeater"] = "water-heater";
7117
- /** Ceiling / standing / exhaust fan (HA `fan.*`). `fan-control` cap. */
7118
- DeviceType["Fan"] = "fan";
7119
- /** Audio / video playback endpoint (HA `media_player.*`). Disjoint from
7120
- * the camera surface — those use `Camera`. `media-player` cap. */
7121
- DeviceType["MediaPlayer"] = "media-player";
7122
- /** Security panel / alarm system (HA `alarm_control_panel.*`).
7123
- * `alarm-panel` cap. */
7124
- DeviceType["AlarmPanel"] = "alarm-panel";
7125
- /** Generic user-settable input (HA `number` / `input_number` / `select`
7126
- * / `input_select` / `text` / `input_text` / `input_datetime`).
7127
- * Sub-type via `DeviceRole`: NumericControl / SelectControl /
7128
- * TextControl / DateTimeControl. */
7129
- DeviceType["Control"] = "control";
7130
- /** Person / device-tracker presence (HA `person.*`, `device_tracker.*`).
7131
- * `presence` cap. */
7132
- DeviceType["Presence"] = "presence";
7133
- /** Weather provider (HA `weather.*`). Tier-3, low MVP priority.
7134
- * `weather` cap. */
7135
- DeviceType["Weather"] = "weather";
7136
- /** Robot vacuum (HA `vacuum.*`). Tier-3. `vacuum-control` cap. */
7137
- DeviceType["Vacuum"] = "vacuum";
7138
- /** Robotic lawn mower (HA `lawn_mower.*`). Tier-3.
7139
- * `lawn-mower-control` cap. */
7140
- DeviceType["LawnMower"] = "lawn-mower";
7141
- /** Physical HA device group — parent container for entity-children
7142
- * adopted from a single HA device entry. Not renderable as a
7143
- * standalone device; exists only to anchor child entities. */
7144
- DeviceType["Container"] = "container";
7145
- /** Single still-image entity (HA `image.*`). Read-only display of an
7146
- * `entity_picture` signed URL the browser loads directly. `image` cap. */
7147
- DeviceType["Image"] = "image";
7148
- /** Smart pet feeder — cloud-connected food dispenser with a bowl food
7149
- * level, battery, desiccant life, feeding state and manual-feed /
7150
- * call-pet / maintenance actions. Installed with the `pet-feeder` cap;
7151
- * dual-hopper models (D4S/D4SH) expose per-hopper portions. Sources:
7152
- * native PetKit (`nodepetkit` `FeederDevice`), reusable by other feeder
7153
- * integrations sharing the same food/desiccant/hopper surface. */
7154
- DeviceType["PetFeeder"] = "pet-feeder";
7155
- return DeviceType;
7156
- }({});
7157
- var DeviceFeature = /* @__PURE__ */ function(DeviceFeature) {
7158
- DeviceFeature["BatteryOperated"] = "battery-operated";
7159
- DeviceFeature["Rebootable"] = "rebootable";
7160
- /**
7161
- * Device supports an on-demand re-sync of its derived spec with its
7162
- * upstream source — drives the generic Re-sync button. The owning
7163
- * provider implements the action via the `device-adoption.resync` cap.
7164
- */
7165
- DeviceFeature["Resyncable"] = "resyncable";
7166
- DeviceFeature["NativeSnapshot"] = "native-snapshot";
7167
- DeviceFeature["DoorbellButton"] = "doorbell-button";
7168
- DeviceFeature["TwoWayAudio"] = "two-way-audio";
7169
- DeviceFeature["PanTiltZoom"] = "pan-tilt-zoom";
7170
- /**
7171
- * Camera supports the on-firmware autotrack subsystem (subject-
7172
- * following). Distinct from `PanTiltZoom` because not every PTZ
7173
- * camera ships autotrack — the admin UI uses this flag to gate
7174
- * the autotrack toggle / settings card without re-deriving from
7175
- * the cap registry. Mirrors `ptz-autotrack` cap registration:
7176
- * driver sets this feature when probe confirms the firmware
7177
- * surface, and registers the cap in the same code path.
7178
- */
7179
- DeviceFeature["PtzAutotrack"] = "ptz-autotrack";
7180
- /**
7181
- * Accessory exposes a "trigger on motion" toggle — the parent camera's
7182
- * motion detection automatically activates this device. Mirrors
7183
- * `motion-trigger` cap registration: drivers set this feature in the
7184
- * same code path that calls `ctx.registerNativeCap(motionTriggerCapability, ...)`.
7185
- *
7186
- * Used by admin UI (gate the in-hero `MotionTriggerToggle` against a
7187
- * fast scalar without binding fetch), notifier rules, and `listAll`
7188
- * filters that want "all devices with on-motion behaviour".
7189
- */
7190
- DeviceFeature["MotionTrigger"] = "motion-trigger";
7191
- /** Light supports rgb-triplet color via `color` cap. */
7192
- DeviceFeature["LightColorRgb"] = "light-color-rgb";
7193
- /** Light supports HSV color via `color` cap. */
7194
- DeviceFeature["LightColorHsv"] = "light-color-hsv";
7195
- /** Light supports color-temperature (mired) via `color` cap. */
7196
- DeviceFeature["LightColorMired"] = "light-color-mired";
7197
- /** Thermostat supports a `heat_cool` dual setpoint (targetLow +
7198
- * targetHigh). Gates the range slider UI. */
7199
- DeviceFeature["ClimateDualSetpoint"] = "climate-dual-setpoint";
7200
- /** Thermostat exposes target humidity and/or current humidity
7201
- * readings. Gates the humidity controls. */
7202
- DeviceFeature["ClimateHumidity"] = "climate-humidity";
7203
- /** Thermostat exposes a fan-mode selector. */
7204
- DeviceFeature["ClimateFanMode"] = "climate-fan-mode";
7205
- /** Thermostat exposes preset modes (eco / away / sleep / vendor). */
7206
- DeviceFeature["ClimatePreset"] = "climate-preset";
7207
- /** Thermostat exposes a vertical louver swing toggle. Gates the
7208
- * vertical-swing switch in the climate UI. Independent of horizontal. */
7209
- DeviceFeature["ClimateSwingVertical"] = "climate-swing-vertical";
7210
- /** Thermostat exposes a horizontal louver swing toggle. Gates the
7211
- * horizontal-swing switch in the climate UI. Independent of vertical. */
7212
- DeviceFeature["ClimateSwingHorizontal"] = "climate-swing-horizontal";
7213
- /** Cover exposes intermediate position control (0..100). Gates the
7214
- * position slider UI. */
7215
- DeviceFeature["CoverPositionable"] = "cover-positionable";
7216
- /** Cover exposes slat-tilt control. Gates the tilt slider UI. */
7217
- DeviceFeature["CoverTilt"] = "cover-tilt";
7218
- /** Valve exposes intermediate position control (0..100). Gates the
7219
- * position slider / drag surface UI. */
7220
- DeviceFeature["ValvePositionable"] = "valve-positionable";
7221
- /** Fan exposes a speed-percentage setter. Gates the speed slider UI. */
7222
- DeviceFeature["FanSpeed"] = "fan-speed";
7223
- /** Fan exposes a preset mode selector. */
7224
- DeviceFeature["FanPreset"] = "fan-preset";
7225
- /** Fan exposes blade direction (forward/reverse) — typical of
7226
- * ceiling fans. */
7227
- DeviceFeature["FanDirection"] = "fan-direction";
7228
- /** Fan exposes an oscillation toggle. */
7229
- DeviceFeature["FanOscillating"] = "fan-oscillating";
7230
- /** Lock requires a PIN code on lock/unlock. Gates the code-entry
7231
- * field on the UI lock-controls panel. */
7232
- DeviceFeature["LockPinRequired"] = "lock-pin-required";
7233
- /** Lock supports a latch-release ("open door") action distinct from
7234
- * unlock. Mirrors HA `LockEntityFeature.OPEN` (bit 1) in
7235
- * `supported_features`. Gates the Open Door button in the UI. */
7236
- DeviceFeature["LockOpen"] = "lock-open";
7237
- /** Media player exposes a seek-to-position surface. */
7238
- DeviceFeature["MediaPlayerSeek"] = "media-player-seek";
7239
- /** Media player exposes a volume-level setter. */
7240
- DeviceFeature["MediaPlayerVolume"] = "media-player-volume";
7241
- /** Media player exposes a mute toggle distinct from volume=0. */
7242
- DeviceFeature["MediaPlayerMute"] = "media-player-mute";
7243
- /** Media player exposes a shuffle toggle. */
7244
- DeviceFeature["MediaPlayerShuffle"] = "media-player-shuffle";
7245
- /** Media player exposes a repeat mode (off / all / one). */
7246
- DeviceFeature["MediaPlayerRepeat"] = "media-player-repeat";
7247
- /** Media player exposes a source / input selector. */
7248
- DeviceFeature["MediaPlayerSelectSource"] = "media-player-select-source";
7249
- /** Media player exposes a play-arbitrary-media surface (URL / id). */
7250
- DeviceFeature["MediaPlayerPlayMedia"] = "media-player-play-media";
7251
- /** Media player exposes next-track. */
7252
- DeviceFeature["MediaPlayerNext"] = "media-player-next";
7253
- /** Media player exposes previous-track. */
7254
- DeviceFeature["MediaPlayerPrevious"] = "media-player-previous";
7255
- /** Media player exposes stop distinct from pause. */
7256
- DeviceFeature["MediaPlayerStop"] = "media-player-stop";
7257
- /** Alarm panel requires a PIN code on arm/disarm. */
7258
- DeviceFeature["AlarmPinRequired"] = "alarm-pin-required";
7259
- /** Presence device carries GPS coordinates (lat/lng/accuracy) in
7260
- * addition to a textual location. */
7261
- DeviceFeature["PresenceGps"] = "presence-gps";
7262
- /** Notifier accepts an inline / URL image attachment. */
7263
- DeviceFeature["NotifierImage"] = "notifier-image";
7264
- /** Notifier accepts a priority hint (high/normal/low). */
7265
- DeviceFeature["NotifierPriority"] = "notifier-priority";
7266
- /** Notifier accepts a free-form `data` payload for platform-specific
7267
- * fields. */
7268
- DeviceFeature["NotifierData"] = "notifier-data";
7269
- /** Notifier supports interactive action buttons / callbacks. */
7270
- DeviceFeature["NotifierActions"] = "notifier-actions";
7271
- /** Notifier supports per-call recipient targeting (multi-user). */
7272
- DeviceFeature["NotifierRecipients"] = "notifier-recipients";
7273
- /** Script runner accepts a variables map on each run invocation. */
7274
- DeviceFeature["ScriptVariables"] = "script-variables";
7275
- /** Automation `trigger` accepts a skipCondition flag — fires the
7276
- * automation's actions while bypassing its condition block. */
7277
- DeviceFeature["AutomationSkipCondition"] = "automation-skip-condition";
7278
- /** Robot vacuum exposes a live cleaning map (image child). Gates the
7279
- * map tile in the vacuum UI. */
7280
- DeviceFeature["VacuumHasMap"] = "vacuum-has-map";
7281
- /** Robot vacuum exposes AI obstacle-detection toggles. Gates the AI
7282
- * switches group. */
7283
- DeviceFeature["VacuumHasAi"] = "vacuum-has-ai";
7284
- /** Robot mower exposes a live mowing map (SVG image child). Gates the
7285
- * mower map tile. */
7286
- DeviceFeature["MowerHasMap"] = "mower-has-map";
7287
- /** Robot mower exposes targeted mowing (all-area / zones / edges /
7288
- * spots) — gates the Mow action + map selectors. */
7289
- DeviceFeature["MowerHasTargetedMowing"] = "mower-has-targeted-mowing";
7290
- return DeviceFeature;
7291
- }({});
7292
- /**
7293
- * Semantic role a device plays within its parent. Populated by driver
7294
- * addons when creating accessory devices (Reolink siren/floodlight/
7295
- * PIR/chime/autotrack/doorbell, ONVIF relay outputs, …). Used by the
7296
- * admin UI to pick icons, labels, and widgets — a `Switch` with
7297
- * `role: Floodlight` renders as a bulb with a brightness slider,
7298
- * whereas a `Switch` with `role: Siren` renders as a klaxon.
7299
- *
7300
- * Undefined for top-level devices (cameras, NVRs, hubs). Persisted in
7301
- * sqlite as a nullable TEXT column — old rows keep working unchanged.
7302
- */
7303
- var DeviceRole = /* @__PURE__ */ function(DeviceRole) {
7304
- DeviceRole["Siren"] = "siren";
7305
- DeviceRole["Floodlight"] = "floodlight";
7306
- DeviceRole["Spotlight"] = "spotlight";
7307
- DeviceRole["PirSensor"] = "pir-sensor";
7308
- DeviceRole["Chime"] = "chime";
7309
- DeviceRole["Autotrack"] = "autotrack";
7310
- DeviceRole["Nightvision"] = "nightvision";
7311
- DeviceRole["PrivacyMask"] = "privacy-mask";
7312
- DeviceRole["Doorbell"] = "doorbell";
7313
- /** Virtual HA toggle (input_boolean.*) — distinguishable from a
7314
- * real Switch device for UI rendering / export adapters. */
7315
- DeviceRole["BinaryHelper"] = "binary-helper";
7316
- /** Generic motion / occupancy / moving event source. Distinct from
7317
- * the camera accessory PirSensor role: that one is a camera child;
7318
- * this is a standalone HA / 3rd-party motion sensor. */
7319
- DeviceRole["MotionSensor"] = "motion-sensor";
7320
- DeviceRole["ContactSensor"] = "contact-sensor";
7321
- DeviceRole["LeakSensor"] = "leak-sensor";
7322
- DeviceRole["SmokeSensor"] = "smoke-sensor";
7323
- DeviceRole["COSensor"] = "co-sensor";
7324
- DeviceRole["GasSensor"] = "gas-sensor";
7325
- DeviceRole["TamperSensor"] = "tamper-sensor";
7326
- DeviceRole["VibrationSensor"] = "vibration-sensor";
7327
- DeviceRole["ConnectivitySensor"] = "connectivity-sensor";
7328
- DeviceRole["SoundSensor"] = "sound-sensor";
7329
- /** Fallback for `binary_sensor` without a known `device_class`. */
7330
- DeviceRole["BinarySensor"] = "binary-sensor";
7331
- DeviceRole["TemperatureSensor"] = "temperature-sensor";
7332
- DeviceRole["HumiditySensor"] = "humidity-sensor";
7333
- DeviceRole["AmbientLightSensor"] = "ambient-light-sensor";
7334
- DeviceRole["PressureSensor"] = "pressure-sensor";
7335
- /** Wind speed or direction (weather-station `wind-sensor` cap). */
7336
- DeviceRole["WindSensor"] = "wind-sensor";
7337
- /** Rain accumulation or rate (weather-station `rain-sensor` cap). */
7338
- DeviceRole["RainSensor"] = "rain-sensor";
7339
- /** UV index (weather-station `uv-sensor` cap). */
7340
- DeviceRole["UvSensor"] = "uv-sensor";
7341
- /** Solar irradiance W/m² (weather-station `solar-radiation-sensor` cap).
7342
- * Distinct from AmbientLightSensor (lux). */
7343
- DeviceRole["SolarRadiationSensor"] = "solar-radiation-sensor";
7344
- /** Soil moisture % (garden/weather `soil-moisture-sensor` cap). */
7345
- DeviceRole["SoilMoistureSensor"] = "soil-moisture-sensor";
7346
- DeviceRole["PowerSensor"] = "power-sensor";
7347
- DeviceRole["EnergySensor"] = "energy-sensor";
7348
- DeviceRole["VoltageSensor"] = "voltage-sensor";
7349
- DeviceRole["CurrentSensor"] = "current-sensor";
7350
- DeviceRole["AirQualitySensor"] = "air-quality-sensor";
7351
- /** Battery level (numeric % via `sensor` OR low-bool via
7352
- * `binary_sensor` — the cap distinguishes via the value type). */
7353
- DeviceRole["BatterySensor"] = "battery-sensor";
7354
- /** Fallback for `sensor` numeric without a known `device_class`. */
7355
- DeviceRole["NumericSensor"] = "numeric-sensor";
7356
- /** String / enum state (HA `sensor` with `state_class: enum` or
7357
- * `attributes.options`). */
7358
- DeviceRole["EnumSensor"] = "enum-sensor";
7359
- /** Date / timestamp state (HA `sensor` with `device_class: timestamp`
7360
- * or `date`). The slice carries the raw ISO string verbatim (hosted on
7361
- * the `enum-sensor` cap); the UI renders it locale-formatted. */
7362
- DeviceRole["DateTimeSensor"] = "datetime-sensor";
7363
- /** Last-resort fallback when nothing else matches. */
7364
- DeviceRole["GenericSensor"] = "generic-sensor";
7365
- DeviceRole["NumericControl"] = "numeric-control";
7366
- DeviceRole["SelectControl"] = "select-control";
7367
- DeviceRole["TextControl"] = "text-control";
7368
- DeviceRole["DateTimeControl"] = "datetime-control";
7369
- /** Mobile push notifier (HA `notify.mobile_app_*`) — supports
7370
- * rich features (image, priority, channel routing). */
7371
- DeviceRole["MobilePushNotifier"] = "mobile-push-notifier";
7372
- /** Chat / messaging service (HA `notify.telegram_*`,
7373
- * `notify.discord_*`, etc.). */
7374
- DeviceRole["MessagingNotifier"] = "messaging-notifier";
7375
- /** Email-based delivery (HA `notify.smtp`, etc.). */
7376
- DeviceRole["EmailNotifier"] = "email-notifier";
7377
- /** Fallback when the notifier service name doesn't match a known
7378
- * pattern. */
7379
- DeviceRole["GenericNotifier"] = "generic-notifier";
7380
- return DeviceRole;
7381
- }({});
7382
7074
  /**
7383
7075
  * Identity — preserves literal types for downstream inference.
7384
7076
  *
@@ -7603,7 +7295,337 @@ function sleep(ms) {
7603
7295
  return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
7604
7296
  }
7605
7297
  //#endregion
7606
- //#region ../types/dist/canonical-hash-CPK2Dy60.mjs
7298
+ //#region ../types/dist/err-msg-DX6i_MY4.mjs
7299
+ var DeviceType = /* @__PURE__ */ function(DeviceType) {
7300
+ DeviceType["Camera"] = "camera";
7301
+ DeviceType["Hub"] = "hub";
7302
+ DeviceType["Light"] = "light";
7303
+ DeviceType["Siren"] = "siren";
7304
+ DeviceType["Switch"] = "switch";
7305
+ DeviceType["Sensor"] = "sensor";
7306
+ DeviceType["Thermostat"] = "thermostat";
7307
+ /** Air-conditioner / heat-pump climate device (HVAC) — shares the
7308
+ * `climate-control` cap surface with `Thermostat` but renders a
7309
+ * dedicated AC-appropriate control UI (mode chips, fan speed,
7310
+ * independent vertical/horizontal swing). Sources: native Gree, and
7311
+ * reusable by other AC integrations. */
7312
+ DeviceType["Climate"] = "climate";
7313
+ DeviceType["Button"] = "button";
7314
+ /** Generic stateless event emitter — carries a device's EXACT declared
7315
+ * event vocabulary verbatim (no normalization). Installed with the
7316
+ * `event-emitter` cap. Sources: HA `event.*` entities (structured) and
7317
+ * HA bus events (e.g. `zha_event`, generic). */
7318
+ DeviceType["EventEmitter"] = "event-emitter";
7319
+ /** Firmware/software update entity — current vs available version,
7320
+ * updatable flag, update state, and an install action. Installed with
7321
+ * the `update` cap. Sources: Homematic firmware-update channels (and
7322
+ * reusable by other providers, e.g. HA `update.*` entities). */
7323
+ DeviceType["Update"] = "update";
7324
+ DeviceType["Generic"] = "generic";
7325
+ /** Generic notification delivery target (HA `notify.<service>`, future
7326
+ * Telegram / Discord / ntfy / SMTP, …). One device per delivery
7327
+ * endpoint; the `notifier` cap defines the send surface. */
7328
+ DeviceType["Notifier"] = "notifier";
7329
+ /** Pre-recorded action sequence with optional parameters
7330
+ * (HA `script.*`). Runnable via `script-runner` cap. */
7331
+ DeviceType["Script"] = "script";
7332
+ /** Automation rule (HA `automation.*`) — enable/disable + manual
7333
+ * trigger surface exposed via `automation-control` cap. */
7334
+ DeviceType["Automation"] = "automation";
7335
+ /** Door / smart lock device (HA `lock.*`). `lock-control` cap. */
7336
+ DeviceType["Lock"] = "lock";
7337
+ /** Window covering, blinds, garage door, valve, etc. (HA `cover.*`,
7338
+ * `valve.*`). `cover` cap with sub-roles for variant. */
7339
+ DeviceType["Cover"] = "cover";
7340
+ /** Pipe / water / gas valve with open/close/stop and optional
7341
+ * position (HA `valve.*`). `valve` cap — a cover-sibling actuator
7342
+ * modelled on the same open/closed lifecycle. */
7343
+ DeviceType["Valve"] = "valve";
7344
+ /** Humidifier / dehumidifier with on/off + target humidity + mode
7345
+ * (HA `humidifier.*`). `humidifier` cap — a climate-family actuator
7346
+ * modelled on the same target / mode lifecycle. */
7347
+ DeviceType["Humidifier"] = "humidifier";
7348
+ /** Water heater / boiler with target temperature + operation mode +
7349
+ * away mode (HA `water_heater.*`). `water-heater` cap — a
7350
+ * climate-family actuator. */
7351
+ DeviceType["WaterHeater"] = "water-heater";
7352
+ /** Ceiling / standing / exhaust fan (HA `fan.*`). `fan-control` cap. */
7353
+ DeviceType["Fan"] = "fan";
7354
+ /** Audio / video playback endpoint (HA `media_player.*`). Disjoint from
7355
+ * the camera surface — those use `Camera`. `media-player` cap. */
7356
+ DeviceType["MediaPlayer"] = "media-player";
7357
+ /** Security panel / alarm system (HA `alarm_control_panel.*`).
7358
+ * `alarm-panel` cap. */
7359
+ DeviceType["AlarmPanel"] = "alarm-panel";
7360
+ /** Generic user-settable input (HA `number` / `input_number` / `select`
7361
+ * / `input_select` / `text` / `input_text` / `input_datetime`).
7362
+ * Sub-type via `DeviceRole`: NumericControl / SelectControl /
7363
+ * TextControl / DateTimeControl. */
7364
+ DeviceType["Control"] = "control";
7365
+ /** Person / device-tracker presence (HA `person.*`, `device_tracker.*`).
7366
+ * `presence` cap. */
7367
+ DeviceType["Presence"] = "presence";
7368
+ /** Weather provider (HA `weather.*`). Tier-3, low MVP priority.
7369
+ * `weather` cap. */
7370
+ DeviceType["Weather"] = "weather";
7371
+ /** Robot vacuum (HA `vacuum.*`). Tier-3. `vacuum-control` cap. */
7372
+ DeviceType["Vacuum"] = "vacuum";
7373
+ /** Robotic lawn mower (HA `lawn_mower.*`). Tier-3.
7374
+ * `lawn-mower-control` cap. */
7375
+ DeviceType["LawnMower"] = "lawn-mower";
7376
+ /** Physical HA device group — parent container for entity-children
7377
+ * adopted from a single HA device entry. Not renderable as a
7378
+ * standalone device; exists only to anchor child entities. */
7379
+ DeviceType["Container"] = "container";
7380
+ /** Single still-image entity (HA `image.*`). Read-only display of an
7381
+ * `entity_picture` signed URL the browser loads directly. `image` cap. */
7382
+ DeviceType["Image"] = "image";
7383
+ /** Smart pet feeder — cloud-connected food dispenser with a bowl food
7384
+ * level, battery, desiccant life, feeding state and manual-feed /
7385
+ * call-pet / maintenance actions. Installed with the `pet-feeder` cap;
7386
+ * dual-hopper models (D4S/D4SH) expose per-hopper portions. Sources:
7387
+ * native PetKit (`nodepetkit` `FeederDevice`), reusable by other feeder
7388
+ * integrations sharing the same food/desiccant/hopper surface. */
7389
+ DeviceType["PetFeeder"] = "pet-feeder";
7390
+ return DeviceType;
7391
+ }({});
7392
+ var DeviceFeature = /* @__PURE__ */ function(DeviceFeature) {
7393
+ DeviceFeature["BatteryOperated"] = "battery-operated";
7394
+ DeviceFeature["Rebootable"] = "rebootable";
7395
+ /**
7396
+ * Device supports an on-demand re-sync of its derived spec with its
7397
+ * upstream source — drives the generic Re-sync button. The owning
7398
+ * provider implements the action via the `device-adoption.resync` cap.
7399
+ */
7400
+ DeviceFeature["Resyncable"] = "resyncable";
7401
+ DeviceFeature["NativeSnapshot"] = "native-snapshot";
7402
+ DeviceFeature["DoorbellButton"] = "doorbell-button";
7403
+ DeviceFeature["TwoWayAudio"] = "two-way-audio";
7404
+ DeviceFeature["PanTiltZoom"] = "pan-tilt-zoom";
7405
+ /**
7406
+ * Camera supports the on-firmware autotrack subsystem (subject-
7407
+ * following). Distinct from `PanTiltZoom` because not every PTZ
7408
+ * camera ships autotrack — the admin UI uses this flag to gate
7409
+ * the autotrack toggle / settings card without re-deriving from
7410
+ * the cap registry. Mirrors `ptz-autotrack` cap registration:
7411
+ * driver sets this feature when probe confirms the firmware
7412
+ * surface, and registers the cap in the same code path.
7413
+ */
7414
+ DeviceFeature["PtzAutotrack"] = "ptz-autotrack";
7415
+ /**
7416
+ * Accessory exposes a "trigger on motion" toggle — the parent camera's
7417
+ * motion detection automatically activates this device. Mirrors
7418
+ * `motion-trigger` cap registration: drivers set this feature in the
7419
+ * same code path that calls `ctx.registerNativeCap(motionTriggerCapability, ...)`.
7420
+ *
7421
+ * Used by admin UI (gate the in-hero `MotionTriggerToggle` against a
7422
+ * fast scalar without binding fetch), notifier rules, and `listAll`
7423
+ * filters that want "all devices with on-motion behaviour".
7424
+ */
7425
+ DeviceFeature["MotionTrigger"] = "motion-trigger";
7426
+ /** Light supports rgb-triplet color via `color` cap. */
7427
+ DeviceFeature["LightColorRgb"] = "light-color-rgb";
7428
+ /** Light supports HSV color via `color` cap. */
7429
+ DeviceFeature["LightColorHsv"] = "light-color-hsv";
7430
+ /** Light supports color-temperature (mired) via `color` cap. */
7431
+ DeviceFeature["LightColorMired"] = "light-color-mired";
7432
+ /** Thermostat supports a `heat_cool` dual setpoint (targetLow +
7433
+ * targetHigh). Gates the range slider UI. */
7434
+ DeviceFeature["ClimateDualSetpoint"] = "climate-dual-setpoint";
7435
+ /** Thermostat exposes target humidity and/or current humidity
7436
+ * readings. Gates the humidity controls. */
7437
+ DeviceFeature["ClimateHumidity"] = "climate-humidity";
7438
+ /** Thermostat exposes a fan-mode selector. */
7439
+ DeviceFeature["ClimateFanMode"] = "climate-fan-mode";
7440
+ /** Thermostat exposes preset modes (eco / away / sleep / vendor). */
7441
+ DeviceFeature["ClimatePreset"] = "climate-preset";
7442
+ /** Thermostat exposes a vertical louver swing toggle. Gates the
7443
+ * vertical-swing switch in the climate UI. Independent of horizontal. */
7444
+ DeviceFeature["ClimateSwingVertical"] = "climate-swing-vertical";
7445
+ /** Thermostat exposes a horizontal louver swing toggle. Gates the
7446
+ * horizontal-swing switch in the climate UI. Independent of vertical. */
7447
+ DeviceFeature["ClimateSwingHorizontal"] = "climate-swing-horizontal";
7448
+ /** Cover exposes intermediate position control (0..100). Gates the
7449
+ * position slider UI. */
7450
+ DeviceFeature["CoverPositionable"] = "cover-positionable";
7451
+ /** Cover exposes slat-tilt control. Gates the tilt slider UI. */
7452
+ DeviceFeature["CoverTilt"] = "cover-tilt";
7453
+ /** Valve exposes intermediate position control (0..100). Gates the
7454
+ * position slider / drag surface UI. */
7455
+ DeviceFeature["ValvePositionable"] = "valve-positionable";
7456
+ /** Fan exposes a speed-percentage setter. Gates the speed slider UI. */
7457
+ DeviceFeature["FanSpeed"] = "fan-speed";
7458
+ /** Fan exposes a preset mode selector. */
7459
+ DeviceFeature["FanPreset"] = "fan-preset";
7460
+ /** Fan exposes blade direction (forward/reverse) — typical of
7461
+ * ceiling fans. */
7462
+ DeviceFeature["FanDirection"] = "fan-direction";
7463
+ /** Fan exposes an oscillation toggle. */
7464
+ DeviceFeature["FanOscillating"] = "fan-oscillating";
7465
+ /** Lock requires a PIN code on lock/unlock. Gates the code-entry
7466
+ * field on the UI lock-controls panel. */
7467
+ DeviceFeature["LockPinRequired"] = "lock-pin-required";
7468
+ /** Lock supports a latch-release ("open door") action distinct from
7469
+ * unlock. Mirrors HA `LockEntityFeature.OPEN` (bit 1) in
7470
+ * `supported_features`. Gates the Open Door button in the UI. */
7471
+ DeviceFeature["LockOpen"] = "lock-open";
7472
+ /** Media player exposes a seek-to-position surface. */
7473
+ DeviceFeature["MediaPlayerSeek"] = "media-player-seek";
7474
+ /** Media player exposes a volume-level setter. */
7475
+ DeviceFeature["MediaPlayerVolume"] = "media-player-volume";
7476
+ /** Media player exposes a mute toggle distinct from volume=0. */
7477
+ DeviceFeature["MediaPlayerMute"] = "media-player-mute";
7478
+ /** Media player exposes a shuffle toggle. */
7479
+ DeviceFeature["MediaPlayerShuffle"] = "media-player-shuffle";
7480
+ /** Media player exposes a repeat mode (off / all / one). */
7481
+ DeviceFeature["MediaPlayerRepeat"] = "media-player-repeat";
7482
+ /** Media player exposes a source / input selector. */
7483
+ DeviceFeature["MediaPlayerSelectSource"] = "media-player-select-source";
7484
+ /** Media player exposes a play-arbitrary-media surface (URL / id). */
7485
+ DeviceFeature["MediaPlayerPlayMedia"] = "media-player-play-media";
7486
+ /** Media player exposes next-track. */
7487
+ DeviceFeature["MediaPlayerNext"] = "media-player-next";
7488
+ /** Media player exposes previous-track. */
7489
+ DeviceFeature["MediaPlayerPrevious"] = "media-player-previous";
7490
+ /** Media player exposes stop distinct from pause. */
7491
+ DeviceFeature["MediaPlayerStop"] = "media-player-stop";
7492
+ /** Alarm panel requires a PIN code on arm/disarm. */
7493
+ DeviceFeature["AlarmPinRequired"] = "alarm-pin-required";
7494
+ /** Presence device carries GPS coordinates (lat/lng/accuracy) in
7495
+ * addition to a textual location. */
7496
+ DeviceFeature["PresenceGps"] = "presence-gps";
7497
+ /** Notifier accepts an inline / URL image attachment. */
7498
+ DeviceFeature["NotifierImage"] = "notifier-image";
7499
+ /** Notifier accepts a priority hint (high/normal/low). */
7500
+ DeviceFeature["NotifierPriority"] = "notifier-priority";
7501
+ /** Notifier accepts a free-form `data` payload for platform-specific
7502
+ * fields. */
7503
+ DeviceFeature["NotifierData"] = "notifier-data";
7504
+ /** Notifier supports interactive action buttons / callbacks. */
7505
+ DeviceFeature["NotifierActions"] = "notifier-actions";
7506
+ /** Notifier supports per-call recipient targeting (multi-user). */
7507
+ DeviceFeature["NotifierRecipients"] = "notifier-recipients";
7508
+ /** Script runner accepts a variables map on each run invocation. */
7509
+ DeviceFeature["ScriptVariables"] = "script-variables";
7510
+ /** Automation `trigger` accepts a skipCondition flag — fires the
7511
+ * automation's actions while bypassing its condition block. */
7512
+ DeviceFeature["AutomationSkipCondition"] = "automation-skip-condition";
7513
+ /** Robot vacuum exposes a live cleaning map (image child). Gates the
7514
+ * map tile in the vacuum UI. */
7515
+ DeviceFeature["VacuumHasMap"] = "vacuum-has-map";
7516
+ /** Robot vacuum exposes AI obstacle-detection toggles. Gates the AI
7517
+ * switches group. */
7518
+ DeviceFeature["VacuumHasAi"] = "vacuum-has-ai";
7519
+ /** Robot mower exposes a live mowing map (SVG image child). Gates the
7520
+ * mower map tile. */
7521
+ DeviceFeature["MowerHasMap"] = "mower-has-map";
7522
+ /** Robot mower exposes targeted mowing (all-area / zones / edges /
7523
+ * spots) — gates the Mow action + map selectors. */
7524
+ DeviceFeature["MowerHasTargetedMowing"] = "mower-has-targeted-mowing";
7525
+ return DeviceFeature;
7526
+ }({});
7527
+ /**
7528
+ * Semantic role a device plays within its parent. Populated by driver
7529
+ * addons when creating accessory devices (Reolink siren/floodlight/
7530
+ * PIR/chime/autotrack/doorbell, ONVIF relay outputs, …). Used by the
7531
+ * admin UI to pick icons, labels, and widgets — a `Switch` with
7532
+ * `role: Floodlight` renders as a bulb with a brightness slider,
7533
+ * whereas a `Switch` with `role: Siren` renders as a klaxon.
7534
+ *
7535
+ * Undefined for top-level devices (cameras, NVRs, hubs). Persisted in
7536
+ * sqlite as a nullable TEXT column — old rows keep working unchanged.
7537
+ */
7538
+ var DeviceRole = /* @__PURE__ */ function(DeviceRole) {
7539
+ DeviceRole["Siren"] = "siren";
7540
+ DeviceRole["Floodlight"] = "floodlight";
7541
+ DeviceRole["Spotlight"] = "spotlight";
7542
+ DeviceRole["PirSensor"] = "pir-sensor";
7543
+ DeviceRole["Chime"] = "chime";
7544
+ DeviceRole["Autotrack"] = "autotrack";
7545
+ DeviceRole["Nightvision"] = "nightvision";
7546
+ DeviceRole["PrivacyMask"] = "privacy-mask";
7547
+ DeviceRole["Doorbell"] = "doorbell";
7548
+ /** Virtual HA toggle (input_boolean.*) — distinguishable from a
7549
+ * real Switch device for UI rendering / export adapters. */
7550
+ DeviceRole["BinaryHelper"] = "binary-helper";
7551
+ /** Generic motion / occupancy / moving event source. Distinct from
7552
+ * the camera accessory PirSensor role: that one is a camera child;
7553
+ * this is a standalone HA / 3rd-party motion sensor. */
7554
+ DeviceRole["MotionSensor"] = "motion-sensor";
7555
+ DeviceRole["ContactSensor"] = "contact-sensor";
7556
+ DeviceRole["LeakSensor"] = "leak-sensor";
7557
+ DeviceRole["SmokeSensor"] = "smoke-sensor";
7558
+ DeviceRole["COSensor"] = "co-sensor";
7559
+ DeviceRole["GasSensor"] = "gas-sensor";
7560
+ DeviceRole["TamperSensor"] = "tamper-sensor";
7561
+ DeviceRole["VibrationSensor"] = "vibration-sensor";
7562
+ DeviceRole["ConnectivitySensor"] = "connectivity-sensor";
7563
+ DeviceRole["SoundSensor"] = "sound-sensor";
7564
+ /** Fallback for `binary_sensor` without a known `device_class`. */
7565
+ DeviceRole["BinarySensor"] = "binary-sensor";
7566
+ DeviceRole["TemperatureSensor"] = "temperature-sensor";
7567
+ DeviceRole["HumiditySensor"] = "humidity-sensor";
7568
+ DeviceRole["AmbientLightSensor"] = "ambient-light-sensor";
7569
+ DeviceRole["PressureSensor"] = "pressure-sensor";
7570
+ /** Wind speed or direction (weather-station `wind-sensor` cap). */
7571
+ DeviceRole["WindSensor"] = "wind-sensor";
7572
+ /** Rain accumulation or rate (weather-station `rain-sensor` cap). */
7573
+ DeviceRole["RainSensor"] = "rain-sensor";
7574
+ /** UV index (weather-station `uv-sensor` cap). */
7575
+ DeviceRole["UvSensor"] = "uv-sensor";
7576
+ /** Solar irradiance W/m² (weather-station `solar-radiation-sensor` cap).
7577
+ * Distinct from AmbientLightSensor (lux). */
7578
+ DeviceRole["SolarRadiationSensor"] = "solar-radiation-sensor";
7579
+ /** Soil moisture % (garden/weather `soil-moisture-sensor` cap). */
7580
+ DeviceRole["SoilMoistureSensor"] = "soil-moisture-sensor";
7581
+ DeviceRole["PowerSensor"] = "power-sensor";
7582
+ DeviceRole["EnergySensor"] = "energy-sensor";
7583
+ DeviceRole["VoltageSensor"] = "voltage-sensor";
7584
+ DeviceRole["CurrentSensor"] = "current-sensor";
7585
+ DeviceRole["AirQualitySensor"] = "air-quality-sensor";
7586
+ /** Battery level (numeric % via `sensor` OR low-bool via
7587
+ * `binary_sensor` — the cap distinguishes via the value type). */
7588
+ DeviceRole["BatterySensor"] = "battery-sensor";
7589
+ /** Fallback for `sensor` numeric without a known `device_class`. */
7590
+ DeviceRole["NumericSensor"] = "numeric-sensor";
7591
+ /** String / enum state (HA `sensor` with `state_class: enum` or
7592
+ * `attributes.options`). */
7593
+ DeviceRole["EnumSensor"] = "enum-sensor";
7594
+ /** Date / timestamp state (HA `sensor` with `device_class: timestamp`
7595
+ * or `date`). The slice carries the raw ISO string verbatim (hosted on
7596
+ * the `enum-sensor` cap); the UI renders it locale-formatted. */
7597
+ DeviceRole["DateTimeSensor"] = "datetime-sensor";
7598
+ /** Last-resort fallback when nothing else matches. */
7599
+ DeviceRole["GenericSensor"] = "generic-sensor";
7600
+ DeviceRole["NumericControl"] = "numeric-control";
7601
+ DeviceRole["SelectControl"] = "select-control";
7602
+ DeviceRole["TextControl"] = "text-control";
7603
+ DeviceRole["DateTimeControl"] = "datetime-control";
7604
+ /** Mobile push notifier (HA `notify.mobile_app_*`) — supports
7605
+ * rich features (image, priority, channel routing). */
7606
+ DeviceRole["MobilePushNotifier"] = "mobile-push-notifier";
7607
+ /** Chat / messaging service (HA `notify.telegram_*`,
7608
+ * `notify.discord_*`, etc.). */
7609
+ DeviceRole["MessagingNotifier"] = "messaging-notifier";
7610
+ /** Email-based delivery (HA `notify.smtp`, etc.). */
7611
+ DeviceRole["EmailNotifier"] = "email-notifier";
7612
+ /** Fallback when the notifier service name doesn't match a known
7613
+ * pattern. */
7614
+ DeviceRole["GenericNotifier"] = "generic-notifier";
7615
+ return DeviceRole;
7616
+ }({});
7617
+ /**
7618
+ import { errMsg } from '@camstack/types'
7619
+ * Extract a human-readable message from an unknown error value.
7620
+ * Replaces the ubiquitous `errMsg(err)` pattern.
7621
+ */
7622
+ function errMsg(err) {
7623
+ if (err instanceof Error) return err.message;
7624
+ if (typeof err === "string") return err;
7625
+ return String(err);
7626
+ }
7627
+ //#endregion
7628
+ //#region ../types/dist/composition-CPFIlFfw.mjs
7607
7629
  /**
7608
7630
  * Deterministic SHA-256 hash of an arbitrary serialisable value. The
7609
7631
  * canonical form sorts object keys alphabetically at every depth so two
@@ -7635,162 +7657,899 @@ function replaceWithSortedKeys(_key, value) {
7635
7657
  }
7636
7658
  return value;
7637
7659
  }
7638
- //#endregion
7639
- //#region ../types/dist/err-msg-IQTHeDzc.mjs
7640
7660
  /**
7641
- import { errMsg } from '@camstack/types'
7642
- * Extract a human-readable message from an unknown error value.
7643
- * Replaces the ubiquitous `errMsg(err)` pattern.
7661
+ * Error types for the safe expression engine. Two distinct classes so callers
7662
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
7663
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
7644
7664
  */
7645
- function errMsg(err) {
7646
- if (err instanceof Error) return err.message;
7647
- if (typeof err === "string") return err;
7648
- return String(err);
7665
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
7666
+ * the failure is anchored to a character (author-facing inline feedback). */
7667
+ var ExpressionParseError = class extends Error {
7668
+ position;
7669
+ constructor(message, position) {
7670
+ super(message);
7671
+ this.name = "ExpressionParseError";
7672
+ this.position = position;
7673
+ }
7674
+ };
7675
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
7676
+ * result, unknown builtin, step-budget exceeded). */
7677
+ var ExpressionEvalError = class extends Error {
7678
+ constructor(message) {
7679
+ super(message);
7680
+ this.name = "ExpressionEvalError";
7681
+ }
7682
+ };
7683
+ function asFiniteNumber(value, name, index) {
7684
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
7685
+ return value;
7649
7686
  }
7650
- var OWNER_TYPES = new Set([
7651
- "track",
7652
- "summary",
7653
- "face",
7654
- "identity",
7655
- "plate",
7656
- "vehicle",
7657
- "scene",
7658
- "motion",
7659
- "object",
7660
- "audio",
7661
- "mosaic"
7662
- ]);
7663
- /**
7664
- * Is this string a declared media owner type?
7665
- *
7666
- * Exported because the `event-media` plane's path parser needs it (D482): a
7667
- * path segment is an arbitrary string until something narrows it, and a
7668
- * hand-written list at the door would drift from {@link MEDIA_OWNER_TYPES} the
7669
- * day an owner kind is added — the failure this module's own docblock already
7670
- * records for `vehicle` and `scene`.
7671
- */
7672
- function isMediaOwnerType(value) {
7673
- return OWNER_TYPES.has(value);
7687
+ function asString(value, name, index) {
7688
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
7689
+ return value;
7674
7690
  }
7675
- /**
7676
- * The owner types that are event tables, in the order the event-media resolver
7677
- * should consider them. Exported so a consumer asking "is this key an event?"
7678
- * does not re-spell the list and drift from it.
7679
- */
7680
- var EVENT_OWNER_TYPES = [
7681
- "motion",
7682
- "object",
7683
- "audio"
7684
- ];
7685
- /**
7686
- * The same list as a Zod enum, for the cap inputs that must NAME the table
7687
- * (D482: `getEventMedia` / `listEventMedia` take an owner, and an owner is
7688
- * `(table, id)`).
7689
- *
7690
- * Built FROM {@link EVENT_OWNER_TYPES} rather than re-spelled: a fourth event
7691
- * table would otherwise be accepted by the codec and refused at the door, with
7692
- * nothing failing until a caller asked.
7693
- */
7694
- var EventOwnerTypeSchema = _enum(EVENT_OWNER_TYPES);
7695
- /** The same list as a Zod enum, for the cap input that carries it. */
7696
- var MediaPresenceOwnerKindSchema = _enum([...EVENT_OWNER_TYPES, "track"]);
7697
- var EVENT_OWNER_TYPE_SET = new Set(EVENT_OWNER_TYPES);
7698
- /** True when this key's owner is one of the three event tables. */
7699
- function isEventOwnerType(ownerType) {
7700
- return EVENT_OWNER_TYPE_SET.has(ownerType);
7691
+ function finiteResult(value, name) {
7692
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
7693
+ return value;
7701
7694
  }
7702
- var EncodeProfileSchema = object({
7703
- video: object({
7704
- codec: _enum([
7705
- "h264",
7706
- "h265",
7707
- "copy"
7708
- ]),
7709
- profile: _enum([
7710
- "baseline",
7711
- "main",
7712
- "high"
7713
- ]).optional(),
7714
- /**
7715
- * `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
7716
- * (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
7717
- * it, or it ships a stream that does not match its own advertisement — the
7718
- * defect class that kept HomeKit black for a year and that Alexa carried
7719
- * silently. Optional because a browser negotiates the level itself.
7720
- */
7721
- level: string().optional(),
7722
- width: number().int().positive().optional(),
7723
- height: number().int().positive().optional(),
7724
- fps: number().positive().optional(),
7725
- bitrateKbps: number().int().positive().optional(),
7726
- gopFrames: number().int().positive().optional(),
7727
- bf: number().int().min(0).optional(),
7728
- preset: _enum([
7729
- "ultrafast",
7730
- "superfast",
7731
- "veryfast",
7732
- "faster",
7733
- "fast",
7734
- "medium"
7735
- ]).optional(),
7736
- tune: _enum([
7737
- "zerolatency",
7738
- "film",
7739
- "animation"
7740
- ]).optional(),
7741
- /**
7742
- * ONE slice per access unit.
7743
- *
7744
- * `-tune zerolatency` turns on x264's sliced threads, and a frame then leaves
7745
- * the encoder as five NAL slices that share one RTP timestamp and carry one
7746
- * marker bit. A libwebrtc depacketiser sees five frame-starts and one
7747
- * frame-end per frame: the first pictures render and the video then freezes
7748
- * for good while the audio, on its own plane, plays on. Measured on this hub
7749
- * 2026-09-13 against the Echo, and reduced to one slice by this flag alone.
7750
- *
7751
- * A NAMED field and not a raw flag, because {@link EgressEncodeSchema} omits
7752
- * `outputArgs` on purpose: an opaque array is part of the sharing key, so two
7753
- * consumers meaning the same thing spelled differently would stop sharing one
7754
- * child. Absent means "whatever the encoder does" — today's behaviour.
7755
- */
7756
- singleSlicePerFrame: boolean().optional()
7757
- }),
7758
- audio: union([literal("passthrough"), object({
7759
- codec: _enum([
7760
- "opus",
7761
- "aac",
7762
- "pcmu",
7763
- "pcma",
7764
- "copy"
7765
- ]),
7766
- bitrateKbps: number().int().positive().optional(),
7767
- sampleRateHz: number().int().positive().optional(),
7768
- channels: union([literal(1), literal(2)]).optional()
7769
- })]),
7770
- /**
7771
- * ffmpeg input-side args, inserted between the fixed global flags
7772
- * (`-hide_banner -loglevel error`) and `-i pipe:0`. Free-text array
7773
- * — the widget surfaces a textarea + suggestion chips for the most-
7774
- * used demuxer/format options.
7775
- */
7776
- inputArgs: array(string()).optional(),
7777
- /**
7778
- * ffmpeg output-side args, inserted between the encode block and
7779
- * the final `-f <muxer> pipe:1`. Use for muxer options, bitstream
7780
- * filters, codec-specific overrides. Free-text array.
7781
- */
7782
- outputArgs: array(string()).optional()
7783
- });
7695
+ function allFiniteNumbers(args, name) {
7696
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
7697
+ }
7698
+ function asBoolean(value, name, index) {
7699
+ if (typeof value !== "boolean") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a boolean`);
7700
+ return value;
7701
+ }
7702
+ /** A plain decimal (optional sign, fraction, exponent) — never hex, never `Infinity`, never a unit suffix. */
7703
+ var DECIMAL = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;
7784
7704
  /**
7785
- * The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
7786
- * Baseline because it is the one profile every consumer in this repo decodes
7787
- * (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
7705
+ * `number(x)`: a finite number, or a string that IS one, as a number; every
7706
+ * other value — `''`, `'unavailable'`, a boolean, null — is `null`
7707
+ * (unavailable, never 0: D393). The one bridge from a string-valued source (an
7708
+ * HA sensor with a unit and no device_class is an `enum-sensor`) to a number.
7788
7709
  */
7789
- var BASE_LIVE_EGRESS_PROFILE = {
7790
- video: {
7791
- codec: "h264",
7792
- profile: "baseline",
7793
- level: "3.1",
7710
+ function toNumberOrNull(value) {
7711
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
7712
+ if (typeof value !== "string") return null;
7713
+ const trimmed = value.trim();
7714
+ if (!DECIMAL.test(trimmed)) return null;
7715
+ const parsed = Number(trimmed);
7716
+ return Number.isFinite(parsed) ? parsed : null;
7717
+ }
7718
+ var INF = Number.POSITIVE_INFINITY;
7719
+ var table = {
7720
+ min: {
7721
+ minArgs: 1,
7722
+ maxArgs: INF,
7723
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
7724
+ },
7725
+ max: {
7726
+ minArgs: 1,
7727
+ maxArgs: INF,
7728
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
7729
+ },
7730
+ abs: {
7731
+ minArgs: 1,
7732
+ maxArgs: 1,
7733
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
7734
+ },
7735
+ floor: {
7736
+ minArgs: 1,
7737
+ maxArgs: 1,
7738
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
7739
+ },
7740
+ ceil: {
7741
+ minArgs: 1,
7742
+ maxArgs: 1,
7743
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
7744
+ },
7745
+ sqrt: {
7746
+ minArgs: 1,
7747
+ maxArgs: 1,
7748
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
7749
+ },
7750
+ round: {
7751
+ minArgs: 1,
7752
+ maxArgs: 2,
7753
+ apply: (args) => {
7754
+ const x = asFiniteNumber(args[0], "round", 0);
7755
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
7756
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
7757
+ const factor = 10 ** digits;
7758
+ return finiteResult(Math.round(x * factor) / factor, "round");
7759
+ }
7760
+ },
7761
+ pow: {
7762
+ minArgs: 2,
7763
+ maxArgs: 2,
7764
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
7765
+ },
7766
+ clamp: {
7767
+ minArgs: 3,
7768
+ maxArgs: 3,
7769
+ apply: (args) => {
7770
+ const x = asFiniteNumber(args[0], "clamp", 0);
7771
+ const lo = asFiniteNumber(args[1], "clamp", 1);
7772
+ const hi = asFiniteNumber(args[2], "clamp", 2);
7773
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
7774
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
7775
+ }
7776
+ },
7777
+ avg: {
7778
+ minArgs: 1,
7779
+ maxArgs: INF,
7780
+ apply: (args) => {
7781
+ const nums = allFiniteNumbers(args, "avg");
7782
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
7783
+ }
7784
+ },
7785
+ sum: {
7786
+ minArgs: 1,
7787
+ maxArgs: INF,
7788
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
7789
+ },
7790
+ coalesce: {
7791
+ minArgs: 1,
7792
+ maxArgs: INF,
7793
+ apply: (args) => {
7794
+ for (const a of args) if (a !== null) return a;
7795
+ return null;
7796
+ }
7797
+ },
7798
+ age: {
7799
+ minArgs: 2,
7800
+ maxArgs: 2,
7801
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
7802
+ },
7803
+ convert: {
7804
+ minArgs: 3,
7805
+ maxArgs: 3,
7806
+ apply: (args, hooks) => {
7807
+ const x = asFiniteNumber(args[0], "convert", 0);
7808
+ const from = asString(args[1], "convert", 1).trim();
7809
+ const to = asString(args[2], "convert", 2).trim();
7810
+ if (hooks.convert) {
7811
+ const out = hooks.convert(x, from, to);
7812
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
7813
+ return finiteResult(out, "convert");
7814
+ }
7815
+ if (from === to) return x;
7816
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
7817
+ }
7818
+ },
7819
+ any: {
7820
+ minArgs: 1,
7821
+ maxArgs: INF,
7822
+ apply: (args) => args.map((a, i) => asBoolean(a, "any", i)).some((b) => b)
7823
+ },
7824
+ all: {
7825
+ minArgs: 1,
7826
+ maxArgs: INF,
7827
+ apply: (args) => args.map((a, i) => asBoolean(a, "all", i)).every((b) => b)
7828
+ },
7829
+ within: {
7830
+ minArgs: 2,
7831
+ maxArgs: 2,
7832
+ apply: (args, hooks) => {
7833
+ const windowMs = asFiniteNumber(args[1], "within", 1);
7834
+ if (windowMs < 0) throw new ExpressionEvalError("within: the window must not be negative");
7835
+ const at = args[0];
7836
+ if (at === null) return false;
7837
+ const ts = asFiniteNumber(at, "within", 0);
7838
+ const now = hooks.now;
7839
+ if (now === void 0 || !Number.isFinite(now)) throw new ExpressionEvalError("within: no clock was supplied to this evaluation");
7840
+ const inside = now - ts <= windowMs;
7841
+ if (inside) hooks.noteDeadline?.(ts + windowMs + 1);
7842
+ return inside;
7843
+ }
7844
+ },
7845
+ number: {
7846
+ minArgs: 1,
7847
+ maxArgs: 1,
7848
+ apply: (args) => toNumberOrNull(args[0])
7849
+ },
7850
+ latest: {
7851
+ minArgs: 1,
7852
+ maxArgs: INF,
7853
+ apply: (args) => {
7854
+ const present = args.flatMap((a, i) => a === null ? [] : [asFiniteNumber(a, "latest", i)]);
7855
+ return present.length === 0 ? null : finiteResult(Math.max(...present), "latest");
7856
+ }
7857
+ }
7858
+ };
7859
+ Object.freeze(Object.assign(Object.create(null), table));
7860
+ /** The set of valid builtin names — used by the parser to reject unknown
7861
+ * callees at parse time (immediate author feedback). */
7862
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
7863
+ /**
7864
+ * Resource-bound constants for the safe expression engine.
7865
+ *
7866
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
7867
+ * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
7868
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
7869
+ * work a single author-supplied expression can request, so a hostile or
7870
+ * accidental pathological string can never spend unbounded CPU/memory.
7871
+ */
7872
+ /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
7873
+ * rejected without allocation. */
7874
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
7875
+ /** A legal binding / identifier name. */
7876
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
7877
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
7878
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
7879
+ var RESERVED_BINDING_NAMES = new Set([
7880
+ "now",
7881
+ "true",
7882
+ "false",
7883
+ "null"
7884
+ ]);
7885
+ /**
7886
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
7887
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
7888
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
7889
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
7890
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
7891
+ * is a parse error with a source position, so member access / assignment /
7892
+ * template literals are lexically impossible.
7893
+ */
7894
+ var KEYWORDS = new Set([
7895
+ "true",
7896
+ "false",
7897
+ "null"
7898
+ ]);
7899
+ function isDigit(ch) {
7900
+ return ch >= "0" && ch <= "9";
7901
+ }
7902
+ function isIdentStart(ch) {
7903
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
7904
+ }
7905
+ function isIdentPart(ch) {
7906
+ return isIdentStart(ch) || isDigit(ch);
7907
+ }
7908
+ function isWhitespace(ch) {
7909
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
7910
+ }
7911
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
7912
+ * Throws `ExpressionParseError` on any illegal character or unterminated
7913
+ * string. */
7914
+ function tokenize(source) {
7915
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
7916
+ const tokens = [];
7917
+ let i = 0;
7918
+ const n = source.length;
7919
+ while (i < n) {
7920
+ const ch = source[i];
7921
+ if (isWhitespace(ch)) {
7922
+ i += 1;
7923
+ continue;
7924
+ }
7925
+ if (isDigit(ch)) {
7926
+ const start = i;
7927
+ while (i < n && isDigit(source[i])) i += 1;
7928
+ if (i < n && source[i] === ".") {
7929
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
7930
+ i += 1;
7931
+ while (i < n && isDigit(source[i])) i += 1;
7932
+ }
7933
+ const text = source.slice(start, i);
7934
+ const value = Number(text);
7935
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
7936
+ tokens.push({
7937
+ type: "number",
7938
+ value,
7939
+ pos: start
7940
+ });
7941
+ continue;
7942
+ }
7943
+ if (ch === "'" || ch === "\"") {
7944
+ const quote = ch;
7945
+ const start = i;
7946
+ i += 1;
7947
+ let out = "";
7948
+ let closed = false;
7949
+ while (i < n) {
7950
+ const c = source[i];
7951
+ if (c === "\\") {
7952
+ const next = i + 1 < n ? source[i + 1] : "";
7953
+ if (next === "\\" || next === "'" || next === "\"") {
7954
+ out += next;
7955
+ i += 2;
7956
+ continue;
7957
+ }
7958
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
7959
+ }
7960
+ if (c === quote) {
7961
+ closed = true;
7962
+ i += 1;
7963
+ break;
7964
+ }
7965
+ out += c;
7966
+ i += 1;
7967
+ }
7968
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
7969
+ tokens.push({
7970
+ type: "string",
7971
+ value: out,
7972
+ pos: start
7973
+ });
7974
+ continue;
7975
+ }
7976
+ if (isIdentStart(ch)) {
7977
+ const start = i;
7978
+ while (i < n && isIdentPart(source[i])) i += 1;
7979
+ const text = source.slice(start, i);
7980
+ if (KEYWORDS.has(text)) tokens.push({
7981
+ type: "keyword",
7982
+ keyword: keywordOf(text),
7983
+ pos: start
7984
+ });
7985
+ else tokens.push({
7986
+ type: "identifier",
7987
+ name: text,
7988
+ pos: start
7989
+ });
7990
+ continue;
7991
+ }
7992
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
7993
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
7994
+ tokens.push({
7995
+ type: "punct",
7996
+ punct: two,
7997
+ pos: i
7998
+ });
7999
+ i += 2;
8000
+ continue;
8001
+ }
8002
+ if (isSinglePunct(ch)) {
8003
+ tokens.push({
8004
+ type: "punct",
8005
+ punct: ch,
8006
+ pos: i
8007
+ });
8008
+ i += 1;
8009
+ continue;
8010
+ }
8011
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
8012
+ }
8013
+ tokens.push({
8014
+ type: "eof",
8015
+ pos: n
8016
+ });
8017
+ return tokens;
8018
+ }
8019
+ function keywordOf(text) {
8020
+ if (text === "true") return "true";
8021
+ if (text === "false") return "false";
8022
+ return "null";
8023
+ }
8024
+ function isSinglePunct(ch) {
8025
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
8026
+ }
8027
+ /**
8028
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
8029
+ *
8030
+ * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
8031
+ * → relational → additive → multiplicative → unary `! -` → call / primary.
8032
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
8033
+ * string validated against the builtin table at parse time, so an unknown
8034
+ * function is rejected immediately (author feedback) and a persisted expression
8035
+ * that references a since-removed builtin degrades at read.
8036
+ *
8037
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
8038
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
8039
+ */
8040
+ /** Binary/logical operator precedence (higher binds tighter). */
8041
+ var BINARY_PRECEDENCE = {
8042
+ "||": 1,
8043
+ "&&": 2,
8044
+ "==": 3,
8045
+ "!=": 3,
8046
+ "<": 4,
8047
+ "<=": 4,
8048
+ ">": 4,
8049
+ ">=": 4,
8050
+ "+": 5,
8051
+ "-": 5,
8052
+ "*": 6,
8053
+ "/": 6,
8054
+ "%": 6
8055
+ };
8056
+ function isLogicalOp(op) {
8057
+ return op === "&&" || op === "||";
8058
+ }
8059
+ function isBinaryOp(op) {
8060
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
8061
+ }
8062
+ var Parser = class {
8063
+ tokens;
8064
+ pos = 0;
8065
+ nodeCount = 0;
8066
+ identifiers = /* @__PURE__ */ new Set();
8067
+ callees = /* @__PURE__ */ new Set();
8068
+ constructor(tokens) {
8069
+ this.tokens = tokens;
8070
+ }
8071
+ parse() {
8072
+ const ast = this.parseTernary();
8073
+ const tok = this.peek();
8074
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
8075
+ return {
8076
+ ast,
8077
+ identifiers: this.identifiers,
8078
+ callees: this.callees,
8079
+ nodeCount: this.nodeCount
8080
+ };
8081
+ }
8082
+ peek() {
8083
+ return this.tokens[this.pos];
8084
+ }
8085
+ next() {
8086
+ return this.tokens[this.pos++];
8087
+ }
8088
+ /** Consume a punctuator token, erroring if the next token isn't it. */
8089
+ expectPunct(punct) {
8090
+ const tok = this.peek();
8091
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
8092
+ this.pos += 1;
8093
+ }
8094
+ matchPunct(punct) {
8095
+ const tok = this.peek();
8096
+ if (tok.type === "punct" && tok.punct === punct) {
8097
+ this.pos += 1;
8098
+ return true;
8099
+ }
8100
+ return false;
8101
+ }
8102
+ countNode() {
8103
+ this.nodeCount += 1;
8104
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
8105
+ }
8106
+ parseTernary() {
8107
+ const test = this.parseBinary(1);
8108
+ if (this.matchPunct("?")) {
8109
+ const consequent = this.parseTernary();
8110
+ this.expectPunct(":");
8111
+ const alternate = this.parseTernary();
8112
+ this.countNode();
8113
+ return {
8114
+ kind: "conditional",
8115
+ test,
8116
+ consequent,
8117
+ alternate
8118
+ };
8119
+ }
8120
+ return test;
8121
+ }
8122
+ parseBinary(minPrec) {
8123
+ let left = this.parseUnary();
8124
+ for (;;) {
8125
+ const tok = this.peek();
8126
+ if (tok.type !== "punct") break;
8127
+ const prec = BINARY_PRECEDENCE[tok.punct];
8128
+ if (prec === void 0 || prec < minPrec) break;
8129
+ const op = tok.punct;
8130
+ this.pos += 1;
8131
+ const right = this.parseBinary(prec + 1);
8132
+ this.countNode();
8133
+ if (isLogicalOp(op)) left = {
8134
+ kind: "logical",
8135
+ op,
8136
+ left,
8137
+ right
8138
+ };
8139
+ else if (isBinaryOp(op)) left = {
8140
+ kind: "binary",
8141
+ op,
8142
+ left,
8143
+ right
8144
+ };
8145
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
8146
+ }
8147
+ return left;
8148
+ }
8149
+ parseUnary() {
8150
+ const tok = this.peek();
8151
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
8152
+ const op = tok.punct;
8153
+ this.pos += 1;
8154
+ const operand = this.parseUnary();
8155
+ this.countNode();
8156
+ return {
8157
+ kind: "unary",
8158
+ op,
8159
+ operand
8160
+ };
8161
+ }
8162
+ return this.parsePrimary();
8163
+ }
8164
+ parsePrimary() {
8165
+ const tok = this.next();
8166
+ switch (tok.type) {
8167
+ case "number":
8168
+ this.countNode();
8169
+ return {
8170
+ kind: "literal",
8171
+ value: tok.value
8172
+ };
8173
+ case "string":
8174
+ this.countNode();
8175
+ return {
8176
+ kind: "literal",
8177
+ value: tok.value
8178
+ };
8179
+ case "keyword":
8180
+ this.countNode();
8181
+ return {
8182
+ kind: "literal",
8183
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
8184
+ };
8185
+ case "identifier": {
8186
+ const nextTok = this.peek();
8187
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
8188
+ this.identifiers.add(tok.name);
8189
+ this.countNode();
8190
+ return {
8191
+ kind: "identifier",
8192
+ name: tok.name
8193
+ };
8194
+ }
8195
+ case "punct":
8196
+ if (tok.punct === "(") {
8197
+ const inner = this.parseTernary();
8198
+ this.expectPunct(")");
8199
+ return inner;
8200
+ }
8201
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
8202
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
8203
+ }
8204
+ }
8205
+ parseCall(callee, pos) {
8206
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
8207
+ this.expectPunct("(");
8208
+ const args = [];
8209
+ if (!this.matchPunct(")")) for (;;) {
8210
+ args.push(this.parseTernary());
8211
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
8212
+ if (this.matchPunct(",")) continue;
8213
+ this.expectPunct(")");
8214
+ break;
8215
+ }
8216
+ this.callees.add(callee);
8217
+ this.countNode();
8218
+ return {
8219
+ kind: "call",
8220
+ callee,
8221
+ args
8222
+ };
8223
+ }
8224
+ };
8225
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
8226
+ * `ExpressionParseError` on any lexical or grammatical failure. */
8227
+ function parseExpression(source) {
8228
+ return new Parser(tokenize(source)).parse();
8229
+ }
8230
+ /**
8231
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
8232
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
8233
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
8234
+ * one per read on a hot resolve path.
8235
+ *
8236
+ * The cache is a module-level singleton: entries are pure, content-addressed
8237
+ * ASTs keyed by the raw source string, so sharing one instance across all
8238
+ * callers is safe and maximises hit rate.
8239
+ */
8240
+ var cache = /* @__PURE__ */ new Map();
8241
+ function getCached(source) {
8242
+ const hit = cache.get(source);
8243
+ if (hit !== void 0) {
8244
+ cache.delete(source);
8245
+ cache.set(source, hit);
8246
+ return hit;
8247
+ }
8248
+ let result;
8249
+ try {
8250
+ result = {
8251
+ ok: true,
8252
+ parsed: parseExpression(source)
8253
+ };
8254
+ } catch (err) {
8255
+ result = {
8256
+ ok: false,
8257
+ error: err instanceof ExpressionParseError ? err.message : String(err)
8258
+ };
8259
+ }
8260
+ cache.set(source, result);
8261
+ if (cache.size > 256) {
8262
+ const oldest = cache.keys().next().value;
8263
+ if (oldest !== void 0) cache.delete(oldest);
8264
+ }
8265
+ return result;
8266
+ }
8267
+ /** Compile `source`, returning a discriminated result instead of throwing.
8268
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
8269
+ function compileExpressionSafe(source) {
8270
+ return getCached(source);
8271
+ }
8272
+ Object.freeze({});
8273
+ /**
8274
+ * Author-time validation. Returns `null` when the source is valid, else a
8275
+ * human-readable error message. Checks: the expression compiles; binding count
8276
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
8277
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
8278
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
8279
+ */
8280
+ function validateExpressionSource(src) {
8281
+ const names = Object.keys(src.bindings);
8282
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
8283
+ for (const name of names) {
8284
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
8285
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
8286
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
8287
+ }
8288
+ const compiled = compileExpressionSafe(src.expr);
8289
+ if (!compiled.ok) return compiled.error;
8290
+ const bound = new Set(names);
8291
+ for (const id of compiled.parsed.identifiers) {
8292
+ if (id === "now") continue;
8293
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
8294
+ }
8295
+ return null;
8296
+ }
8297
+ /** A composed device's stableId is this prefix plus the block id. */
8298
+ var COMPOSED_DEVICE_STABLE_ID_PREFIX = "composed-";
8299
+ /** Longest snippet a `code` source may carry (slice 4). */
8300
+ var MAX_COMPOSITION_CODE_LENGTH = 2e4;
8301
+ var CompositionSourceRefSchema = object({
8302
+ /** The owning addon; `stableId` is unique only within it. */
8303
+ addonId: string().min(1),
8304
+ stableId: string().min(1)
8305
+ });
8306
+ /** One field of one capability of one source device. */
8307
+ var CompositionFieldReadSchema = object({
8308
+ source: CompositionSourceRefSchema,
8309
+ cap: string().min(1),
8310
+ /** Dotted path into the source cap's runtime-state slice. */
8311
+ fieldPath: string().min(1)
8312
+ });
8313
+ /** `from`: copy one source field verbatim. It is also an expression's `from` binding. */
8314
+ var CompositionFromSourceSchema = CompositionFieldReadSchema.extend({ kind: literal("from") });
8315
+ var CompositionBindingSchema = discriminatedUnion("kind", [CompositionFromSourceSchema, object({
8316
+ kind: literal("literal"),
8317
+ value: union([
8318
+ string(),
8319
+ number(),
8320
+ boolean(),
8321
+ _null()
8322
+ ])
8323
+ })]);
8324
+ /**
8325
+ * `expression`: a formula over named bindings, in the salvaged expression
8326
+ * engine. Validated at parse by the SAME function every other consumer runs,
8327
+ * so the editor and the store cannot disagree.
8328
+ */
8329
+ var CompositionExpressionSourceSchema = object({
8330
+ kind: literal("expression"),
8331
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
8332
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), CompositionBindingSchema)
8333
+ }).superRefine((src, ctx) => {
8334
+ const err = validateExpressionSource(src);
8335
+ if (err !== null) ctx.addIssue({
8336
+ code: "custom",
8337
+ message: err,
8338
+ path: ["expr"]
8339
+ });
8340
+ });
8341
+ /** `code`: RESERVED for slice 4 (own runner, one-way eject). */
8342
+ var CompositionCodeSourceSchema = object({
8343
+ kind: literal("code"),
8344
+ code: string().min(1).max(MAX_COMPOSITION_CODE_LENGTH)
8345
+ });
8346
+ var CompositionFieldSourceSchema = discriminatedUnion("kind", [
8347
+ CompositionFromSourceSchema,
8348
+ CompositionExpressionSourceSchema,
8349
+ CompositionCodeSourceSchema
8350
+ ]);
8351
+ var CompositionCommandTargetSchema = discriminatedUnion("kind", [object({
8352
+ kind: literal("forward"),
8353
+ source: CompositionSourceRefSchema,
8354
+ cap: string().min(1),
8355
+ method: string().min(1)
8356
+ }), CompositionCodeSourceSchema]);
8357
+ /** An item key inside an item-array cap (`consumables.items[<key>]`). */
8358
+ var COMPOSITION_ITEM_KEY_RE = /^[a-z0-9][a-z0-9-]{0,47}$/;
8359
+ /**
8360
+ * One item of an item-array cap (D663), addressed through the cap's
8361
+ * `status.itemArray` descriptor. `fields` are paths INSIDE the item, nested
8362
+ * allowed (`remaining.value`); the key and label come from the entry itself.
8363
+ */
8364
+ var CompositionItemEntrySchema = object({
8365
+ label: string().min(1).max(80),
8366
+ fields: record(string().min(1), CompositionFieldSourceSchema)
8367
+ });
8368
+ var CompositionFeatureSchema = discriminatedUnion("kind", [object({
8369
+ kind: literal("fields"),
8370
+ cap: string().min(1),
8371
+ fields: record(string().min(1), CompositionFieldSourceSchema).refine((fields) => Object.keys(fields).length <= 32, { message: `at most 32 fields per capability` }),
8372
+ /**
8373
+ * Items of the cap's item array, keyed by item key (D663). Naming `items`
8374
+ * owns the WHOLE array field: the composed value holds exactly these items.
8375
+ */
8376
+ items: record(string().regex(COMPOSITION_ITEM_KEY_RE), CompositionItemEntrySchema).refine((items) => Object.keys(items).length <= 16, { message: `at most 16 items` }).optional(),
8377
+ /** RESERVED for slice 3; refused by the validator when non-empty. */
8378
+ commands: record(string().min(1), CompositionCommandTargetSchema).optional()
8379
+ }), object({
8380
+ kind: literal("passthrough"),
8381
+ cap: string().min(1),
8382
+ source: CompositionSourceRefSchema
8383
+ })]);
8384
+ var CompositionSchema = object({
8385
+ target: discriminatedUnion("kind", [object({
8386
+ kind: literal("new"),
8387
+ type: _enum(DeviceType),
8388
+ role: _enum(DeviceRole).optional()
8389
+ }), object({
8390
+ kind: literal("existing"),
8391
+ device: CompositionSourceRefSchema
8392
+ })]),
8393
+ features: array(CompositionFeatureSchema).min(1).max(16)
8394
+ }).superRefine((composition, ctx) => {
8395
+ const seen = /* @__PURE__ */ new Set();
8396
+ composition.features.forEach((feature, index) => {
8397
+ if (seen.has(feature.cap)) ctx.addIssue({
8398
+ code: "custom",
8399
+ message: `capability \`${feature.cap}\` is composed twice — one feature per capability`,
8400
+ path: [
8401
+ "features",
8402
+ index,
8403
+ "cap"
8404
+ ]
8405
+ });
8406
+ seen.add(feature.cap);
8407
+ });
8408
+ });
8409
+ var OWNER_TYPES = new Set([
8410
+ "track",
8411
+ "summary",
8412
+ "face",
8413
+ "identity",
8414
+ "plate",
8415
+ "vehicle",
8416
+ "scene",
8417
+ "motion",
8418
+ "object",
8419
+ "audio",
8420
+ "mosaic"
8421
+ ]);
8422
+ /**
8423
+ * Is this string a declared media owner type?
8424
+ *
8425
+ * Exported because the `event-media` plane's path parser needs it (D482): a
8426
+ * path segment is an arbitrary string until something narrows it, and a
8427
+ * hand-written list at the door would drift from {@link MEDIA_OWNER_TYPES} the
8428
+ * day an owner kind is added — the failure this module's own docblock already
8429
+ * records for `vehicle` and `scene`.
8430
+ */
8431
+ function isMediaOwnerType(value) {
8432
+ return OWNER_TYPES.has(value);
8433
+ }
8434
+ /**
8435
+ * The owner types that are event tables, in the order the event-media resolver
8436
+ * should consider them. Exported so a consumer asking "is this key an event?"
8437
+ * does not re-spell the list and drift from it.
8438
+ */
8439
+ var EVENT_OWNER_TYPES = [
8440
+ "motion",
8441
+ "object",
8442
+ "audio"
8443
+ ];
8444
+ /**
8445
+ * The same list as a Zod enum, for the cap inputs that must NAME the table
8446
+ * (D482: `getEventMedia` / `listEventMedia` take an owner, and an owner is
8447
+ * `(table, id)`).
8448
+ *
8449
+ * Built FROM {@link EVENT_OWNER_TYPES} rather than re-spelled: a fourth event
8450
+ * table would otherwise be accepted by the codec and refused at the door, with
8451
+ * nothing failing until a caller asked.
8452
+ */
8453
+ var EventOwnerTypeSchema = _enum(EVENT_OWNER_TYPES);
8454
+ /** The same list as a Zod enum, for the cap input that carries it. */
8455
+ var MediaPresenceOwnerKindSchema = _enum([...EVENT_OWNER_TYPES, "track"]);
8456
+ var EVENT_OWNER_TYPE_SET = new Set(EVENT_OWNER_TYPES);
8457
+ /** True when this key's owner is one of the three event tables. */
8458
+ function isEventOwnerType(ownerType) {
8459
+ return EVENT_OWNER_TYPE_SET.has(ownerType);
8460
+ }
8461
+ var EncodeProfileSchema = object({
8462
+ video: object({
8463
+ codec: _enum([
8464
+ "h264",
8465
+ "h265",
8466
+ "copy"
8467
+ ]),
8468
+ profile: _enum([
8469
+ "baseline",
8470
+ "main",
8471
+ "high"
8472
+ ]).optional(),
8473
+ /**
8474
+ * `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
8475
+ * (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
8476
+ * it, or it ships a stream that does not match its own advertisement — the
8477
+ * defect class that kept HomeKit black for a year and that Alexa carried
8478
+ * silently. Optional because a browser negotiates the level itself.
8479
+ */
8480
+ level: string().optional(),
8481
+ width: number().int().positive().optional(),
8482
+ height: number().int().positive().optional(),
8483
+ fps: number().positive().optional(),
8484
+ bitrateKbps: number().int().positive().optional(),
8485
+ gopFrames: number().int().positive().optional(),
8486
+ bf: number().int().min(0).optional(),
8487
+ preset: _enum([
8488
+ "ultrafast",
8489
+ "superfast",
8490
+ "veryfast",
8491
+ "faster",
8492
+ "fast",
8493
+ "medium"
8494
+ ]).optional(),
8495
+ tune: _enum([
8496
+ "zerolatency",
8497
+ "film",
8498
+ "animation"
8499
+ ]).optional(),
8500
+ /**
8501
+ * ONE slice per access unit.
8502
+ *
8503
+ * `-tune zerolatency` turns on x264's sliced threads, and a frame then leaves
8504
+ * the encoder as five NAL slices that share one RTP timestamp and carry one
8505
+ * marker bit. A libwebrtc depacketiser sees five frame-starts and one
8506
+ * frame-end per frame: the first pictures render and the video then freezes
8507
+ * for good while the audio, on its own plane, plays on. Measured on this hub
8508
+ * 2026-09-13 against the Echo, and reduced to one slice by this flag alone.
8509
+ *
8510
+ * A NAMED field and not a raw flag, because {@link EgressEncodeSchema} omits
8511
+ * `outputArgs` on purpose: an opaque array is part of the sharing key, so two
8512
+ * consumers meaning the same thing spelled differently would stop sharing one
8513
+ * child. Absent means "whatever the encoder does" — today's behaviour.
8514
+ */
8515
+ singleSlicePerFrame: boolean().optional()
8516
+ }),
8517
+ audio: union([literal("passthrough"), object({
8518
+ codec: _enum([
8519
+ "opus",
8520
+ "aac",
8521
+ "pcmu",
8522
+ "pcma",
8523
+ "copy"
8524
+ ]),
8525
+ bitrateKbps: number().int().positive().optional(),
8526
+ sampleRateHz: number().int().positive().optional(),
8527
+ channels: union([literal(1), literal(2)]).optional()
8528
+ })]),
8529
+ /**
8530
+ * ffmpeg input-side args, inserted between the fixed global flags
8531
+ * (`-hide_banner -loglevel error`) and `-i pipe:0`. Free-text array
8532
+ * — the widget surfaces a textarea + suggestion chips for the most-
8533
+ * used demuxer/format options.
8534
+ */
8535
+ inputArgs: array(string()).optional(),
8536
+ /**
8537
+ * ffmpeg output-side args, inserted between the encode block and
8538
+ * the final `-f <muxer> pipe:1`. Use for muxer options, bitstream
8539
+ * filters, codec-specific overrides. Free-text array.
8540
+ */
8541
+ outputArgs: array(string()).optional()
8542
+ });
8543
+ /**
8544
+ * The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
8545
+ * Baseline because it is the one profile every consumer in this repo decodes
8546
+ * (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
8547
+ */
8548
+ var BASE_LIVE_EGRESS_PROFILE = {
8549
+ video: {
8550
+ codec: "h264",
8551
+ profile: "baseline",
8552
+ level: "3.1",
7794
8553
  width: 1280,
7795
8554
  height: 720,
7796
8555
  fps: 25,
@@ -9508,1504 +10267,888 @@ function isStorageLocationMode(value) {
9508
10267
  /** May this location be written to? */
9509
10268
  function mayWriteToLocation(location) {
9510
10269
  return modeMayWrite(resolveLocationMode(location));
9511
- }
9512
- /** What eviction may do to this location. */
9513
- function evictionPolicyOfLocation(location) {
9514
- return evictionPolicyForMode(resolveLocationMode(location));
9515
- }
9516
- /**
9517
- * `StorageLocationType` — an addon-declared id that identifies the *kind* of
9518
- * storage a location serves. Defined here (not in `capabilities/storage.cap.ts`)
9519
- * so the persisted record schema and the consumer-facing cap can both consume it
9520
- * without forming a circular import. The `storage` cap re-exports it
9521
- * verbatim for back-compat.
9522
- *
9523
- * This Zod schema is the **authoritative source** for `StorageLocationType`.
9524
- * The TS alias in `./storage.ts` re-exports `z.infer<typeof
9525
- * StorageLocationTypeSchema>` so the wire surface (cap) and the legacy
9526
- * `IStorageProvider` interface stay in lockstep.
9527
- *
9528
- * The type is now an **open string** (not a closed enum) — addons declare
9529
- * their own location kinds via `StorageLocationDeclaration.id`. The regex
9530
- * enforces a safe id format: lowercase-start, alphanumeric + hyphens.
9531
- */
9532
- var StorageLocationTypeSchema = string().regex(/^[a-z][a-zA-Z0-9-]*$/);
9533
- /**
9534
- * Persisted record for a storage location instance. Operators can register
9535
- * multiple instances for multi-cardinality types (e.g. two `backups`
9536
- * locations with different `providerId`s). Cardinality is now declared per
9537
- * location via `StorageLocationDeclaration.cardinality` — the static
9538
- * `STORAGE_LOCATION_CARDINALITY` map has been removed.
9539
- *
9540
- * `id` is a stable namespaced string of the form `<type>:<slug>`.
9541
- * The seed names its first instance `<type>:default` — a NAME, not a flag.
9542
- * There is no default location any more (D383): `enabled` is the whole write
9543
- * model, and a bare type ref resolves to the sole location of the type, or —
9544
- * transitionally, only while legacy NULL-stamped rows exist — to the row whose
9545
- * slug is `default`.
9546
- *
9547
- * `isSystem` is a legacy persisted flag. Seed still creates the initial
9548
- * `<type>:default` locations; the flag is no longer a lock, a badge, or a
9549
- * prune selector. New writes leave it false. Deletion is gated on uniqueness
9550
- * / last-enabled, not on this bit.
9551
- */
9552
- var StorageLocationSchema = object({
9553
- id: string().regex(/^[a-z][a-zA-Z0-9-]*:[a-zA-Z0-9-]+$/),
9554
- type: string(),
9555
- displayName: string().min(1),
9556
- providerId: string().min(1),
9557
- config: record(string(), unknown()),
9558
- /**
9559
- * Cluster node this location physically lives on. REQUIRED for node-local
9560
- * providers (filesystem — the path exists on one node's disk), null/absent
9561
- * for node-agnostic providers (S3/SFTP/WebDAV, reachable from any node).
9562
- * `'hub'` is the hub node. Validated against the provider's `nodeLocal`
9563
- * flag at upsert time, not here (the schema is provider-agnostic).
9564
- */
9565
- nodeId: string().optional(),
9566
- isSystem: boolean().default(false),
9567
- /**
9568
- * THE write switch, and the only one (D383). `enabled: true` means every
9569
- * consumer that chooses a write target for this type may write here, and all
9570
- * enabled locations of a type are used TOGETHER; `false` means read-only —
9571
- * still read, still played back, still age-swept, still drained, never
9572
- * written.
9573
- *
9574
- * OPTIONAL only for the wire: an upsert that omits it means "leave what is
9575
- * stored" on an update and "born inert unless it is the first location of its
9576
- * type" on a create. On a PERSISTED row absence is legacy and it means
9577
- * enabled — {@link isLocationEnabled} is the one place that says so, and the
9578
- * orchestrator stamps every flagless row `true` once at hydrate so absence
9579
- * stops existing rather than being re-derived on every read.
9580
- */
9581
- enabled: boolean().optional(),
9582
- /**
9583
- * THE state of this location (D385), and the only authority on what may be
9584
- * written, read or evicted here. Interpreted in exactly one place —
9585
- * `storage-location-mode.ts` — which also folds the legacy
9586
- * `enabled` / `config.readOnly` pair into a mode so an old row is never
9587
- * ambiguous.
9588
- *
9589
- * OPTIONAL only for the wire and for rows written before D385: absence is
9590
- * resolved by `resolveLocationMode`, and the orchestrator stamps every
9591
- * unstamped row ONCE at hydrate so absence stops existing rather than being
9592
- * re-derived on every read. `enabled` survives one release as a DERIVED
9593
- * mirror (`mode === 'active'`); `withLocationMode` is the only writer of
9594
- * either, so the two cannot disagree.
9595
- */
9596
- mode: StorageLocationModeSchema.optional(),
9597
- /** COMPUTED at read time by the orchestrator (statfs of the backing volume
9598
- * for node-local locations it can reach) — never persisted, absent when the
9599
- * volume is remote/unreachable. The single capacity truth every UI reads. */
9600
- capacity: object({
9601
- totalBytes: number(),
9602
- availableBytes: number()
9603
- }).nullable().optional(),
9604
- /**
9605
- * How much of that volume CamStack ITSELF holds on this location (D388) —
9606
- * COMPUTED at read time from the `storage-occupancy` providers' own figures,
9607
- * never persisted, never a filesystem walk.
9608
- *
9609
- * **ABSENT MEANS UNKNOWN, never zero.** No provider has reported for this
9610
- * location yet — nobody stores here, the owning addon is down, or the first
9611
- * refresh has not completed. A UI must omit the segment rather than draw it
9612
- * at zero, which would claim we occupy nothing (D315). It is an OBJECT and
9613
- * not a bare number precisely so that a `?? 0` on the consuming side has to
9614
- * be spelled out loud instead of appearing by accident.
9615
- *
9616
- * `measuredAtMs` is the OLDEST contributing measurement, so it is honest
9617
- * about the whole figure rather than about its freshest part.
9618
- */
9619
- owned: object({
9620
- bytes: number().int().nonnegative(),
9621
- measuredAtMs: number().int().nonnegative()
9622
- }).optional(),
9623
- createdAt: number(),
9624
- updatedAt: number()
9625
- });
9626
- object({ isDefault: boolean().optional() });
9627
- /**
9628
- * How far a `drain` has got (D386) — the read a UI renders, and nothing more.
9629
- *
9630
- * `estimatedEmptyAtMs` is derived from the growth the ratchet has actually
9631
- * OBSERVED and is `null` when it has observed none. Never a fabricated date: a
9632
- * drain with no observed growth has no honest ETA, and inventing one is how an
9633
- * operator learns not to believe the screen.
9634
- */
9635
- var StorageDrainProgressSchema = object({
9636
- locationId: string(),
9637
- startedAtMs: number(),
9638
- startBytes: number(),
9639
- bytesRemaining: number(),
9640
- drained: boolean(),
9641
- estimatedEmptyAtMs: number().nullable()
9642
- });
9643
- /**
9644
- * Reference accepted by consumer-facing `api.storage.*` calls.
9645
- * Either:
9646
- * - a `StorageLocationType` (e.g. `'backups'`) → the sole location of that type
9647
- * (transitionally, the `<type>:default`-slugged row when several exist)
9648
- * - a fully-qualified id (e.g. `'backups:nas-01'`) → addresses a specific instance
9649
- *
9650
- * The orchestrator's `resolveRef(ref)` handles both cases.
9651
- */
9652
- var StorageLocationRefSchema = union([StorageLocationTypeSchema, string().regex(/^[a-z][a-zA-Z0-9-]*:[a-zA-Z0-9-]+$/)]);
9653
- /**
9654
- * `StorageLocationDeclaration` — a single storage-location entry declared by
9655
- * an addon in its `package.json` under `camstack.storageLocations`.
9656
- *
9657
- * Design intent:
9658
- * - **Addon declares its needs** — each addon describes the logical storage
9659
- * slots it requires (e.g. `recordings`, `recordingsLow`) without caring
9660
- * about the physical path.
9661
- * - **Kernel aggregates** — at boot the kernel collects declarations from all
9662
- * installed addons, deduplicates by `id`, and exposes the union via the
9663
- * storage-locations settings surface.
9664
- * - **Orchestrator seeds** — for every declared `id` the orchestrator ensures
9665
- * at least one instance named `<id>:default` is present, using
9666
- * `defaultsTo` to inherit the resolved root from another location when the
9667
- * declaration is a derivative slot (e.g. `recordingsLow` defaults to
9668
- * `recordings`).
9669
- * - **ids are global** — `id` values are shared across the entire deployment;
9670
- * two addons declaring the same `id` must agree on `cardinality` (validated
9671
- * at kernel aggregation time, not here).
9672
- */
9673
- /**
9674
- * `StorageAccess` — how the service that DECLARED a storage-location kind
9675
- * actually reaches the bytes. It is the constraint that decides which
9676
- * `storage-provider`s may back a location of that kind.
9677
- *
9678
- * - `'local-path'` — the service asks `storage.resolve` for a path string and
9679
- * then does its own `node:fs` I/O on it (the recorder's segment writer, the
9680
- * post-analysis media roots). Only a provider that serves a genuine local
9681
- * filesystem (`getProviderInfo().nodeLocal === true`) can satisfy that: a
9682
- * remote provider's `resolve` returns a path on the REMOTE host, and
9683
- * `fs.readdir` of it on this node either fails or — far worse — succeeds
9684
- * against a same-named local directory that is something else entirely.
9685
- *
9686
- * - `'cap-mediated'` — every byte travels through the `storage` cap
9687
- * (`read`/`write`, or `beginUpload`/`writeChunk`/`finalizeUpload`). The
9688
- * service never sees a path, so any provider can back it. `backups` is the
9689
- * one kind that qualifies today.
9690
- *
9691
- * Before this existed, `recordings` was unreachable by SFTP/S3/WebDAV only as
9692
- * an EMERGENT property of how the recorder happened to be written. Nothing
9693
- * refused the configuration; the first write simply went somewhere wrong, and
9694
- * a recording write that goes wrong surfaces as a silent black window rather
9695
- * than an error (the read path does not `stat`). This turns that accident into
9696
- * a declared, enforced, testable refusal.
9697
- */
9698
- var StorageAccessSchema = _enum(["local-path", "cap-mediated"]);
9699
- var StorageLocationDeclarationSchema = object({
9700
- /**
9701
- * Global location identifier, e.g. `recordings` or `recordingsLow`.
9702
- * Must start with a lowercase letter and may contain letters, digits, and
9703
- * hyphens.
9704
- */
9705
- id: string().regex(/^[a-z][a-zA-Z0-9-]*$/, { message: "id must start with a lowercase letter and contain only letters, digits, or hyphens" }),
9706
- /** Human-readable name shown in the admin UI. */
9707
- displayName: string().min(1, { message: "displayName must not be empty" }),
9708
- /** Optional longer explanation of what data this location stores. */
9709
- description: string().optional(),
9710
- /**
9711
- * `single` — exactly one instance of this location is allowed system-wide
9712
- * (e.g. `logs`, `models`). The operator can edit it but not add more.
9713
- * `multi` — the operator may register several instances (e.g. a second
9714
- * `recordings` on a NAS for disk tiering); one is the default at any time.
9715
- */
9716
- cardinality: _enum(["single", "multi"]),
9717
- /**
9718
- * HOW the declaring service reaches the bytes — and therefore WHICH
9719
- * providers may back a location of this kind. See {@link StorageAccessSchema}
9720
- * and {@link STORAGE_ACCESS_FALLBACK}.
9721
- *
9722
- * Absent means `'local-path'`. That default is FAIL-CLOSED on purpose: it
9723
- * can only over-restrict (refuse a remote provider for a kind that might
9724
- * have coped) and never under-restrict. Declaring `'cap-mediated'` is the
9725
- * permissive direction and is therefore never inferred — a repo guard
9726
- * (`scripts/check-storage-access-declarations.ts`) refuses to let it be
9727
- * reached by omission.
9728
- */
9729
- access: StorageAccessSchema.optional(),
9730
- /**
9731
- * When set, the default instance for this location inherits its resolved
9732
- * root from the named location's default instance. Useful for derivative
9733
- * slots (e.g. `recordingsLow` → `recordings`) so operators only need to
9734
- * configure the primary location.
9735
- */
9736
- defaultsTo: string().optional(),
9737
- /**
9738
- * Which node root the seeded `<id>:default` instance is placed under on a
9739
- * FRESH install:
9740
- * - `'data'` (default) — the node's data dir (`CAMSTACK_DATA` / boot dir),
9741
- * the appData volume. Right for small/durable data (logs, models).
9742
- * - `'media'` — the dedicated media volume (`CAMSTACK_MEDIA_ROOT`) when that
9743
- * env is set, else falls back to the data root. Right for bulky, hot media
9744
- * (recordings, event media) that should stay off the appData disk.
9745
- * - `'backup'` — the dedicated backup volume (`CAMSTACK_BACKUP_ROOT`, default
9746
- * `/backups` in the image) so archives live on their own mount rather than
9747
- * filling the appData disk. Falls back to the data root when unset.
9748
- *
9749
- * Only affects the seeded default's `basePath`; operators can repoint any
9750
- * location afterwards, and a `defaultsTo` slot inherits its parent's root
9751
- * regardless of this field. Absent (the common case) is treated as `'data'`.
9752
- */
9753
- defaultRoot: _enum([
9754
- "data",
9755
- "media",
9756
- "backup"
9757
- ]).optional()
9758
- });
9759
- var DecoderStatsSchema = object({
9760
- inputFps: number(),
9761
- outputFps: number(),
9762
- avgDecodeTimeMs: number(),
9763
- droppedFrames: number(),
9764
- /**
9765
- * Pull-mode adaptive-fps telemetry (optional — only pull sessions run the
9766
- * lag-driven controller; push sessions omit these). `lagMs` is the EWMA of
9767
- * the decoder's real-time drift (rising = falling behind live); `adaptiveFps`
9768
- * is the current lag-throttled emit rate (≤ `effectiveFps` ceiling).
9769
- */
9770
- lagMs: number().optional(),
9771
- effectiveFps: number().optional(),
9772
- adaptiveFps: number().optional()
9773
- });
9774
- var DecoderSessionConfigSchema = object({
9775
- codec: string(),
9776
- maxFps: number().default(0),
9777
- outputFormat: _enum([
9778
- "jpeg",
9779
- "rgb",
9780
- "bgr",
9781
- "yuv420",
9782
- "gray"
9783
- ]).default("jpeg"),
9784
- scale: number().default(1),
9785
- width: number().optional(),
9786
- height: number().optional(),
9787
- /**
9788
- * Identifier of the camera this decoder session serves. Optional
9789
- * because the cap is generic (any caller could request decode), but
9790
- * stream-broker passes it so decoder logs include `deviceId` for
9791
- * per-camera filtering when diagnosing failures (e.g. node-av
9792
- * sendPacket errors on a single hung camera).
9793
- */
9794
- deviceId: number().int().nonnegative().optional(),
9795
- /**
9796
- * Free-form tag for log scoping. Stream-broker uses
9797
- * `broker:<deviceId>/<profile>`. Decoder session logger surfaces it
9798
- * on every line so `grep tag=broker:5/high` filters one camera
9799
- * profile cleanly.
9800
- */
9801
- tag: string().optional(),
9802
- /**
9803
- * Where the session delivers decoded frames (Phase 5 / D9):
9804
- *
9805
- * - `'callback'` (default) — the legacy pixel path: decoded frames are
9806
- * buffered as `DecodedFrame`s and drained via `pullFrames`.
9807
- * - `'shm'` — the shared-memory frame plane: decoded frames are written
9808
- * into an OS shared-memory ring and drained as zero-pixel
9809
- * `FrameHandle`s via `pullHandles`. A session is one mode or the
9810
- * other — `pullFrames` returns nothing for an `'shm'` session and
9811
- * `pullHandles` returns nothing for a `'callback'` session.
9812
- */
9813
- frameSink: _enum(["callback", "shm"]).default("callback"),
9814
- /**
9815
- * Per-camera decoder DEBUG facility. When `true`, a pull-mode session emits
9816
- * a throttled (~1Hz) structured `decoder debug` line (effective/adaptive fps,
9817
- * real-time lag, dropped-frame delta, avg decode time, hwaccel). Mirrors the
9818
- * stream-broker's `streamingDebug` gate — off by default so production logs
9819
- * stay quiet and the emit path pays zero per-frame cost when disabled.
9820
- */
9821
- debug: boolean().optional()
9822
- });
9823
- /**
9824
- * Per-component log CHANNELS — the gate a hot path consults, and the registry
9825
- * an addon declares its channels in.
9826
- *
9827
- * ## Two axes, deliberately separated
9828
- *
9829
- * - **DECLARATION** — which channels exist. Only the addon knows:
9830
- * `stream-broker` knows webrtc/ICE/RTP, `provider-reolink` knows
9831
- * baichuan/handshake. A hand-wired central list rots at the first addition,
9832
- * and rots silently. So a channel is declared where it is consulted, and the
9833
- * `log-channels` capability enumerates the declarations.
9834
- * - **VALUE** — at which level, for which scope, until when. That stays ONE
9835
- * thing: the logging settings document on the `system` cap. Two authorities
9836
- * over the values is the exact defect
9837
- * `docs/design/plans/2026-08-26-logging-per-componente.md` was written to
9838
- * remove; re-introducing it from the cure side would be grotesque.
9839
- *
9840
- * Nothing in this file reads a clock, an env var or a store. The registry is
9841
- * a MIRROR: it is moved only by {@link LogChannelRegistry.apply}, called off
9842
- * the hot path with a value somebody actually read, and by
9843
- * {@link LogChannelRegistry.tick}, called on a timer. A store read that fails
9844
- * never reaches here, so it can neither disarm an armed channel nor arm a
9845
- * disarmed one (D49).
9846
- *
9847
- * ## The canonical call shape
9848
- *
9849
- * ```ts
9850
- * if (CH_RTP.on && CH_RTP.wants(deviceId)) {
9851
- * CH_RTP.log(logger, 'rtp subscriber added', { tags: { deviceId }, meta: { ssrc } })
9852
- * }
9853
- * ```
9854
- *
9855
- * `on` is a plain boolean FIELD — never a getter — and it is the FIRST thing
9856
- * read. Disarmed, a call site costs one load and one branch, and the `extras`
9857
- * object literal is never constructed because it lives inside the branch. It
9858
- * is the same shape already proven in production at `stream-broker.ts:1650`,
9859
- * and the same discipline `LoggingGate.allowsDestination` uses for the
9860
- * destination floor (measured at 1.93 ns/call when off).
9861
- *
9862
- * ## Why a channel emits at `info`
9863
- *
9864
- * `loki-logging.addon.ts` pins the destination default at `info` and
9865
- * `loki-destination.ts` drops everything below it, so a line emitted at
9866
- * `debug` never reaches Loki and the hub's in-memory ring only holds ~35
9867
- * minutes. A diagnostic that cannot be read an hour later is worse than no
9868
- * diagnostic, because it looks done. {@link LogChannelGate.log} therefore
9869
- * emits at the channel's declared level, whose schema floor is `info`.
9870
- */
9871
- /**
9872
- * The level a channel writes at once armed.
9873
- *
9874
- * `debug` is absent ON PURPOSE and not by omission: below `info` the line does
9875
- * not leave the process for Loki, and the whole point of arming a channel is
9876
- * to read it later.
9877
- */
9878
- var LogChannelLevelSchema = _enum([
9879
- "info",
9880
- "warn",
9881
- "error"
9882
- ]);
9883
- /**
9884
- * What an addon declares about one channel. No value, no state — a
9885
- * declaration is inert.
9886
- */
9887
- var LogChannelDescriptorSchema = object({
9888
- /**
9889
- * Dotted `area.thing`, unique across the workspace. `area` is conventionally
9890
- * the addon's short name so an operator reading a channel list can tell who
9891
- * owns it without a second lookup.
9892
- */
9893
- name: string().min(3).regex(/^[a-z0-9-]+(\.[a-z0-9-]+)+$/, "a channel name is dotted lower-kebab, e.g. area.thing"),
9894
- /** One sentence: what the operator will SEE after arming it. */
9895
- description: string().min(1),
9896
- /** The level its lines are emitted at. Never below `info`. */
9897
- defaultLevel: LogChannelLevelSchema,
9898
- /**
9899
- * Whether this channel can be narrowed to a camera.
9900
- *
9901
- * `true` is a PROMISE with two halves, and both must hold: the gate is
9902
- * consulted with the numeric device id, AND every line the channel admits
9903
- * carries `tags: { deviceId }` with that same numeric id. The second half is
9904
- * what makes `| json | deviceId="617"` work in Loki — `loki-payload.ts`
9905
- * keeps `deviceId` out of the stream labels for cardinality, so the tag in
9906
- * the body is the only way to filter.
9907
- *
9908
- * A channel whose lines carry the device only in `meta` (or not at all) is
9909
- * declared `false`. Declaring it `true` anyway would be a lie the UI repeats:
9910
- * the operator narrows to one camera, sees nothing, and concludes the code
9911
- * path was never taken.
9912
- */
9913
- perDevice: boolean()
9914
- });
9915
- /**
9916
- * An armed window over one channel, as the document hands it to a mirror.
9917
- *
9918
- * A window is a DEADLINE, never a flag (ADR-0244): a channel somebody forgot
9919
- * expires by itself, which is the one failure a boolean cannot avoid.
9920
- */
9921
- var LogChannelWindowSchema = object({
9922
- channel: string().min(1),
9923
- /** Epoch ms the window closes at. */
9924
- armedUntilMs: number(),
9925
- /** `null` = every camera. A non-empty list narrows to those numeric ids. */
9926
- deviceIds: array(number().int()).readonly().nullable()
9927
- });
10270
+ }
10271
+ /** What eviction may do to this location. */
10272
+ function evictionPolicyOfLocation(location) {
10273
+ return evictionPolicyForMode(resolveLocationMode(location));
10274
+ }
9928
10275
  /**
9929
- * Distinct (device, family, variant) counters one instance will hold.
10276
+ * `StorageLocationType` — an addon-declared id that identifies the *kind* of
10277
+ * storage a location serves. Defined here (not in `capabilities/storage.cap.ts`)
10278
+ * so the persisted record schema and the consumer-facing cap can both consume it
10279
+ * without forming a circular import. The `storage` cap re-exports it
10280
+ * verbatim for back-compat.
9930
10281
  *
9931
- * A large fleet x the handful of families any single addon reports, with
9932
- * slack. At ~200 B per counter this is a ~100 KB ceiling on a process that
9933
- * already declares an RSS budget in the gigabytes.
9934
- */
9935
- var MAX_KEYS = 1024;
9936
- /**
9937
- * Where reasons past {@link MAX_REASONS_PER_KEY} go.
10282
+ * This Zod schema is the **authoritative source** for `StorageLocationType`.
10283
+ * The TS alias in `./storage.ts` re-exports `z.infer<typeof
10284
+ * StorageLocationTypeSchema>` so the wire surface (cap) and the legacy
10285
+ * `IStorageProvider` interface stay in lockstep.
9938
10286
  *
9939
- * They are FOLDED, never dropped: `attempts - succeeded` must always equal the
9940
- * sum of the reason counts, or the ratio stops adding up.
10287
+ * The type is now an **open string** (not a closed enum) — addons declare
10288
+ * their own location kinds via `StorageLocationDeclaration.id`. The regex
10289
+ * enforces a safe id format: lowercase-start, alphanumeric + hyphens.
9941
10290
  */
9942
- var OVERFLOW_REASON = "other";
9943
- /** `deviceId` + `family` + optional `variant`, flattened into the map key. */
9944
- function counterKey(deviceId, family, variant) {
9945
- return variant === void 0 ? `${deviceId}${family}` : `${deviceId}${family}${variant}`;
9946
- }
10291
+ var StorageLocationTypeSchema = string().regex(/^[a-z][a-zA-Z0-9-]*$/);
9947
10292
  /**
9948
- * A bounded set of per-camera, cumulative failure counters.
10293
+ * Persisted record for a storage location instance. Operators can register
10294
+ * multiple instances for multi-cardinality types (e.g. two `backups`
10295
+ * locations with different `providerId`s). Cardinality is now declared per
10296
+ * location via `StorageLocationDeclaration.cardinality` — the static
10297
+ * `STORAGE_LOCATION_CARDINALITY` map has been removed.
9949
10298
  *
9950
- * One instance per contributing subsystem. `note` is O(1) and allocation-free
9951
- * on the steady path; `snapshot` reads without mutating anything.
10299
+ * `id` is a stable namespaced string of the form `<type>:<slug>`.
10300
+ * The seed names its first instance `<type>:default` — a NAME, not a flag.
10301
+ * There is no default location any more (D383): `enabled` is the whole write
10302
+ * model, and a bare type ref resolves to the sole location of the type, or —
10303
+ * transitionally, only while legacy NULL-stamped rows exist — to the row whose
10304
+ * slug is `default`.
10305
+ *
10306
+ * `isSystem` is a legacy persisted flag. Seed still creates the initial
10307
+ * `<type>:default` locations; the flag is no longer a lock, a badge, or a
10308
+ * prune selector. New writes leave it false. Deletion is gated on uniqueness
10309
+ * / last-enabled, not on this bit.
9952
10310
  */
9953
- var FailureCounters = class {
9954
- maxKeys;
9955
- maxReasons;
9956
- counters = /* @__PURE__ */ new Map();
9957
- refused = 0;
9958
- constructor(maxKeys = MAX_KEYS, maxReasons = 16) {
9959
- this.maxKeys = maxKeys;
9960
- this.maxReasons = maxReasons;
9961
- }
10311
+ var StorageLocationSchema = object({
10312
+ id: string().regex(/^[a-z][a-zA-Z0-9-]*:[a-zA-Z0-9-]+$/),
10313
+ type: string(),
10314
+ displayName: string().min(1),
10315
+ providerId: string().min(1),
10316
+ config: record(string(), unknown()),
9962
10317
  /**
9963
- * Counters refused because {@link MAX_KEYS} was already held.
9964
- *
9965
- * Cumulative for the life of the instance: a bound that bit is a fact about
9966
- * the deployment, and a surface that hid it would under-report a fleet
9967
- * precisely when the fleet got large enough to matter.
10318
+ * Cluster node this location physically lives on. REQUIRED for node-local
10319
+ * providers (filesystem — the path exists on one node's disk), null/absent
10320
+ * for node-agnostic providers (S3/SFTP/WebDAV, reachable from any node).
10321
+ * `'hub'` is the hub node. Validated against the provider's `nodeLocal`
10322
+ * flag at upsert time, not here (the schema is provider-agnostic).
9968
10323
  */
9969
- get keysRefused() {
9970
- return this.refused;
9971
- }
9972
- /** Counters currently held. */
9973
- get size() {
9974
- return this.counters.size;
9975
- }
10324
+ nodeId: string().optional(),
10325
+ isSystem: boolean().default(false),
9976
10326
  /**
9977
- * Fold one observation in.
10327
+ * THE write switch, and the only one (D383). `enabled: true` means every
10328
+ * consumer that chooses a write target for this type may write here, and all
10329
+ * enabled locations of a type are used TOGETHER; `false` means read-only —
10330
+ * still read, still played back, still age-swept, still drained, never
10331
+ * written.
9978
10332
  *
9979
- * A non-positive or non-integer `deviceId` is REFUSED rather than bucketed:
9980
- * see the module docblock — an entry that cannot name its camera is worse
9981
- * than no entry.
9982
- */
9983
- note(observation, nowMs) {
9984
- if (!Number.isInteger(observation.deviceId) || observation.deviceId <= 0) return;
9985
- const key = counterKey(observation.deviceId, observation.family, observation.variant);
9986
- let counter = this.counters.get(key);
9987
- if (counter === void 0) {
9988
- if (this.counters.size >= this.maxKeys) {
9989
- this.refused += 1;
9990
- return;
9991
- }
9992
- counter = {
9993
- deviceId: observation.deviceId,
9994
- family: observation.family,
9995
- variant: observation.variant,
9996
- sinceMs: nowMs,
9997
- attempts: 0,
9998
- succeeded: 0,
9999
- reasons: /* @__PURE__ */ new Map()
10000
- };
10001
- this.counters.set(key, counter);
10002
- }
10003
- counter.attempts += 1;
10004
- if (observation.reason === void 0) {
10005
- counter.succeeded += 1;
10006
- return;
10007
- }
10008
- const reason = counter.reasons.has(observation.reason) || counter.reasons.size < this.maxReasons ? observation.reason : OVERFLOW_REASON;
10009
- counter.reasons.set(reason, (counter.reasons.get(reason) ?? 0) + 1);
10010
- }
10011
- /** Read every counter. Never mutates — see the module docblock. */
10012
- snapshot(nowMs) {
10013
- const out = [];
10014
- for (const counter of this.counters.values()) out.push({
10015
- deviceId: counter.deviceId,
10016
- family: counter.family,
10017
- ...counter.variant !== void 0 ? { variant: counter.variant } : {},
10018
- sinceMs: counter.sinceMs,
10019
- atMs: nowMs,
10020
- attempts: counter.attempts,
10021
- succeeded: counter.succeeded,
10022
- reasons: [...counter.reasons.entries()].map(([reason, count]) => ({
10023
- reason,
10024
- count
10025
- })).toSorted((a, b) => b.count - a.count)
10026
- });
10027
- return out;
10028
- }
10029
- /** Drop everything (host disposal). */
10030
- clear() {
10031
- this.counters.clear();
10032
- }
10033
- };
10034
- var ClipModelMetaSchema = object({
10035
- /** The paired text encoder's catalog id (the embedding-encoder's text catalog). */
10036
- textModelId: string().min(1),
10037
- /** Output dimension of BOTH towers. A vector of any other length is refused. */
10038
- embeddingDim: number().int().positive(),
10039
- /**
10040
- * The `vector-store` index this model's vectors live in. ONE feature space
10041
- * per index: two models share an index only when their vectors are ranked
10042
- * against each other, which for CLIP means never across families. MobileCLIP
10043
- * S1 and S2 share `object-clip` for history (separated by the row's
10044
- * `modelId`); any other model gets its own.
10333
+ * OPTIONAL only for the wire: an upsert that omits it means "leave what is
10334
+ * stored" on an update and "born inert unless it is the first location of its
10335
+ * type" on a create. On a PERSISTED row absence is legacy and it means
10336
+ * enabled — {@link isLocationEnabled} is the one place that says so, and the
10337
+ * orchestrator stamps every flagless row `true` once at hydrate so absence
10338
+ * stops existing rather than being re-derived on every read.
10045
10339
  */
10046
- vectorIndex: string().regex(/^object-clip(-[a-z0-9][a-z0-9-]*)?$/),
10340
+ enabled: boolean().optional(),
10047
10341
  /**
10048
- * The text encoder's tokenizer, a declared SIBLING file of its onnx. The name
10049
- * must be unique per tokenizer: every model's siblings land in ONE flat
10050
- * models directory, so two different tokenizers under the same name would
10051
- * silently overwrite each other (whichever model downloaded last wins, and
10052
- * the other one tokenises with the wrong vocabulary).
10342
+ * THE state of this location (D385), and the only authority on what may be
10343
+ * written, read or evicted here. Interpreted in exactly one place —
10344
+ * `storage-location-mode.ts` — which also folds the legacy
10345
+ * `enabled` / `config.readOnly` pair into a mode so an old row is never
10346
+ * ambiguous.
10347
+ *
10348
+ * OPTIONAL only for the wire and for rows written before D385: absence is
10349
+ * resolved by `resolveLocationMode`, and the orchestrator stamps every
10350
+ * unstamped row ONCE at hydrate so absence stops existing rather than being
10351
+ * re-derived on every read. `enabled` survives one release as a DERIVED
10352
+ * mirror (`mode === 'active'`); `withLocationMode` is the only writer of
10353
+ * either, so the two cannot disagree.
10053
10354
  */
10054
- tokenizerFile: string().min(1),
10055
- /** Token window the text graph takes (SigLIP2: 64; CLIP BPE: 77). */
10056
- contextLength: number().int().positive(),
10057
- /** Id the tokenizer pads with. */
10058
- padId: number().int().nonnegative(),
10355
+ mode: StorageLocationModeSchema.optional(),
10356
+ /** COMPUTED at read time by the orchestrator (statfs of the backing volume
10357
+ * for node-local locations it can reach) — never persisted, absent when the
10358
+ * volume is remote/unreachable. The single capacity truth every UI reads. */
10359
+ capacity: object({
10360
+ totalBytes: number(),
10361
+ availableBytes: number()
10362
+ }).nullable().optional(),
10059
10363
  /**
10060
- * The search cosine floor at `strictness: 'loose'` — calibrated per model,
10061
- * because cosine bands are a property of the model: 0.2 keeps 96% of matched
10062
- * COCO caption-image pairs on MobileCLIP S1 and 2.7% on SigLIP2.
10364
+ * How much of that volume CamStack ITSELF holds on this location (D388) —
10365
+ * COMPUTED at read time from the `storage-occupancy` providers' own figures,
10366
+ * never persisted, never a filesystem walk.
10367
+ *
10368
+ * **ABSENT MEANS UNKNOWN, never zero.** No provider has reported for this
10369
+ * location yet — nobody stores here, the owning addon is down, or the first
10370
+ * refresh has not completed. A UI must omit the segment rather than draw it
10371
+ * at zero, which would claim we occupy nothing (D315). It is an OBJECT and
10372
+ * not a bare number precisely so that a `?? 0` on the consuming side has to
10373
+ * be spelled out loud instead of appearing by accident.
10374
+ *
10375
+ * `measuredAtMs` is the OLDEST contributing measurement, so it is honest
10376
+ * about the whole figure rather than about its freshest part.
10063
10377
  */
10064
- searchMinScore: number().min(0).max(1)
10378
+ owned: object({
10379
+ bytes: number().int().nonnegative(),
10380
+ measuredAtMs: number().int().nonnegative()
10381
+ }).optional(),
10382
+ createdAt: number(),
10383
+ updatedAt: number()
10065
10384
  });
10066
- var ClipSearchStrictnessSchema = _enum([
10067
- "loose",
10068
- "balanced",
10069
- "strict"
10070
- ]);
10071
- var CLIP_STRICTNESS_FACTOR = {
10072
- loose: 1,
10073
- balanced: 1.25,
10074
- strict: 1.5
10075
- };
10076
- /**
10077
- * The cosine floor a search runs with.
10078
- *
10079
- * An explicit `minScore` is honoured verbatim: it is a number a caller chose,
10080
- * and silently rescaling it would make the same URL mean different things on
10081
- * two installs. Everything else is relative to the model.
10082
- */
10083
- function resolveClipSearchMinScore(meta, request) {
10084
- if (request.minScore !== void 0) return request.minScore;
10085
- const factor = CLIP_STRICTNESS_FACTOR[request.strictness ?? "loose"];
10086
- return Math.min(1, Math.round(meta.searchMinScore * factor * 1e3) / 1e3);
10087
- }
10088
- var MODEL_FORMATS = [
10089
- "onnx",
10090
- "coreml",
10091
- "openvino",
10092
- "tflite",
10093
- "pt",
10094
- "gguf"
10095
- ];
10385
+ object({ isDefault: boolean().optional() });
10096
10386
  /**
10097
- * Multi-file format payload.
10387
+ * How far a `drain` has got (D386) — the read a UI renders, and nothing more.
10098
10388
  *
10099
- * - Directory formats (`isDirectory: true`, e.g. `.mlpackage`): files
10100
- * relative to the directory root — the downloader fetches each from
10101
- * `{url}/{file}` into `{modelDir}/{file}`. If omitted, it probes the
10102
- * HuggingFace API (slower).
10103
- * - Single-file formats (no `isDirectory`, e.g. OpenVINO IR): sibling
10104
- * files fetched from the SAME remote directory as `url` and stored flat
10105
- * alongside the main file — e.g. `['camstack-yolov9t.bin']` for the IR
10106
- * weights next to `camstack-yolov9t.xml`.
10107
- */
10108
- var ModelFormatEntrySchema = object({
10109
- url: string(),
10110
- sizeMB: number(),
10111
- /** Whether this format is a directory bundle (e.g., .mlpackage) rather than a single file */
10112
- isDirectory: boolean().optional(),
10113
- /** Multi-file payload (directory members or sibling files). */
10114
- files: array(string()).readonly().optional(),
10115
- /** Runtime(s) that can use this format. If omitted, inferred from ModelFormat key */
10116
- runtimes: array(_enum(["python"])).readonly().optional()
10117
- });
10118
- /**
10119
- * Extra file that must be downloaded alongside the model (e.g., labels JSON, dict.txt).
10120
- * The downloader fetches from `url` and saves to `{modelsDir}/{filename}`.
10389
+ * `estimatedEmptyAtMs` is derived from the growth the ratchet has actually
10390
+ * OBSERVED and is `null` when it has observed none. Never a fabricated date: a
10391
+ * drain with no observed growth has no honest ETA, and inventing one is how an
10392
+ * operator learns not to believe the screen.
10121
10393
  */
10122
- var ModelExtraFileSchema = object({
10123
- url: string(),
10124
- filename: string(),
10125
- sizeMB: number()
10394
+ var StorageDrainProgressSchema = object({
10395
+ locationId: string(),
10396
+ startedAtMs: number(),
10397
+ startBytes: number(),
10398
+ bytesRemaining: number(),
10399
+ drained: boolean(),
10400
+ estimatedEmptyAtMs: number().nullable()
10126
10401
  });
10127
10402
  /**
10128
- * Per-format payload map. Modelled as an explicit object (one optional key
10129
- * per `ModelFormat`) rather than `z.record(enum, …)` — zod v4's enum-keyed
10130
- * record requires every key, but a catalog entry only ships a subset of
10131
- * formats.
10403
+ * Reference accepted by consumer-facing `api.storage.*` calls.
10404
+ * Either:
10405
+ * - a `StorageLocationType` (e.g. `'backups'`) → the sole location of that type
10406
+ * (transitionally, the `<type>:default`-slugged row when several exist)
10407
+ * - a fully-qualified id (e.g. `'backups:nas-01'`) → addresses a specific instance
10408
+ *
10409
+ * The orchestrator's `resolveRef(ref)` handles both cases.
10132
10410
  */
10133
- var ModelFormatsSchema = object({
10134
- onnx: ModelFormatEntrySchema.optional(),
10135
- coreml: ModelFormatEntrySchema.optional(),
10136
- openvino: ModelFormatEntrySchema.optional(),
10137
- tflite: ModelFormatEntrySchema.optional(),
10138
- pt: ModelFormatEntrySchema.optional(),
10139
- gguf: ModelFormatEntrySchema.optional()
10140
- });
10411
+ var StorageLocationRefSchema = union([StorageLocationTypeSchema, string().regex(/^[a-z][a-zA-Z0-9-]*:[a-zA-Z0-9-]+$/)]);
10141
10412
  /**
10142
- * Variant-selector grouping axes. Shared by the full `ModelCatalogEntry` and by
10143
- * the reduced `PipelineModelOption` returned in `pipeline.getSchema()` so the
10144
- * grouped Family→Tier→Variant picker renders identically in the config UI and
10145
- * in the pipeline/device steppers. The flat `id` stays the source of truth for
10146
- * resolution/download/persistence; this is a presentation overlay resolved back
10147
- * to an `id`.
10413
+ * `StorageLocationDeclaration` — a single storage-location entry declared by
10414
+ * an addon in its `package.json` under `camstack.storageLocations`.
10415
+ *
10416
+ * Design intent:
10417
+ * - **Addon declares its needs** — each addon describes the logical storage
10418
+ * slots it requires (e.g. `recordings`, `recordingsLow`) without caring
10419
+ * about the physical path.
10420
+ * - **Kernel aggregates** — at boot the kernel collects declarations from all
10421
+ * installed addons, deduplicates by `id`, and exposes the union via the
10422
+ * storage-locations settings surface.
10423
+ * - **Orchestrator seeds** — for every declared `id` the orchestrator ensures
10424
+ * at least one instance named `<id>:default` is present, using
10425
+ * `defaultsTo` to inherit the resolved root from another location when the
10426
+ * declaration is a derivative slot (e.g. `recordingsLow` defaults to
10427
+ * `recordings`).
10428
+ * - **ids are global** — `id` values are shared across the entire deployment;
10429
+ * two addons declaring the same `id` must agree on `cardinality` (validated
10430
+ * at kernel aggregation time, not here).
10148
10431
  */
10149
- var ModelVariantGroupSchema = object({
10150
- /** Top-level family, e.g. `yolo26` (later `d-fine`, `rf-detr`). */
10151
- family: string(),
10152
- /** Size within the family, e.g. `n` | `s` | `m` | `l`. */
10153
- tier: string(),
10154
- /** Quantization axis. Omit ⇒ the fp32 base build. */
10155
- precision: _enum(["fp32", "int8"]).optional(),
10156
- /**
10157
- * Speed-optimization axis. Omit ⇒ the standard build. `fast` marks a
10158
- * latency-optimized export (e.g. ReLU-activation variant) — the slot the
10159
- * future performance variants plug into.
10160
- */
10161
- optimization: _enum(["standard", "fast"]).optional(),
10162
- /**
10163
- * Input-resolution axis (square input side, px). Omit ⇒ the family's native
10164
- * resolution (640 for yolo26). Reduced-input builds (320 / 256) are a big,
10165
- * cheap latency lever — especially on Apple ANE and the Intel N100 — at a
10166
- * small-object accuracy cost. Mirrors the model's `inputSize` but lifted onto
10167
- * the group so the selector can offer it as a variant axis.
10168
- */
10169
- resolution: number().int().positive().optional()
10170
- });
10171
- var ModelProviderIdSchema = _enum([
10172
- "camstack",
10173
- "frigate",
10174
- "scrypted",
10175
- "custom"
10176
- ]);
10177
10432
  /**
10178
- * The licence of a model, as a record rather than a bare SPDX string: a
10179
- * permissive CODE licence routinely sits on top of restrictive WEIGHTS or
10180
- * DATA, and the obligations (attribution, modification notices, source offers)
10181
- * are per upstream. A persisted custom-model row written when this field was a
10182
- * string still parses — the string is lifted into `{ weights }`.
10433
+ * `StorageAccess` — how the service that DECLARED a storage-location kind
10434
+ * actually reaches the bytes. It is the constraint that decides which
10435
+ * `storage-provider`s may back a location of that kind.
10183
10436
  *
10184
- * Every licence surface (the admin "Models and licenses" page,
10185
- * `THIRD_PARTY_MODELS.md`, the Hugging Face model table) is GENERATED from
10186
- * these records by `scripts/gen-model-licenses.ts` (D661).
10437
+ * - `'local-path'` — the service asks `storage.resolve` for a path string and
10438
+ * then does its own `node:fs` I/O on it (the recorder's segment writer, the
10439
+ * post-analysis media roots). Only a provider that serves a genuine local
10440
+ * filesystem (`getProviderInfo().nodeLocal === true`) can satisfy that: a
10441
+ * remote provider's `resolve` returns a path on the REMOTE host, and
10442
+ * `fs.readdir` of it on this node either fails or — far worse — succeeds
10443
+ * against a same-named local directory that is something else entirely.
10444
+ *
10445
+ * - `'cap-mediated'` — every byte travels through the `storage` cap
10446
+ * (`read`/`write`, or `beginUpload`/`writeChunk`/`finalizeUpload`). The
10447
+ * service never sees a path, so any provider can back it. `backups` is the
10448
+ * one kind that qualifies today.
10449
+ *
10450
+ * Before this existed, `recordings` was unreachable by SFTP/S3/WebDAV only as
10451
+ * an EMERGENT property of how the recorder happened to be written. Nothing
10452
+ * refused the configuration; the first write simply went somewhere wrong, and
10453
+ * a recording write that goes wrong surfaces as a silent black window rather
10454
+ * than an error (the read path does not `stat`). This turns that accident into
10455
+ * a declared, enforced, testable refusal.
10187
10456
  */
10188
- var ModelLicenseSchema = preprocess((value) => typeof value === "string" ? { weights: value } : value, object({
10189
- /** SPDX id of the WEIGHTS' terms, a `LicenseRef-*` for non-SPDX terms, or `UNKNOWN`. */
10190
- weights: string().min(1),
10191
- /** SPDX id of the upstream code that defines / trained the network. */
10192
- code: string().min(1).optional(),
10193
- /** Training data whose terms add obligations (attribution, non-commercial). */
10194
- data: object({
10195
- name: string().min(1),
10196
- terms: string().min(1),
10197
- url: string().url().optional()
10198
- }).optional(),
10199
- /** The project the weights come from. Required on built-in entries (guard). */
10200
- upstream: object({
10201
- name: string().min(1),
10202
- url: string().url()
10203
- }).optional(),
10204
- /** Where the licence text governing the weights is published. */
10205
- url: string().url().optional(),
10206
- /** Attribution the licence REQUIRES, verbatim. */
10207
- attribution: string().min(1).optional(),
10208
- /** What CamStack changed. Required when `hosting` is `camstack-hf` (guard). */
10209
- modifications: string().min(1).optional(),
10210
- /** Who serves the file a node downloads. */
10211
- hosting: _enum([
10212
- "camstack-hf",
10213
- "third-party",
10214
- "built-in"
10215
- ]).optional(),
10216
- /** True when the weights or their data forbid commercial use. */
10217
- nonCommercial: boolean().optional()
10218
- }));
10219
- var ModelCatalogEntrySchema = object({
10220
- id: string(),
10221
- name: string(),
10222
- description: string(),
10223
- formats: ModelFormatsSchema,
10224
- inputSize: object({
10225
- width: number(),
10226
- height: number()
10227
- }),
10228
- /**
10229
- * Channel count of the model input tensor. Omit ⇒ 3 (RGB), the default for
10230
- * every detector / classifier / embedder. Set to 1 for a grayscale CTC text
10231
- * recognizer (EasyOCR VGG plate-OCR: input `[N,1,H,W]`) so the preprocess
10232
- * feeds a single-channel, EasyOCR-normalized tensor instead of the default
10233
- * 3-channel RGB one. Threaded through `PoolModelConfig.inputChannels` to the
10234
- * Python inference pool.
10235
- */
10236
- inputChannels: number().int().positive().optional(),
10237
- labels: array(LabelDefinitionSchema).readonly(),
10238
- inputLayout: _enum(["nchw", "nhwc"]).optional(),
10239
- /**
10240
- * `'scrfd'` applies InsightFace's SCRFD input contract, `(x - 127.5) / 128`
10241
- * (upstream `insightface/model_zoo/scrfd.py`), instead of the historical
10242
- * plain `/255`. Measured against COCO GT: 188 → 196 faces found on the
10243
- * `scrfd-2.5g` catalog entry (2026-09-26 model-replacement spike, §2.4-2).
10244
- */
10245
- inputNormalization: _enum([
10246
- "zero-one",
10247
- "imagenet",
10248
- "none",
10249
- "scrfd"
10250
- ]).optional(),
10251
- /**
10252
- * The model already applies softmax IN-GRAPH — its raw output is a
10253
- * probability distribution, not logits. When set, the `softmax`
10254
- * postprocessor must NOT re-apply softmax: re-softmaxing an already-normalised
10255
- * probability vector collapses it toward uniform (top-1 score craters far
10256
- * below its true value, making every confidence gate meaningless). Absent ⇒
10257
- * the output is raw logits and the postprocessor applies softmax (the normal
10258
- * case). Set on the Google AIY Birds `bird-classifier` (softmax baked into the
10259
- * TF graph). Threaded to the Python pool via `PoolModelConfig.outputProbabilities`.
10260
- */
10261
- outputProbabilities: boolean().optional(),
10262
- preprocessMode: _enum(["letterbox", "resize"]).optional(),
10263
- /**
10264
- * Per-MODEL postprocessor override. Absent ⇒ the step's own
10265
- * `StepDefinition.postprocessor` applies (the normal case — every model in a
10266
- * step shares its decode). Set it when a step hosts models with DIFFERENT raw
10267
- * output layouts under one slot: e.g. object-detection is `'yolo'` by default,
10268
- * but a Coral SSD MobileNet build emits the `TFLite_Detection_PostProcess`
10269
- * 4-tensor layout and needs `'ssd'`. Threaded into `PoolModelConfig.postprocessor`
10270
- * by the engine factory (`modelEntry.postprocessor ?? def.postprocessor`).
10271
- */
10272
- postprocessor: custom().optional(),
10457
+ var StorageAccessSchema = _enum(["local-path", "cap-mediated"]);
10458
+ var StorageLocationDeclarationSchema = object({
10273
10459
  /**
10274
- * Per-MODEL default confidence floor. Absent ⇒ the step's
10275
- * `StepDefinition.defaultConfidence` applies. A score is a property of one
10276
- * model's output scale, not of the step slot it sits in: YuNet's
10277
- * `sqrt(cls·obj)` is not SCRFD's score, and inheriting SCRFD's 0.5 filled the
10278
- * face gallery with wheels and hands (D662, amends D645). An operator's
10279
- * explicit value still wins over it. Resolved in ONE place —
10280
- * `addon-pipeline/.../registry/effective-confidence.ts`.
10460
+ * Global location identifier, e.g. `recordings` or `recordingsLow`.
10461
+ * Must start with a lowercase letter and may contain letters, digits, and
10462
+ * hyphens.
10281
10463
  */
10282
- defaultConfidence: number().min(0).max(1).optional(),
10464
+ id: string().regex(/^[a-z][a-zA-Z0-9-]*$/, { message: "id must start with a lowercase letter and contain only letters, digits, or hyphens" }),
10465
+ /** Human-readable name shown in the admin UI. */
10466
+ displayName: string().min(1, { message: "displayName must not be empty" }),
10467
+ /** Optional longer explanation of what data this location stores. */
10468
+ description: string().optional(),
10283
10469
  /**
10284
- * When true, the executor produces a landmark-aligned crop (similarity warp
10285
- * onto the canonical template) before this step runs, instead of a plain
10286
- * axis-aligned bbox crop. Required for face-recognition embedders (ArcFace):
10287
- * their embeddings are only discriminative on an aligned input. The face
10288
- * detector that produced the parent detail must emit 5 landmarks.
10470
+ * `single` — exactly one instance of this location is allowed system-wide
10471
+ * (e.g. `logs`, `models`). The operator can edit it but not add more.
10472
+ * `multi` — the operator may register several instances (e.g. a second
10473
+ * `recordings` on a NAS for disk tiering); one is the default at any time.
10289
10474
  */
10290
- faceAlignment: boolean().optional(),
10475
+ cardinality: _enum(["single", "multi"]),
10291
10476
  /**
10292
- * Auxiliary files required at runtime (labels JSON, charset dict, etc.).
10293
- * Downloaded into the same modelsDir alongside the model file.
10477
+ * HOW the declaring service reaches the bytes — and therefore WHICH
10478
+ * providers may back a location of this kind. See {@link StorageAccessSchema}
10479
+ * and {@link STORAGE_ACCESS_FALLBACK}.
10480
+ *
10481
+ * Absent means `'local-path'`. That default is FAIL-CLOSED on purpose: it
10482
+ * can only over-restrict (refuse a remote provider for a kind that might
10483
+ * have coped) and never under-restrict. Declaring `'cap-mediated'` is the
10484
+ * permissive direction and is therefore never inferred — a repo guard
10485
+ * (`scripts/check-storage-access-declarations.ts`) refuses to let it be
10486
+ * reached by omission.
10294
10487
  */
10295
- extraFiles: array(ModelExtraFileSchema).readonly().optional(),
10488
+ access: StorageAccessSchema.optional(),
10296
10489
  /**
10297
- * LEGACY entry — retained in the catalog so a persisted operator selection
10298
- * still RESOLVES (and can be re-activated), but hidden from the selectable
10299
- * model list and excluded from the auto format-default pick. Set on the
10300
- * superseded / consolidated models (older lineages, redundant fp16 IRs) so
10301
- * the active lineup stays the coherent curated ladder without deleting a
10302
- * model anyone may still be pinned to. `resolveModelForFormat` keeps honoring
10303
- * an explicit legacy id that has a build for the node's format.
10490
+ * When set, the default instance for this location inherits its resolved
10491
+ * root from the named location's default instance. Useful for derivative
10492
+ * slots (e.g. `recordingsLow` → `recordings`) so operators only need to
10493
+ * configure the primary location.
10304
10494
  */
10305
- legacy: boolean().optional(),
10495
+ defaultsTo: string().optional(),
10306
10496
  /**
10307
- * Measured quality/latency metadata — populated from the benchmark addon on
10308
- * the real node classes. Absent = not yet measured (most entries today; the
10309
- * catalog historically carried only `sizeMB`, a poor cross-architecture
10310
- * speed proxy). `p95LatencyMs` is keyed by node class (e.g. `n100`, `mac`).
10497
+ * Which node root the seeded `<id>:default` instance is placed under on a
10498
+ * FRESH install:
10499
+ * - `'data'` (default) — the node's data dir (`CAMSTACK_DATA` / boot dir),
10500
+ * the appData volume. Right for small/durable data (logs, models).
10501
+ * - `'media'` — the dedicated media volume (`CAMSTACK_MEDIA_ROOT`) when that
10502
+ * env is set, else falls back to the data root. Right for bulky, hot media
10503
+ * (recordings, event media) that should stay off the appData disk.
10504
+ * - `'backup'` — the dedicated backup volume (`CAMSTACK_BACKUP_ROOT`, default
10505
+ * `/backups` in the image) so archives live on their own mount rather than
10506
+ * filling the appData disk. Falls back to the data root when unset.
10507
+ *
10508
+ * Only affects the seeded default's `basePath`; operators can repoint any
10509
+ * location afterwards, and a `defaultsTo` slot inherits its parent's root
10510
+ * regardless of this field. Absent (the common case) is treated as `'data'`.
10311
10511
  */
10312
- metrics: object({
10313
- map50: number().optional(),
10314
- p95LatencyMs: record(string(), number()).optional()
10315
- }).optional(),
10512
+ defaultRoot: _enum([
10513
+ "data",
10514
+ "media",
10515
+ "backup"
10516
+ ]).optional()
10517
+ });
10518
+ var DecoderStatsSchema = object({
10519
+ inputFps: number(),
10520
+ outputFps: number(),
10521
+ avgDecodeTimeMs: number(),
10522
+ droppedFrames: number(),
10316
10523
  /**
10317
- * The model's licence record — see {@link ModelLicenseSchema}. Optional in
10318
- * the schema (a custom model's author may state none); REQUIRED and complete
10319
- * on every built-in catalog entry (`scripts/check-catalog-licence.ts`).
10524
+ * Pull-mode adaptive-fps telemetry (optional — only pull sessions run the
10525
+ * lag-driven controller; push sessions omit these). `lagMs` is the EWMA of
10526
+ * the decoder's real-time drift (rising = falling behind live); `adaptiveFps`
10527
+ * is the current lag-throttled emit rate (≤ `effectiveFps` ceiling).
10320
10528
  */
10321
- license: ModelLicenseSchema.optional(),
10529
+ lagMs: number().optional(),
10530
+ effectiveFps: number().optional(),
10531
+ adaptiveFps: number().optional()
10532
+ });
10533
+ var DecoderSessionConfigSchema = object({
10534
+ codec: string(),
10535
+ maxFps: number().default(0),
10536
+ outputFormat: _enum([
10537
+ "jpeg",
10538
+ "rgb",
10539
+ "bgr",
10540
+ "yuv420",
10541
+ "gray"
10542
+ ]).default("jpeg"),
10543
+ scale: number().default(1),
10544
+ width: number().optional(),
10545
+ height: number().optional(),
10322
10546
  /**
10323
- * Variant-selector grouping. The UI groups models by `family` + `tier` and
10324
- * offers `precision` / `optimization` as variant axes WITHIN a tier — so all
10325
- * of a family's sizes and quantizations collapse into one grouped picker
10326
- * instead of a flat list of `yolo26s`, `yolo26s-int8`, … Absent ⇒ ungrouped
10327
- * (legacy / custom models) — never shown in the grouped selector. The flat
10328
- * `id` stays the source of truth for resolution/download/persistence; grouping
10329
- * is a presentation overlay resolved back to an `id`.
10547
+ * Identifier of the camera this decoder session serves. Optional
10548
+ * because the cap is generic (any caller could request decode), but
10549
+ * stream-broker passes it so decoder logs include `deviceId` for
10550
+ * per-camera filtering when diagnosing failures (e.g. node-av
10551
+ * sendPacket errors on a single hung camera).
10330
10552
  */
10331
- group: ModelVariantGroupSchema.optional(),
10553
+ deviceId: number().int().nonnegative().optional(),
10332
10554
  /**
10333
- * Catalog source for the pipeline stepper's provider-first picker. Absent on
10334
- * built-in CamStack entries (treated as `camstack`) and on registry rows
10335
- * persisted before this field existed (`inferModelProvider` fills those).
10555
+ * Free-form tag for log scoping. Stream-broker uses
10556
+ * `broker:<deviceId>/<profile>`. Decoder session logger surfaces it
10557
+ * on every line so `grep tag=broker:5/high` filters one camera
10558
+ * profile cleanly.
10336
10559
  */
10337
- provider: ModelProviderIdSchema.optional(),
10560
+ tag: string().optional(),
10338
10561
  /**
10339
- * Per-MODEL class map override. Absent ⇒ the step's `StepDefinition.classMap`
10340
- * applies (Frigate / COCO public catalog). Set on a custom model whose raw
10341
- * labels already ARE the CamStack macros (Scrypted identity map).
10562
+ * Where the session delivers decoded frames (Phase 5 / D9):
10563
+ *
10564
+ * - `'callback'` (default) — the legacy pixel path: decoded frames are
10565
+ * buffered as `DecodedFrame`s and drained via `pullFrames`.
10566
+ * - `'shm'` — the shared-memory frame plane: decoded frames are written
10567
+ * into an OS shared-memory ring and drained as zero-pixel
10568
+ * `FrameHandle`s via `pullHandles`. A session is one mode or the
10569
+ * other — `pullFrames` returns nothing for an `'shm'` session and
10570
+ * `pullHandles` returns nothing for a `'callback'` session.
10342
10571
  */
10343
- classMap: DetectionCatalogClassMapSchema.optional(),
10572
+ frameSink: _enum(["callback", "shm"]).default("callback"),
10344
10573
  /**
10345
- * The model's CLIP feature-space contract (text encoder, dimension, vector
10346
- * index, token window, search floor) — see `clip-model.ts`. Present ONLY on a
10347
- * CLIP image encoder; its absence is what "not a CLIP model" means to the
10348
- * embedding encoder and the semantic-search store (D649).
10574
+ * Per-camera decoder DEBUG facility. When `true`, a pull-mode session emits
10575
+ * a throttled (~1Hz) structured `decoder debug` line (effective/adaptive fps,
10576
+ * real-time lag, dropped-frame delta, avg decode time, hwaccel). Mirrors the
10577
+ * stream-broker's `streamingDebug` gate — off by default so production logs
10578
+ * stay quiet and the emit path pays zero per-frame cost when disabled.
10349
10579
  */
10350
- clip: ClipModelMetaSchema.optional()
10351
- });
10352
- var ConvertTargetSchema = discriminatedUnion("format", [object({
10353
- format: literal("openvino"),
10354
- precisions: array(_enum(["fp16", "int8"])).min(1).readonly()
10355
- }), object({ format: literal("coreml") })]);
10356
- var ModelConvertMetadataSchema = object({
10357
- id: string().regex(/^[a-zA-Z0-9._-]+$/),
10358
- name: string(),
10359
- labels: array(LabelDefinitionSchema).readonly(),
10360
- inputSize: object({
10361
- width: number(),
10362
- height: number()
10363
- }),
10364
- inputLayout: _enum(["nchw", "nhwc"]).optional(),
10365
- inputNormalization: _enum([
10366
- "zero-one",
10367
- "imagenet",
10368
- "none",
10369
- "scrfd"
10370
- ]).optional(),
10371
- preprocessMode: _enum(["letterbox", "resize"]).optional(),
10372
- outputFormat: _enum([
10373
- "yolo",
10374
- "ssd",
10375
- "embedding",
10376
- "classification",
10377
- "ocr",
10378
- "segmentation"
10379
- ]),
10380
- faceAlignment: boolean().optional(),
10381
- classMap: DetectionCatalogClassMapSchema.optional()
10382
- });
10383
- var ConvertResultSchema = object({
10384
- entry: ModelCatalogEntrySchema,
10385
- artifacts: array(object({
10386
- format: _enum(MODEL_FORMATS),
10387
- precision: _enum(["fp16", "int8"]).optional(),
10388
- sizeMB: number(),
10389
- validated: boolean(),
10390
- files: array(string()).readonly()
10391
- })).readonly()
10580
+ debug: boolean().optional()
10392
10581
  });
10393
10582
  /**
10394
- * Error types for the safe expression engine. Two distinct classes so callers
10395
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
10396
- * failure — both are non-fatal to the host: read paths degrade to "skip link".
10397
- */
10398
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
10399
- * the failure is anchored to a character (author-facing inline feedback). */
10400
- var ExpressionParseError = class extends Error {
10401
- position;
10402
- constructor(message, position) {
10403
- super(message);
10404
- this.name = "ExpressionParseError";
10405
- this.position = position;
10406
- }
10407
- };
10408
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
10409
- * result, unknown builtin, step-budget exceeded). */
10410
- var ExpressionEvalError = class extends Error {
10411
- constructor(message) {
10412
- super(message);
10413
- this.name = "ExpressionEvalError";
10414
- }
10415
- };
10416
- function asFiniteNumber(value, name, index) {
10417
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
10418
- return value;
10419
- }
10420
- function asString$1(value, name, index) {
10421
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
10422
- return value;
10423
- }
10424
- function finiteResult(value, name) {
10425
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
10426
- return value;
10427
- }
10428
- function allFiniteNumbers(args, name) {
10429
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
10430
- }
10431
- function asBoolean$1(value, name, index) {
10432
- if (typeof value !== "boolean") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a boolean`);
10433
- return value;
10434
- }
10435
- var INF = Number.POSITIVE_INFINITY;
10436
- var table = {
10437
- min: {
10438
- minArgs: 1,
10439
- maxArgs: INF,
10440
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
10441
- },
10442
- max: {
10443
- minArgs: 1,
10444
- maxArgs: INF,
10445
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
10446
- },
10447
- abs: {
10448
- minArgs: 1,
10449
- maxArgs: 1,
10450
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
10451
- },
10452
- floor: {
10453
- minArgs: 1,
10454
- maxArgs: 1,
10455
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
10456
- },
10457
- ceil: {
10458
- minArgs: 1,
10459
- maxArgs: 1,
10460
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
10461
- },
10462
- sqrt: {
10463
- minArgs: 1,
10464
- maxArgs: 1,
10465
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
10466
- },
10467
- round: {
10468
- minArgs: 1,
10469
- maxArgs: 2,
10470
- apply: (args) => {
10471
- const x = asFiniteNumber(args[0], "round", 0);
10472
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
10473
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
10474
- const factor = 10 ** digits;
10475
- return finiteResult(Math.round(x * factor) / factor, "round");
10476
- }
10477
- },
10478
- pow: {
10479
- minArgs: 2,
10480
- maxArgs: 2,
10481
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
10482
- },
10483
- clamp: {
10484
- minArgs: 3,
10485
- maxArgs: 3,
10486
- apply: (args) => {
10487
- const x = asFiniteNumber(args[0], "clamp", 0);
10488
- const lo = asFiniteNumber(args[1], "clamp", 1);
10489
- const hi = asFiniteNumber(args[2], "clamp", 2);
10490
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
10491
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
10492
- }
10493
- },
10494
- avg: {
10495
- minArgs: 1,
10496
- maxArgs: INF,
10497
- apply: (args) => {
10498
- const nums = allFiniteNumbers(args, "avg");
10499
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
10500
- }
10501
- },
10502
- sum: {
10503
- minArgs: 1,
10504
- maxArgs: INF,
10505
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
10506
- },
10507
- coalesce: {
10508
- minArgs: 1,
10509
- maxArgs: INF,
10510
- apply: (args) => {
10511
- for (const a of args) if (a !== null) return a;
10512
- return null;
10513
- }
10514
- },
10515
- age: {
10516
- minArgs: 2,
10517
- maxArgs: 2,
10518
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
10519
- },
10520
- convert: {
10521
- minArgs: 3,
10522
- maxArgs: 3,
10523
- apply: (args, hooks) => {
10524
- const x = asFiniteNumber(args[0], "convert", 0);
10525
- const from = asString$1(args[1], "convert", 1).trim();
10526
- const to = asString$1(args[2], "convert", 2).trim();
10527
- if (hooks.convert) {
10528
- const out = hooks.convert(x, from, to);
10529
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
10530
- return finiteResult(out, "convert");
10531
- }
10532
- if (from === to) return x;
10533
- throw new ExpressionEvalError("convert: unit conversion table not installed");
10534
- }
10535
- },
10536
- any: {
10537
- minArgs: 1,
10538
- maxArgs: INF,
10539
- apply: (args) => args.map((a, i) => asBoolean$1(a, "any", i)).some((b) => b)
10540
- },
10541
- all: {
10542
- minArgs: 1,
10543
- maxArgs: INF,
10544
- apply: (args) => args.map((a, i) => asBoolean$1(a, "all", i)).every((b) => b)
10545
- },
10546
- within: {
10547
- minArgs: 2,
10548
- maxArgs: 2,
10549
- apply: (args, hooks) => {
10550
- const windowMs = asFiniteNumber(args[1], "within", 1);
10551
- if (windowMs < 0) throw new ExpressionEvalError("within: the window must not be negative");
10552
- const at = args[0];
10553
- if (at === null) return false;
10554
- const ts = asFiniteNumber(at, "within", 0);
10555
- const now = hooks.now;
10556
- if (now === void 0 || !Number.isFinite(now)) throw new ExpressionEvalError("within: no clock was supplied to this evaluation");
10557
- const inside = now - ts <= windowMs;
10558
- if (inside) hooks.noteDeadline?.(ts + windowMs + 1);
10559
- return inside;
10560
- }
10561
- },
10562
- latest: {
10563
- minArgs: 1,
10564
- maxArgs: INF,
10565
- apply: (args) => {
10566
- const present = args.flatMap((a, i) => a === null ? [] : [asFiniteNumber(a, "latest", i)]);
10567
- return present.length === 0 ? null : finiteResult(Math.max(...present), "latest");
10568
- }
10569
- }
10570
- };
10571
- Object.freeze(Object.assign(Object.create(null), table));
10572
- /** The set of valid builtin names — used by the parser to reject unknown
10573
- * callees at parse time (immediate author feedback). */
10574
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
10575
- /**
10576
- * Resource-bound constants for the safe expression engine.
10583
+ * Per-component log CHANNELS — the gate a hot path consults, and the registry
10584
+ * an addon declares its channels in.
10577
10585
  *
10578
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
10579
- * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
10580
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
10581
- * work a single author-supplied expression can request, so a hostile or
10582
- * accidental pathological string can never spend unbounded CPU/memory.
10583
- */
10584
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
10585
- * rejected without allocation. */
10586
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
10587
- /** A legal binding / identifier name. */
10588
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
10589
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
10590
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
10591
- var RESERVED_BINDING_NAMES = new Set([
10592
- "now",
10593
- "true",
10594
- "false",
10595
- "null"
10596
- ]);
10597
- /**
10598
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
10599
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
10600
- * single/double-quoted strings with a tiny escape set, identifiers, the three
10601
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
10602
- * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
10603
- * is a parse error with a source position, so member access / assignment /
10604
- * template literals are lexically impossible.
10605
- */
10606
- var KEYWORDS = new Set([
10607
- "true",
10608
- "false",
10609
- "null"
10610
- ]);
10611
- function isDigit(ch) {
10612
- return ch >= "0" && ch <= "9";
10613
- }
10614
- function isIdentStart(ch) {
10615
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
10616
- }
10617
- function isIdentPart(ch) {
10618
- return isIdentStart(ch) || isDigit(ch);
10619
- }
10620
- function isWhitespace(ch) {
10621
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
10622
- }
10623
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
10624
- * Throws `ExpressionParseError` on any illegal character or unterminated
10625
- * string. */
10626
- function tokenize(source) {
10627
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
10628
- const tokens = [];
10629
- let i = 0;
10630
- const n = source.length;
10631
- while (i < n) {
10632
- const ch = source[i];
10633
- if (isWhitespace(ch)) {
10634
- i += 1;
10635
- continue;
10636
- }
10637
- if (isDigit(ch)) {
10638
- const start = i;
10639
- while (i < n && isDigit(source[i])) i += 1;
10640
- if (i < n && source[i] === ".") {
10641
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
10642
- i += 1;
10643
- while (i < n && isDigit(source[i])) i += 1;
10644
- }
10645
- const text = source.slice(start, i);
10646
- const value = Number(text);
10647
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
10648
- tokens.push({
10649
- type: "number",
10650
- value,
10651
- pos: start
10652
- });
10653
- continue;
10654
- }
10655
- if (ch === "'" || ch === "\"") {
10656
- const quote = ch;
10657
- const start = i;
10658
- i += 1;
10659
- let out = "";
10660
- let closed = false;
10661
- while (i < n) {
10662
- const c = source[i];
10663
- if (c === "\\") {
10664
- const next = i + 1 < n ? source[i + 1] : "";
10665
- if (next === "\\" || next === "'" || next === "\"") {
10666
- out += next;
10667
- i += 2;
10668
- continue;
10669
- }
10670
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
10671
- }
10672
- if (c === quote) {
10673
- closed = true;
10674
- i += 1;
10675
- break;
10676
- }
10677
- out += c;
10678
- i += 1;
10679
- }
10680
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
10681
- tokens.push({
10682
- type: "string",
10683
- value: out,
10684
- pos: start
10685
- });
10686
- continue;
10687
- }
10688
- if (isIdentStart(ch)) {
10689
- const start = i;
10690
- while (i < n && isIdentPart(source[i])) i += 1;
10691
- const text = source.slice(start, i);
10692
- if (KEYWORDS.has(text)) tokens.push({
10693
- type: "keyword",
10694
- keyword: keywordOf(text),
10695
- pos: start
10696
- });
10697
- else tokens.push({
10698
- type: "identifier",
10699
- name: text,
10700
- pos: start
10701
- });
10702
- continue;
10703
- }
10704
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
10705
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
10706
- tokens.push({
10707
- type: "punct",
10708
- punct: two,
10709
- pos: i
10710
- });
10711
- i += 2;
10712
- continue;
10713
- }
10714
- if (isSinglePunct(ch)) {
10715
- tokens.push({
10716
- type: "punct",
10717
- punct: ch,
10718
- pos: i
10719
- });
10720
- i += 1;
10721
- continue;
10722
- }
10723
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
10724
- }
10725
- tokens.push({
10726
- type: "eof",
10727
- pos: n
10728
- });
10729
- return tokens;
10730
- }
10731
- function keywordOf(text) {
10732
- if (text === "true") return "true";
10733
- if (text === "false") return "false";
10734
- return "null";
10735
- }
10736
- function isSinglePunct(ch) {
10737
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
10738
- }
10586
+ * ## Two axes, deliberately separated
10587
+ *
10588
+ * - **DECLARATION** — which channels exist. Only the addon knows:
10589
+ * `stream-broker` knows webrtc/ICE/RTP, `provider-reolink` knows
10590
+ * baichuan/handshake. A hand-wired central list rots at the first addition,
10591
+ * and rots silently. So a channel is declared where it is consulted, and the
10592
+ * `log-channels` capability enumerates the declarations.
10593
+ * - **VALUE** — at which level, for which scope, until when. That stays ONE
10594
+ * thing: the logging settings document on the `system` cap. Two authorities
10595
+ * over the values is the exact defect
10596
+ * `docs/design/plans/2026-08-26-logging-per-componente.md` was written to
10597
+ * remove; re-introducing it from the cure side would be grotesque.
10598
+ *
10599
+ * Nothing in this file reads a clock, an env var or a store. The registry is
10600
+ * a MIRROR: it is moved only by {@link LogChannelRegistry.apply}, called off
10601
+ * the hot path with a value somebody actually read, and by
10602
+ * {@link LogChannelRegistry.tick}, called on a timer. A store read that fails
10603
+ * never reaches here, so it can neither disarm an armed channel nor arm a
10604
+ * disarmed one (D49).
10605
+ *
10606
+ * ## The canonical call shape
10607
+ *
10608
+ * ```ts
10609
+ * if (CH_RTP.on && CH_RTP.wants(deviceId)) {
10610
+ * CH_RTP.log(logger, 'rtp subscriber added', { tags: { deviceId }, meta: { ssrc } })
10611
+ * }
10612
+ * ```
10613
+ *
10614
+ * `on` is a plain boolean FIELD — never a getter — and it is the FIRST thing
10615
+ * read. Disarmed, a call site costs one load and one branch, and the `extras`
10616
+ * object literal is never constructed because it lives inside the branch. It
10617
+ * is the same shape already proven in production at `stream-broker.ts:1650`,
10618
+ * and the same discipline `LoggingGate.allowsDestination` uses for the
10619
+ * destination floor (measured at 1.93 ns/call when off).
10620
+ *
10621
+ * ## Why a channel emits at `info`
10622
+ *
10623
+ * `loki-logging.addon.ts` pins the destination default at `info` and
10624
+ * `loki-destination.ts` drops everything below it, so a line emitted at
10625
+ * `debug` never reaches Loki and the hub's in-memory ring only holds ~35
10626
+ * minutes. A diagnostic that cannot be read an hour later is worse than no
10627
+ * diagnostic, because it looks done. {@link LogChannelGate.log} therefore
10628
+ * emits at the channel's declared level, whose schema floor is `info`.
10629
+ */
10739
10630
  /**
10740
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
10631
+ * The level a channel writes at once armed.
10741
10632
  *
10742
- * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
10743
- * → relational → additive → multiplicative → unary `! -` → call / primary.
10744
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
10745
- * string validated against the builtin table at parse time, so an unknown
10746
- * function is rejected immediately (author feedback) and a persisted expression
10747
- * that references a since-removed builtin degrades at read.
10633
+ * `debug` is absent ON PURPOSE and not by omission: below `info` the line does
10634
+ * not leave the process for Loki, and the whole point of arming a channel is
10635
+ * to read it later.
10636
+ */
10637
+ var LogChannelLevelSchema = _enum([
10638
+ "info",
10639
+ "warn",
10640
+ "error"
10641
+ ]);
10642
+ /**
10643
+ * What an addon declares about one channel. No value, no state — a
10644
+ * declaration is inert.
10645
+ */
10646
+ var LogChannelDescriptorSchema = object({
10647
+ /**
10648
+ * Dotted `area.thing`, unique across the workspace. `area` is conventionally
10649
+ * the addon's short name so an operator reading a channel list can tell who
10650
+ * owns it without a second lookup.
10651
+ */
10652
+ name: string().min(3).regex(/^[a-z0-9-]+(\.[a-z0-9-]+)+$/, "a channel name is dotted lower-kebab, e.g. area.thing"),
10653
+ /** One sentence: what the operator will SEE after arming it. */
10654
+ description: string().min(1),
10655
+ /** The level its lines are emitted at. Never below `info`. */
10656
+ defaultLevel: LogChannelLevelSchema,
10657
+ /**
10658
+ * Whether this channel can be narrowed to a camera.
10659
+ *
10660
+ * `true` is a PROMISE with two halves, and both must hold: the gate is
10661
+ * consulted with the numeric device id, AND every line the channel admits
10662
+ * carries `tags: { deviceId }` with that same numeric id. The second half is
10663
+ * what makes `| json | deviceId="617"` work in Loki — `loki-payload.ts`
10664
+ * keeps `deviceId` out of the stream labels for cardinality, so the tag in
10665
+ * the body is the only way to filter.
10666
+ *
10667
+ * A channel whose lines carry the device only in `meta` (or not at all) is
10668
+ * declared `false`. Declaring it `true` anyway would be a lie the UI repeats:
10669
+ * the operator narrows to one camera, sees nothing, and concludes the code
10670
+ * path was never taken.
10671
+ */
10672
+ perDevice: boolean()
10673
+ });
10674
+ /**
10675
+ * An armed window over one channel, as the document hands it to a mirror.
10748
10676
  *
10749
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
10750
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
10677
+ * A window is a DEADLINE, never a flag (ADR-0244): a channel somebody forgot
10678
+ * expires by itself, which is the one failure a boolean cannot avoid.
10751
10679
  */
10752
- /** Binary/logical operator precedence (higher binds tighter). */
10753
- var BINARY_PRECEDENCE = {
10754
- "||": 1,
10755
- "&&": 2,
10756
- "==": 3,
10757
- "!=": 3,
10758
- "<": 4,
10759
- "<=": 4,
10760
- ">": 4,
10761
- ">=": 4,
10762
- "+": 5,
10763
- "-": 5,
10764
- "*": 6,
10765
- "/": 6,
10766
- "%": 6
10767
- };
10768
- function isLogicalOp(op) {
10769
- return op === "&&" || op === "||";
10770
- }
10771
- function isBinaryOp(op) {
10772
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
10680
+ var LogChannelWindowSchema = object({
10681
+ channel: string().min(1),
10682
+ /** Epoch ms the window closes at. */
10683
+ armedUntilMs: number(),
10684
+ /** `null` = every camera. A non-empty list narrows to those numeric ids. */
10685
+ deviceIds: array(number().int()).readonly().nullable()
10686
+ });
10687
+ /**
10688
+ * Distinct (device, family, variant) counters one instance will hold.
10689
+ *
10690
+ * A large fleet x the handful of families any single addon reports, with
10691
+ * slack. At ~200 B per counter this is a ~100 KB ceiling on a process that
10692
+ * already declares an RSS budget in the gigabytes.
10693
+ */
10694
+ var MAX_KEYS = 1024;
10695
+ /**
10696
+ * Where reasons past {@link MAX_REASONS_PER_KEY} go.
10697
+ *
10698
+ * They are FOLDED, never dropped: `attempts - succeeded` must always equal the
10699
+ * sum of the reason counts, or the ratio stops adding up.
10700
+ */
10701
+ var OVERFLOW_REASON = "other";
10702
+ /** `deviceId` + `family` + optional `variant`, flattened into the map key. */
10703
+ function counterKey(deviceId, family, variant) {
10704
+ return variant === void 0 ? `${deviceId}${family}` : `${deviceId}${family}${variant}`;
10773
10705
  }
10774
- var Parser = class {
10775
- tokens;
10776
- pos = 0;
10777
- nodeCount = 0;
10778
- identifiers = /* @__PURE__ */ new Set();
10779
- callees = /* @__PURE__ */ new Set();
10780
- constructor(tokens) {
10781
- this.tokens = tokens;
10782
- }
10783
- parse() {
10784
- const ast = this.parseTernary();
10785
- const tok = this.peek();
10786
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
10787
- return {
10788
- ast,
10789
- identifiers: this.identifiers,
10790
- callees: this.callees,
10791
- nodeCount: this.nodeCount
10792
- };
10793
- }
10794
- peek() {
10795
- return this.tokens[this.pos];
10796
- }
10797
- next() {
10798
- return this.tokens[this.pos++];
10799
- }
10800
- /** Consume a punctuator token, erroring if the next token isn't it. */
10801
- expectPunct(punct) {
10802
- const tok = this.peek();
10803
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
10804
- this.pos += 1;
10805
- }
10806
- matchPunct(punct) {
10807
- const tok = this.peek();
10808
- if (tok.type === "punct" && tok.punct === punct) {
10809
- this.pos += 1;
10810
- return true;
10811
- }
10812
- return false;
10706
+ /**
10707
+ * A bounded set of per-camera, cumulative failure counters.
10708
+ *
10709
+ * One instance per contributing subsystem. `note` is O(1) and allocation-free
10710
+ * on the steady path; `snapshot` reads without mutating anything.
10711
+ */
10712
+ var FailureCounters = class {
10713
+ maxKeys;
10714
+ maxReasons;
10715
+ counters = /* @__PURE__ */ new Map();
10716
+ refused = 0;
10717
+ constructor(maxKeys = MAX_KEYS, maxReasons = 16) {
10718
+ this.maxKeys = maxKeys;
10719
+ this.maxReasons = maxReasons;
10813
10720
  }
10814
- countNode() {
10815
- this.nodeCount += 1;
10816
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
10721
+ /**
10722
+ * Counters refused because {@link MAX_KEYS} was already held.
10723
+ *
10724
+ * Cumulative for the life of the instance: a bound that bit is a fact about
10725
+ * the deployment, and a surface that hid it would under-report a fleet
10726
+ * precisely when the fleet got large enough to matter.
10727
+ */
10728
+ get keysRefused() {
10729
+ return this.refused;
10817
10730
  }
10818
- parseTernary() {
10819
- const test = this.parseBinary(1);
10820
- if (this.matchPunct("?")) {
10821
- const consequent = this.parseTernary();
10822
- this.expectPunct(":");
10823
- const alternate = this.parseTernary();
10824
- this.countNode();
10825
- return {
10826
- kind: "conditional",
10827
- test,
10828
- consequent,
10829
- alternate
10830
- };
10831
- }
10832
- return test;
10731
+ /** Counters currently held. */
10732
+ get size() {
10733
+ return this.counters.size;
10833
10734
  }
10834
- parseBinary(minPrec) {
10835
- let left = this.parseUnary();
10836
- for (;;) {
10837
- const tok = this.peek();
10838
- if (tok.type !== "punct") break;
10839
- const prec = BINARY_PRECEDENCE[tok.punct];
10840
- if (prec === void 0 || prec < minPrec) break;
10841
- const op = tok.punct;
10842
- this.pos += 1;
10843
- const right = this.parseBinary(prec + 1);
10844
- this.countNode();
10845
- if (isLogicalOp(op)) left = {
10846
- kind: "logical",
10847
- op,
10848
- left,
10849
- right
10850
- };
10851
- else if (isBinaryOp(op)) left = {
10852
- kind: "binary",
10853
- op,
10854
- left,
10855
- right
10735
+ /**
10736
+ * Fold one observation in.
10737
+ *
10738
+ * A non-positive or non-integer `deviceId` is REFUSED rather than bucketed:
10739
+ * see the module docblock — an entry that cannot name its camera is worse
10740
+ * than no entry.
10741
+ */
10742
+ note(observation, nowMs) {
10743
+ if (!Number.isInteger(observation.deviceId) || observation.deviceId <= 0) return;
10744
+ const key = counterKey(observation.deviceId, observation.family, observation.variant);
10745
+ let counter = this.counters.get(key);
10746
+ if (counter === void 0) {
10747
+ if (this.counters.size >= this.maxKeys) {
10748
+ this.refused += 1;
10749
+ return;
10750
+ }
10751
+ counter = {
10752
+ deviceId: observation.deviceId,
10753
+ family: observation.family,
10754
+ variant: observation.variant,
10755
+ sinceMs: nowMs,
10756
+ attempts: 0,
10757
+ succeeded: 0,
10758
+ reasons: /* @__PURE__ */ new Map()
10856
10759
  };
10857
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
10760
+ this.counters.set(key, counter);
10858
10761
  }
10859
- return left;
10860
- }
10861
- parseUnary() {
10862
- const tok = this.peek();
10863
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
10864
- const op = tok.punct;
10865
- this.pos += 1;
10866
- const operand = this.parseUnary();
10867
- this.countNode();
10868
- return {
10869
- kind: "unary",
10870
- op,
10871
- operand
10872
- };
10762
+ counter.attempts += 1;
10763
+ if (observation.reason === void 0) {
10764
+ counter.succeeded += 1;
10765
+ return;
10873
10766
  }
10874
- return this.parsePrimary();
10767
+ const reason = counter.reasons.has(observation.reason) || counter.reasons.size < this.maxReasons ? observation.reason : OVERFLOW_REASON;
10768
+ counter.reasons.set(reason, (counter.reasons.get(reason) ?? 0) + 1);
10875
10769
  }
10876
- parsePrimary() {
10877
- const tok = this.next();
10878
- switch (tok.type) {
10879
- case "number":
10880
- this.countNode();
10881
- return {
10882
- kind: "literal",
10883
- value: tok.value
10884
- };
10885
- case "string":
10886
- this.countNode();
10887
- return {
10888
- kind: "literal",
10889
- value: tok.value
10890
- };
10891
- case "keyword":
10892
- this.countNode();
10893
- return {
10894
- kind: "literal",
10895
- value: tok.keyword === "null" ? null : tok.keyword === "true"
10896
- };
10897
- case "identifier": {
10898
- const nextTok = this.peek();
10899
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
10900
- this.identifiers.add(tok.name);
10901
- this.countNode();
10902
- return {
10903
- kind: "identifier",
10904
- name: tok.name
10905
- };
10906
- }
10907
- case "punct":
10908
- if (tok.punct === "(") {
10909
- const inner = this.parseTernary();
10910
- this.expectPunct(")");
10911
- return inner;
10912
- }
10913
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
10914
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
10915
- }
10770
+ /** Read every counter. Never mutates — see the module docblock. */
10771
+ snapshot(nowMs) {
10772
+ const out = [];
10773
+ for (const counter of this.counters.values()) out.push({
10774
+ deviceId: counter.deviceId,
10775
+ family: counter.family,
10776
+ ...counter.variant !== void 0 ? { variant: counter.variant } : {},
10777
+ sinceMs: counter.sinceMs,
10778
+ atMs: nowMs,
10779
+ attempts: counter.attempts,
10780
+ succeeded: counter.succeeded,
10781
+ reasons: [...counter.reasons.entries()].map(([reason, count]) => ({
10782
+ reason,
10783
+ count
10784
+ })).toSorted((a, b) => b.count - a.count)
10785
+ });
10786
+ return out;
10916
10787
  }
10917
- parseCall(callee, pos) {
10918
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
10919
- this.expectPunct("(");
10920
- const args = [];
10921
- if (!this.matchPunct(")")) for (;;) {
10922
- args.push(this.parseTernary());
10923
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
10924
- if (this.matchPunct(",")) continue;
10925
- this.expectPunct(")");
10926
- break;
10927
- }
10928
- this.callees.add(callee);
10929
- this.countNode();
10930
- return {
10931
- kind: "call",
10932
- callee,
10933
- args
10934
- };
10788
+ /** Drop everything (host disposal). */
10789
+ clear() {
10790
+ this.counters.clear();
10935
10791
  }
10936
10792
  };
10937
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
10938
- * `ExpressionParseError` on any lexical or grammatical failure. */
10939
- function parseExpression(source) {
10940
- return new Parser(tokenize(source)).parse();
10793
+ var ClipModelMetaSchema = object({
10794
+ /** The paired text encoder's catalog id (the embedding-encoder's text catalog). */
10795
+ textModelId: string().min(1),
10796
+ /** Output dimension of BOTH towers. A vector of any other length is refused. */
10797
+ embeddingDim: number().int().positive(),
10798
+ /**
10799
+ * The `vector-store` index this model's vectors live in. ONE feature space
10800
+ * per index: two models share an index only when their vectors are ranked
10801
+ * against each other, which for CLIP means never across families. MobileCLIP
10802
+ * S1 and S2 share `object-clip` for history (separated by the row's
10803
+ * `modelId`); any other model gets its own.
10804
+ */
10805
+ vectorIndex: string().regex(/^object-clip(-[a-z0-9][a-z0-9-]*)?$/),
10806
+ /**
10807
+ * The text encoder's tokenizer, a declared SIBLING file of its onnx. The name
10808
+ * must be unique per tokenizer: every model's siblings land in ONE flat
10809
+ * models directory, so two different tokenizers under the same name would
10810
+ * silently overwrite each other (whichever model downloaded last wins, and
10811
+ * the other one tokenises with the wrong vocabulary).
10812
+ */
10813
+ tokenizerFile: string().min(1),
10814
+ /** Token window the text graph takes (SigLIP2: 64; CLIP BPE: 77). */
10815
+ contextLength: number().int().positive(),
10816
+ /** Id the tokenizer pads with. */
10817
+ padId: number().int().nonnegative(),
10818
+ /**
10819
+ * The search cosine floor at `strictness: 'loose'` — calibrated per model,
10820
+ * because cosine bands are a property of the model: 0.2 keeps 96% of matched
10821
+ * COCO caption-image pairs on MobileCLIP S1 and 2.7% on SigLIP2.
10822
+ */
10823
+ searchMinScore: number().min(0).max(1)
10824
+ });
10825
+ var ClipSearchStrictnessSchema = _enum([
10826
+ "loose",
10827
+ "balanced",
10828
+ "strict"
10829
+ ]);
10830
+ var CLIP_STRICTNESS_FACTOR = {
10831
+ loose: 1,
10832
+ balanced: 1.25,
10833
+ strict: 1.5
10834
+ };
10835
+ /**
10836
+ * The cosine floor a search runs with.
10837
+ *
10838
+ * An explicit `minScore` is honoured verbatim: it is a number a caller chose,
10839
+ * and silently rescaling it would make the same URL mean different things on
10840
+ * two installs. Everything else is relative to the model.
10841
+ */
10842
+ function resolveClipSearchMinScore(meta, request) {
10843
+ if (request.minScore !== void 0) return request.minScore;
10844
+ const factor = CLIP_STRICTNESS_FACTOR[request.strictness ?? "loose"];
10845
+ return Math.min(1, Math.round(meta.searchMinScore * factor * 1e3) / 1e3);
10941
10846
  }
10847
+ var MODEL_FORMATS = [
10848
+ "onnx",
10849
+ "coreml",
10850
+ "openvino",
10851
+ "tflite",
10852
+ "pt",
10853
+ "gguf"
10854
+ ];
10855
+ /**
10856
+ * Multi-file format payload.
10857
+ *
10858
+ * - Directory formats (`isDirectory: true`, e.g. `.mlpackage`): files
10859
+ * relative to the directory root — the downloader fetches each from
10860
+ * `{url}/{file}` into `{modelDir}/{file}`. If omitted, it probes the
10861
+ * HuggingFace API (slower).
10862
+ * - Single-file formats (no `isDirectory`, e.g. OpenVINO IR): sibling
10863
+ * files fetched from the SAME remote directory as `url` and stored flat
10864
+ * alongside the main file — e.g. `['camstack-yolov9t.bin']` for the IR
10865
+ * weights next to `camstack-yolov9t.xml`.
10866
+ */
10867
+ var ModelFormatEntrySchema = object({
10868
+ url: string(),
10869
+ sizeMB: number(),
10870
+ /** Whether this format is a directory bundle (e.g., .mlpackage) rather than a single file */
10871
+ isDirectory: boolean().optional(),
10872
+ /** Multi-file payload (directory members or sibling files). */
10873
+ files: array(string()).readonly().optional(),
10874
+ /** Runtime(s) that can use this format. If omitted, inferred from ModelFormat key */
10875
+ runtimes: array(_enum(["python"])).readonly().optional()
10876
+ });
10877
+ /**
10878
+ * Extra file that must be downloaded alongside the model (e.g., labels JSON, dict.txt).
10879
+ * The downloader fetches from `url` and saves to `{modelsDir}/{filename}`.
10880
+ */
10881
+ var ModelExtraFileSchema = object({
10882
+ url: string(),
10883
+ filename: string(),
10884
+ sizeMB: number()
10885
+ });
10886
+ /**
10887
+ * Per-format payload map. Modelled as an explicit object (one optional key
10888
+ * per `ModelFormat`) rather than `z.record(enum, …)` — zod v4's enum-keyed
10889
+ * record requires every key, but a catalog entry only ships a subset of
10890
+ * formats.
10891
+ */
10892
+ var ModelFormatsSchema = object({
10893
+ onnx: ModelFormatEntrySchema.optional(),
10894
+ coreml: ModelFormatEntrySchema.optional(),
10895
+ openvino: ModelFormatEntrySchema.optional(),
10896
+ tflite: ModelFormatEntrySchema.optional(),
10897
+ pt: ModelFormatEntrySchema.optional(),
10898
+ gguf: ModelFormatEntrySchema.optional()
10899
+ });
10900
+ /**
10901
+ * Variant-selector grouping axes. Shared by the full `ModelCatalogEntry` and by
10902
+ * the reduced `PipelineModelOption` returned in `pipeline.getSchema()` so the
10903
+ * grouped Family→Tier→Variant picker renders identically in the config UI and
10904
+ * in the pipeline/device steppers. The flat `id` stays the source of truth for
10905
+ * resolution/download/persistence; this is a presentation overlay resolved back
10906
+ * to an `id`.
10907
+ */
10908
+ var ModelVariantGroupSchema = object({
10909
+ /** Top-level family, e.g. `yolo26` (later `d-fine`, `rf-detr`). */
10910
+ family: string(),
10911
+ /** Size within the family, e.g. `n` | `s` | `m` | `l`. */
10912
+ tier: string(),
10913
+ /** Quantization axis. Omit ⇒ the fp32 base build. */
10914
+ precision: _enum(["fp32", "int8"]).optional(),
10915
+ /**
10916
+ * Speed-optimization axis. Omit ⇒ the standard build. `fast` marks a
10917
+ * latency-optimized export (e.g. ReLU-activation variant) — the slot the
10918
+ * future performance variants plug into.
10919
+ */
10920
+ optimization: _enum(["standard", "fast"]).optional(),
10921
+ /**
10922
+ * Input-resolution axis (square input side, px). Omit ⇒ the family's native
10923
+ * resolution (640 for yolo26). Reduced-input builds (320 / 256) are a big,
10924
+ * cheap latency lever — especially on Apple ANE and the Intel N100 — at a
10925
+ * small-object accuracy cost. Mirrors the model's `inputSize` but lifted onto
10926
+ * the group so the selector can offer it as a variant axis.
10927
+ */
10928
+ resolution: number().int().positive().optional()
10929
+ });
10930
+ var ModelProviderIdSchema = _enum([
10931
+ "camstack",
10932
+ "frigate",
10933
+ "scrypted",
10934
+ "custom"
10935
+ ]);
10942
10936
  /**
10943
- * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
10944
- * by expr"). The cache stores BOTH successes and failures (negative caching),
10945
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
10946
- * one per read on a hot resolve path.
10937
+ * The licence of a model, as a record rather than a bare SPDX string: a
10938
+ * permissive CODE licence routinely sits on top of restrictive WEIGHTS or
10939
+ * DATA, and the obligations (attribution, modification notices, source offers)
10940
+ * are per upstream. A persisted custom-model row written when this field was a
10941
+ * string still parses — the string is lifted into `{ weights }`.
10947
10942
  *
10948
- * The cache is a module-level singleton: entries are pure, content-addressed
10949
- * ASTs keyed by the raw source string, so sharing one instance across all
10950
- * callers is safe and maximises hit rate.
10951
- */
10952
- var cache = /* @__PURE__ */ new Map();
10953
- function getCached(source) {
10954
- const hit = cache.get(source);
10955
- if (hit !== void 0) {
10956
- cache.delete(source);
10957
- cache.set(source, hit);
10958
- return hit;
10959
- }
10960
- let result;
10961
- try {
10962
- result = {
10963
- ok: true,
10964
- parsed: parseExpression(source)
10965
- };
10966
- } catch (err) {
10967
- result = {
10968
- ok: false,
10969
- error: err instanceof ExpressionParseError ? err.message : String(err)
10970
- };
10971
- }
10972
- cache.set(source, result);
10973
- if (cache.size > 256) {
10974
- const oldest = cache.keys().next().value;
10975
- if (oldest !== void 0) cache.delete(oldest);
10976
- }
10977
- return result;
10978
- }
10979
- /** Compile `source`, returning a discriminated result instead of throwing.
10980
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
10981
- function compileExpressionSafe(source) {
10982
- return getCached(source);
10983
- }
10984
- Object.freeze({});
10985
- /**
10986
- * Author-time validation. Returns `null` when the source is valid, else a
10987
- * human-readable error message. Checks: the expression compiles; binding count
10988
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
10989
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
10990
- * FREE identifier of the AST is covered by a binding or the injected `now`.
10943
+ * Every licence surface (the admin "Models and licenses" page,
10944
+ * `THIRD_PARTY_MODELS.md`, the Hugging Face model table) is GENERATED from
10945
+ * these records by `scripts/gen-model-licenses.ts` (D661).
10991
10946
  */
10992
- function validateExpressionSource(src) {
10993
- const names = Object.keys(src.bindings);
10994
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
10995
- for (const name of names) {
10996
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
10997
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
10998
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
10999
- }
11000
- const compiled = compileExpressionSafe(src.expr);
11001
- if (!compiled.ok) return compiled.error;
11002
- const bound = new Set(names);
11003
- for (const id of compiled.parsed.identifiers) {
11004
- if (id === "now") continue;
11005
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
11006
- }
11007
- return null;
11008
- }
10947
+ var ModelLicenseSchema = preprocess((value) => typeof value === "string" ? { weights: value } : value, object({
10948
+ /** SPDX id of the WEIGHTS' terms, a `LicenseRef-*` for non-SPDX terms, or `UNKNOWN`. */
10949
+ weights: string().min(1),
10950
+ /** SPDX id of the upstream code that defines / trained the network. */
10951
+ code: string().min(1).optional(),
10952
+ /** Training data whose terms add obligations (attribution, non-commercial). */
10953
+ data: object({
10954
+ name: string().min(1),
10955
+ terms: string().min(1),
10956
+ url: string().url().optional()
10957
+ }).optional(),
10958
+ /** The project the weights come from. Required on built-in entries (guard). */
10959
+ upstream: object({
10960
+ name: string().min(1),
10961
+ url: string().url()
10962
+ }).optional(),
10963
+ /** Where the licence text governing the weights is published. */
10964
+ url: string().url().optional(),
10965
+ /** Attribution the licence REQUIRES, verbatim. */
10966
+ attribution: string().min(1).optional(),
10967
+ /** What CamStack changed. Required when `hosting` is `camstack-hf` (guard). */
10968
+ modifications: string().min(1).optional(),
10969
+ /** Who serves the file a node downloads. */
10970
+ hosting: _enum([
10971
+ "camstack-hf",
10972
+ "third-party",
10973
+ "built-in"
10974
+ ]).optional(),
10975
+ /** True when the weights or their data forbid commercial use. */
10976
+ nonCommercial: boolean().optional()
10977
+ }));
10978
+ var ModelCatalogEntrySchema = object({
10979
+ id: string(),
10980
+ name: string(),
10981
+ description: string(),
10982
+ formats: ModelFormatsSchema,
10983
+ inputSize: object({
10984
+ width: number(),
10985
+ height: number()
10986
+ }),
10987
+ /**
10988
+ * Channel count of the model input tensor. Omit ⇒ 3 (RGB), the default for
10989
+ * every detector / classifier / embedder. Set to 1 for a grayscale CTC text
10990
+ * recognizer (EasyOCR VGG plate-OCR: input `[N,1,H,W]`) so the preprocess
10991
+ * feeds a single-channel, EasyOCR-normalized tensor instead of the default
10992
+ * 3-channel RGB one. Threaded through `PoolModelConfig.inputChannels` to the
10993
+ * Python inference pool.
10994
+ */
10995
+ inputChannels: number().int().positive().optional(),
10996
+ labels: array(LabelDefinitionSchema).readonly(),
10997
+ inputLayout: _enum(["nchw", "nhwc"]).optional(),
10998
+ /**
10999
+ * `'scrfd'` applies InsightFace's SCRFD input contract, `(x - 127.5) / 128`
11000
+ * (upstream `insightface/model_zoo/scrfd.py`), instead of the historical
11001
+ * plain `/255`. Measured against COCO GT: 188 → 196 faces found on the
11002
+ * `scrfd-2.5g` catalog entry (2026-09-26 model-replacement spike, §2.4-2).
11003
+ */
11004
+ inputNormalization: _enum([
11005
+ "zero-one",
11006
+ "imagenet",
11007
+ "none",
11008
+ "scrfd"
11009
+ ]).optional(),
11010
+ /**
11011
+ * The model already applies softmax IN-GRAPH — its raw output is a
11012
+ * probability distribution, not logits. When set, the `softmax`
11013
+ * postprocessor must NOT re-apply softmax: re-softmaxing an already-normalised
11014
+ * probability vector collapses it toward uniform (top-1 score craters far
11015
+ * below its true value, making every confidence gate meaningless). Absent ⇒
11016
+ * the output is raw logits and the postprocessor applies softmax (the normal
11017
+ * case). Set on the Google AIY Birds `bird-classifier` (softmax baked into the
11018
+ * TF graph). Threaded to the Python pool via `PoolModelConfig.outputProbabilities`.
11019
+ */
11020
+ outputProbabilities: boolean().optional(),
11021
+ preprocessMode: _enum(["letterbox", "resize"]).optional(),
11022
+ /**
11023
+ * Per-MODEL postprocessor override. Absent ⇒ the step's own
11024
+ * `StepDefinition.postprocessor` applies (the normal case — every model in a
11025
+ * step shares its decode). Set it when a step hosts models with DIFFERENT raw
11026
+ * output layouts under one slot: e.g. object-detection is `'yolo'` by default,
11027
+ * but a Coral SSD MobileNet build emits the `TFLite_Detection_PostProcess`
11028
+ * 4-tensor layout and needs `'ssd'`. Threaded into `PoolModelConfig.postprocessor`
11029
+ * by the engine factory (`modelEntry.postprocessor ?? def.postprocessor`).
11030
+ */
11031
+ postprocessor: custom().optional(),
11032
+ /**
11033
+ * Per-MODEL default confidence floor. Absent ⇒ the step's
11034
+ * `StepDefinition.defaultConfidence` applies. A score is a property of one
11035
+ * model's output scale, not of the step slot it sits in: YuNet's
11036
+ * `sqrt(cls·obj)` is not SCRFD's score, and inheriting SCRFD's 0.5 filled the
11037
+ * face gallery with wheels and hands (D662, amends D645). An operator's
11038
+ * explicit value still wins over it. Resolved in ONE place —
11039
+ * `addon-pipeline/.../registry/effective-confidence.ts`.
11040
+ */
11041
+ defaultConfidence: number().min(0).max(1).optional(),
11042
+ /**
11043
+ * When true, the executor produces a landmark-aligned crop (similarity warp
11044
+ * onto the canonical template) before this step runs, instead of a plain
11045
+ * axis-aligned bbox crop. Required for face-recognition embedders (ArcFace):
11046
+ * their embeddings are only discriminative on an aligned input. The face
11047
+ * detector that produced the parent detail must emit 5 landmarks.
11048
+ */
11049
+ faceAlignment: boolean().optional(),
11050
+ /**
11051
+ * Auxiliary files required at runtime (labels JSON, charset dict, etc.).
11052
+ * Downloaded into the same modelsDir alongside the model file.
11053
+ */
11054
+ extraFiles: array(ModelExtraFileSchema).readonly().optional(),
11055
+ /**
11056
+ * LEGACY entry — retained in the catalog so a persisted operator selection
11057
+ * still RESOLVES (and can be re-activated), but hidden from the selectable
11058
+ * model list and excluded from the auto format-default pick. Set on the
11059
+ * superseded / consolidated models (older lineages, redundant fp16 IRs) so
11060
+ * the active lineup stays the coherent curated ladder without deleting a
11061
+ * model anyone may still be pinned to. `resolveModelForFormat` keeps honoring
11062
+ * an explicit legacy id that has a build for the node's format.
11063
+ */
11064
+ legacy: boolean().optional(),
11065
+ /**
11066
+ * Measured quality/latency metadata — populated from the benchmark addon on
11067
+ * the real node classes. Absent = not yet measured (most entries today; the
11068
+ * catalog historically carried only `sizeMB`, a poor cross-architecture
11069
+ * speed proxy). `p95LatencyMs` is keyed by node class (e.g. `n100`, `mac`).
11070
+ */
11071
+ metrics: object({
11072
+ map50: number().optional(),
11073
+ p95LatencyMs: record(string(), number()).optional()
11074
+ }).optional(),
11075
+ /**
11076
+ * The model's licence record — see {@link ModelLicenseSchema}. Optional in
11077
+ * the schema (a custom model's author may state none); REQUIRED and complete
11078
+ * on every built-in catalog entry (`scripts/check-catalog-licence.ts`).
11079
+ */
11080
+ license: ModelLicenseSchema.optional(),
11081
+ /**
11082
+ * Variant-selector grouping. The UI groups models by `family` + `tier` and
11083
+ * offers `precision` / `optimization` as variant axes WITHIN a tier — so all
11084
+ * of a family's sizes and quantizations collapse into one grouped picker
11085
+ * instead of a flat list of `yolo26s`, `yolo26s-int8`, … Absent ⇒ ungrouped
11086
+ * (legacy / custom models) — never shown in the grouped selector. The flat
11087
+ * `id` stays the source of truth for resolution/download/persistence; grouping
11088
+ * is a presentation overlay resolved back to an `id`.
11089
+ */
11090
+ group: ModelVariantGroupSchema.optional(),
11091
+ /**
11092
+ * Catalog source for the pipeline stepper's provider-first picker. Absent on
11093
+ * built-in CamStack entries (treated as `camstack`) and on registry rows
11094
+ * persisted before this field existed (`inferModelProvider` fills those).
11095
+ */
11096
+ provider: ModelProviderIdSchema.optional(),
11097
+ /**
11098
+ * Per-MODEL class map override. Absent ⇒ the step's `StepDefinition.classMap`
11099
+ * applies (Frigate / COCO public catalog). Set on a custom model whose raw
11100
+ * labels already ARE the CamStack macros (Scrypted identity map).
11101
+ */
11102
+ classMap: DetectionCatalogClassMapSchema.optional(),
11103
+ /**
11104
+ * The model's CLIP feature-space contract (text encoder, dimension, vector
11105
+ * index, token window, search floor) — see `clip-model.ts`. Present ONLY on a
11106
+ * CLIP image encoder; its absence is what "not a CLIP model" means to the
11107
+ * embedding encoder and the semantic-search store (D649).
11108
+ */
11109
+ clip: ClipModelMetaSchema.optional()
11110
+ });
11111
+ var ConvertTargetSchema = discriminatedUnion("format", [object({
11112
+ format: literal("openvino"),
11113
+ precisions: array(_enum(["fp16", "int8"])).min(1).readonly()
11114
+ }), object({ format: literal("coreml") })]);
11115
+ var ModelConvertMetadataSchema = object({
11116
+ id: string().regex(/^[a-zA-Z0-9._-]+$/),
11117
+ name: string(),
11118
+ labels: array(LabelDefinitionSchema).readonly(),
11119
+ inputSize: object({
11120
+ width: number(),
11121
+ height: number()
11122
+ }),
11123
+ inputLayout: _enum(["nchw", "nhwc"]).optional(),
11124
+ inputNormalization: _enum([
11125
+ "zero-one",
11126
+ "imagenet",
11127
+ "none",
11128
+ "scrfd"
11129
+ ]).optional(),
11130
+ preprocessMode: _enum(["letterbox", "resize"]).optional(),
11131
+ outputFormat: _enum([
11132
+ "yolo",
11133
+ "ssd",
11134
+ "embedding",
11135
+ "classification",
11136
+ "ocr",
11137
+ "segmentation"
11138
+ ]),
11139
+ faceAlignment: boolean().optional(),
11140
+ classMap: DetectionCatalogClassMapSchema.optional()
11141
+ });
11142
+ var ConvertResultSchema = object({
11143
+ entry: ModelCatalogEntrySchema,
11144
+ artifacts: array(object({
11145
+ format: _enum(MODEL_FORMATS),
11146
+ precision: _enum(["fp16", "int8"]).optional(),
11147
+ sizeMB: number(),
11148
+ validated: boolean(),
11149
+ files: array(string()).readonly()
11150
+ })).readonly()
11151
+ });
11009
11152
  var ExpressionBindingSourceSchema = union([
11010
11153
  object({
11011
11154
  kind: literal("field").optional(),
@@ -13356,102 +13499,6 @@ var cameraStreamsCapability = {
13356
13499
  function kebabToCamel(s) {
13357
13500
  return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
13358
13501
  }
13359
- /** A composed device's stableId is this prefix plus the block id. */
13360
- var COMPOSED_DEVICE_STABLE_ID_PREFIX = "composed-";
13361
- /** Longest snippet a `code` source may carry (slice 4). */
13362
- var MAX_COMPOSITION_CODE_LENGTH = 2e4;
13363
- var CompositionSourceRefSchema = object({
13364
- /** The owning addon; `stableId` is unique only within it. */
13365
- addonId: string().min(1),
13366
- stableId: string().min(1)
13367
- });
13368
- /** One field of one capability of one source device. */
13369
- var CompositionFieldReadSchema = object({
13370
- source: CompositionSourceRefSchema,
13371
- cap: string().min(1),
13372
- /** Dotted path into the source cap's runtime-state slice. */
13373
- fieldPath: string().min(1)
13374
- });
13375
- /** `from`: copy one source field verbatim. It is also an expression's `from` binding. */
13376
- var CompositionFromSourceSchema = CompositionFieldReadSchema.extend({ kind: literal("from") });
13377
- var CompositionBindingSchema = discriminatedUnion("kind", [CompositionFromSourceSchema, object({
13378
- kind: literal("literal"),
13379
- value: union([
13380
- string(),
13381
- number(),
13382
- boolean(),
13383
- _null()
13384
- ])
13385
- })]);
13386
- /**
13387
- * `expression`: a formula over named bindings, in the salvaged expression
13388
- * engine. Validated at parse by the SAME function every other consumer runs,
13389
- * so the editor and the store cannot disagree.
13390
- */
13391
- var CompositionExpressionSourceSchema = object({
13392
- kind: literal("expression"),
13393
- expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
13394
- bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), CompositionBindingSchema)
13395
- }).superRefine((src, ctx) => {
13396
- const err = validateExpressionSource(src);
13397
- if (err !== null) ctx.addIssue({
13398
- code: "custom",
13399
- message: err,
13400
- path: ["expr"]
13401
- });
13402
- });
13403
- /** `code`: RESERVED for slice 4 (own runner, one-way eject). */
13404
- var CompositionCodeSourceSchema = object({
13405
- kind: literal("code"),
13406
- code: string().min(1).max(MAX_COMPOSITION_CODE_LENGTH)
13407
- });
13408
- var CompositionFieldSourceSchema = discriminatedUnion("kind", [
13409
- CompositionFromSourceSchema,
13410
- CompositionExpressionSourceSchema,
13411
- CompositionCodeSourceSchema
13412
- ]);
13413
- var CompositionCommandTargetSchema = discriminatedUnion("kind", [object({
13414
- kind: literal("forward"),
13415
- source: CompositionSourceRefSchema,
13416
- cap: string().min(1),
13417
- method: string().min(1)
13418
- }), CompositionCodeSourceSchema]);
13419
- var CompositionFeatureSchema = discriminatedUnion("kind", [object({
13420
- kind: literal("fields"),
13421
- cap: string().min(1),
13422
- fields: record(string().min(1), CompositionFieldSourceSchema).refine((fields) => Object.keys(fields).length <= 32, { message: `at most 32 fields per capability` }),
13423
- /** RESERVED for slice 3; refused by the validator when non-empty. */
13424
- commands: record(string().min(1), CompositionCommandTargetSchema).optional()
13425
- }), object({
13426
- kind: literal("passthrough"),
13427
- cap: string().min(1),
13428
- source: CompositionSourceRefSchema
13429
- })]);
13430
- var CompositionSchema = object({
13431
- target: discriminatedUnion("kind", [object({
13432
- kind: literal("new"),
13433
- type: _enum(DeviceType),
13434
- role: _enum(DeviceRole).optional()
13435
- }), object({
13436
- kind: literal("existing"),
13437
- device: CompositionSourceRefSchema
13438
- })]),
13439
- features: array(CompositionFeatureSchema).min(1).max(16)
13440
- }).superRefine((composition, ctx) => {
13441
- const seen = /* @__PURE__ */ new Set();
13442
- composition.features.forEach((feature, index) => {
13443
- if (seen.has(feature.cap)) ctx.addIssue({
13444
- code: "custom",
13445
- message: `capability \`${feature.cap}\` is composed twice — one feature per capability`,
13446
- path: [
13447
- "features",
13448
- index,
13449
- "cap"
13450
- ]
13451
- });
13452
- seen.add(feature.cap);
13453
- });
13454
- });
13455
13502
  /** One refusal, named (D391). `path` points into the composition, e.g. `features[0].fields.celsius`. */
13456
13503
  var CompositionProblemSchema = object({
13457
13504
  code: _enum([
@@ -13471,7 +13518,12 @@ var CompositionProblemSchema = object({
13471
13518
  "source-field-unknown",
13472
13519
  "expression-invalid",
13473
13520
  "cycle",
13474
- "duplicate-target"
13521
+ "duplicate-target",
13522
+ "not-item-array",
13523
+ "item-field-reserved",
13524
+ "target-missing",
13525
+ "target-unreadable",
13526
+ "not-replaceable"
13475
13527
  ]),
13476
13528
  path: string(),
13477
13529
  message: string()
@@ -13482,8 +13534,18 @@ var CompositionFieldKindSchema = _enum([
13482
13534
  "boolean",
13483
13535
  "enum"
13484
13536
  ]);
13485
- /** How a field says "unavailable": `null` when its schema allows it, else the device goes offline (D393). */
13486
- var CompositionUnavailableModeSchema = _enum(["null", "offline"]);
13537
+ /**
13538
+ * How a field says "unavailable" (D393, D663): `null` when its schema allows it;
13539
+ * `unknown` — the enum's own word — for an ADDED field that has one; `release`
13540
+ * for a REPLACED field of an existing device, whose claim is released so the
13541
+ * native provider's reading shows again; else the (composed) device goes offline.
13542
+ */
13543
+ var CompositionUnavailableModeSchema = _enum([
13544
+ "null",
13545
+ "offline",
13546
+ "unknown",
13547
+ "release"
13548
+ ]);
13487
13549
  /**
13488
13550
  * `derived` reads a source · `clock` reads only `now`, recomputed when a sibling
13489
13551
  * field of its cap changes value · `constant` reads nothing, computed once.
@@ -13493,9 +13555,17 @@ var CompositionFieldRoleSchema = _enum([
13493
13555
  "clock",
13494
13556
  "constant"
13495
13557
  ]);
13558
+ /** The item a planned field belongs to; its `path` is then the dotted path INSIDE the item. */
13559
+ var CompositionFieldItemSchema = object({
13560
+ arrayPath: string(),
13561
+ key: string(),
13562
+ label: string()
13563
+ });
13496
13564
  var CompositionFieldPlanSchema = object({
13497
13565
  cap: string(),
13566
+ /** Top-level field, or — when `item` is set — the dotted path inside that item. */
13498
13567
  path: string(),
13568
+ item: CompositionFieldItemSchema.optional(),
13499
13569
  /** Index into `composition.features` this field's capability came from —
13500
13570
  * lets a source/cycle problem name its path as `features[N].fields.<path>`
13501
13571
  * (the same convention `planComposition`'s own problems already use). */
@@ -13508,16 +13578,37 @@ var CompositionFieldPlanSchema = object({
13508
13578
  source: CompositionFieldSourceSchema,
13509
13579
  reads: array(CompositionFieldReadSchema)
13510
13580
  });
13581
+ /**
13582
+ * What a feature does to its target (D663): `new` builds a capability on a new
13583
+ * device; on an existing device it `add`s a capability the device lacks, or
13584
+ * `replace`s named fields of one it has natively.
13585
+ */
13586
+ var CompositionFeatureModeSchema = _enum([
13587
+ "new",
13588
+ "add",
13589
+ "replace"
13590
+ ]);
13591
+ var CompositionFeaturePlanSchema = object({
13592
+ featureIndex: number().int().nonnegative(),
13593
+ cap: string(),
13594
+ mode: CompositionFeatureModeSchema
13595
+ });
13511
13596
  var CompositionValidationSchema = object({
13512
13597
  ok: boolean(),
13513
13598
  problems: array(CompositionProblemSchema),
13514
- fields: array(CompositionFieldPlanSchema)
13599
+ fields: array(CompositionFieldPlanSchema),
13600
+ features: array(CompositionFeaturePlanSchema)
13515
13601
  });
13516
- /** Mirrors `CoreBlockStatus`, pinned by `core-blocks-source.spec.ts` (Task 11). */
13602
+ /**
13603
+ * Mirrors `CoreBlockStatus`, pinned by `core-blocks-source.spec.ts`. `degraded`:
13604
+ * running, but a replaced field is released to the native provider while its
13605
+ * source is unavailable (ruling S1b, D663) — the reason names each such field.
13606
+ */
13517
13607
  var CompositionRunStatusSchema = _enum([
13518
13608
  "stopped",
13519
13609
  "starting",
13520
13610
  "running",
13611
+ "degraded",
13521
13612
  "failed"
13522
13613
  ]);
13523
13614
  var CompositionFieldStateKindSchema = _enum([
@@ -13528,6 +13619,8 @@ var CompositionFieldStateKindSchema = _enum([
13528
13619
  var CompositionFieldStateSchema = object({
13529
13620
  cap: string(),
13530
13621
  path: string(),
13622
+ /** Same as the plan's: two items' `status` are told apart by it. */
13623
+ item: CompositionFieldItemSchema.optional(),
13531
13624
  state: CompositionFieldStateKindSchema,
13532
13625
  /** `null` whenever `state === 'unavailable'`; never a plausible stand-in (D393). */
13533
13626
  value: union([
@@ -13549,11 +13642,14 @@ var CompositionBlockStateSchema = object({
13549
13642
  * agent is the reason placement is not fixed to the hub. */
13550
13643
  var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
13551
13644
  /** What a block's process is doing. Mirrors the addon runner's own lifecycle so
13552
- * a failing block reads the same way a failing addon does. */
13645
+ * a failing block reads the same way a failing addon does. `degraded` is a
13646
+ * composition on an existing device with a field handed back to the native
13647
+ * provider while its source is unavailable (S1b, D663); the error names it. */
13553
13648
  var CoreBlockStatusSchema = _enum([
13554
13649
  "stopped",
13555
13650
  "starting",
13556
13651
  "running",
13652
+ "degraded",
13557
13653
  "failed"
13558
13654
  ]);
13559
13655
  /** Client-authored fields. */
@@ -13589,9 +13685,16 @@ var CoreBlockSourceSchema = discriminatedUnion("kind", [
13589
13685
  composition: CompositionSchema
13590
13686
  })
13591
13687
  ]);
13592
- /** The client-authored half of a composition block. No `code`, no placement (hub-only; Phase 7). */
13688
+ /**
13689
+ * The client-authored half of a composition block. No `code`, no placement (hub-only; Phase 7).
13690
+ *
13691
+ * `name`: REQUIRED for a `new` target — the block's name IS the device's name
13692
+ * (D659). ABSENT for an `existing` target — a customization has no name of its
13693
+ * own; the server derives one (`customizationBlockName`, D663) and refuses a
13694
+ * client-sent name by name.
13695
+ */
13593
13696
  var CompositionBlockInputSchema = object({
13594
- name: string().min(1).max(120),
13697
+ name: string().min(1).max(120).optional(),
13595
13698
  enabled: boolean(),
13596
13699
  composition: CompositionSchema
13597
13700
  });
@@ -13642,6 +13745,11 @@ var CompositionGetReadSchema = discriminatedUnion("state", [object({
13642
13745
  state: literal("loaded"),
13643
13746
  view: CompositionViewSchema.nullable()
13644
13747
  }), CompositionsNotLoadedSchema]);
13748
+ /** The customization of one existing device, found by its TARGET, or `not-loaded` (D315). */
13749
+ var CompositionCustomizationReadSchema = discriminatedUnion("state", [object({
13750
+ state: literal("loaded"),
13751
+ view: CompositionViewSchema.nullable()
13752
+ }), CompositionsNotLoadedSchema]);
13645
13753
  /** What a compile attempt produced. */
13646
13754
  var CoreBlockCompileResultSchema = object({
13647
13755
  ok: boolean(),
@@ -13692,6 +13800,9 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
13692
13800
  auth: "admin",
13693
13801
  caller: "required"
13694
13802
  }), method(object({ blockId: string() }), CompositionGetReadSchema, { auth: "admin" }), method(object({}), CompositionListReadSchema, { auth: "admin" }), method(object({
13803
+ addonId: string().min(1),
13804
+ stableId: string().min(1)
13805
+ }), CompositionCustomizationReadSchema, { auth: "admin" }), method(object({
13695
13806
  composition: CompositionSchema,
13696
13807
  blockId: string().optional()
13697
13808
  }), CompositionValidationSchema, {
@@ -15669,6 +15780,52 @@ method(object({
15669
15780
  deviceIds: array(number()).readonly(),
15670
15781
  caps: array(string()).readonly().optional()
15671
15782
  }), record(string(), record(string(), unknown().nullable())));
15783
+ /**
15784
+ * Per-field ownership (D663). A composition may CLAIM fields of a native
15785
+ * cap's runtime-state slice on an existing device: `replace` overlays the
15786
+ * claimed fields on the native slice, `add` builds a slice the device does
15787
+ * not natively have. Declared here — once — so the hub mirror (Task 5) and
15788
+ * the claim methods (Task 6) share one union.
15789
+ */
15790
+ var FieldClaimModeSchema = _enum(["replace", "add"]);
15791
+ /** What a claim, patch or release answers. A refusal always names its reason. */
15792
+ var ClaimOutcomeSchema = discriminatedUnion("ok", [object({
15793
+ ok: literal(true),
15794
+ changed: boolean()
15795
+ }), object({
15796
+ ok: literal(false),
15797
+ code: _enum([
15798
+ "owned-by-other",
15799
+ "not-claimed",
15800
+ "field-not-claimed",
15801
+ "invalid-slice",
15802
+ "unknown-device",
15803
+ "row-unreadable",
15804
+ "migration-in-flight"
15805
+ ]),
15806
+ message: string()
15807
+ })]);
15808
+ /** One claim as `listClaims` reports it. `fields` is the PLANNED set, explicit (ruling S5). */
15809
+ var FieldClaimSchema = object({
15810
+ deviceId: number(),
15811
+ capName: string(),
15812
+ owner: string().min(1),
15813
+ mode: FieldClaimModeSchema,
15814
+ fields: array(string().min(1)).min(1)
15815
+ });
15816
+ /**
15817
+ * What `listClaims` answers. The claims INDEX is a listing projection loaded
15818
+ * from the settings store at boot; until a load has succeeded it is
15819
+ * `not-loaded`, never `[]` — an unanswered question must not look like an
15820
+ * empty answer (D315).
15821
+ */
15822
+ var ClaimsListingSchema = discriminatedUnion("state", [object({
15823
+ state: literal("loaded"),
15824
+ claims: array(FieldClaimSchema)
15825
+ }), object({
15826
+ state: literal("not-loaded"),
15827
+ reason: string()
15828
+ })]);
15672
15829
  method(object({ deviceId: number() }), record(string(), record(string(), unknown()))), method(object({
15673
15830
  deviceId: number(),
15674
15831
  capName: string()
@@ -15676,7 +15833,35 @@ method(object({ deviceId: number() }), record(string(), record(string(), unknown
15676
15833
  deviceId: number(),
15677
15834
  capName: string(),
15678
15835
  slice: record(string(), unknown())
15679
- }), _void(), { kind: "mutation" }), object({
15836
+ }), _void(), { kind: "mutation" }), method(object({
15837
+ deviceId: number(),
15838
+ capName: string(),
15839
+ owner: string().min(1),
15840
+ mode: FieldClaimModeSchema,
15841
+ fields: array(string().min(1)).min(1),
15842
+ values: record(string(), unknown())
15843
+ }), ClaimOutcomeSchema, {
15844
+ kind: "mutation",
15845
+ auth: "admin"
15846
+ }), method(object({
15847
+ deviceId: number(),
15848
+ capName: string(),
15849
+ owner: string().min(1),
15850
+ values: record(string(), unknown())
15851
+ }), ClaimOutcomeSchema, {
15852
+ kind: "mutation",
15853
+ auth: "admin"
15854
+ }), method(object({
15855
+ deviceId: number(),
15856
+ capName: string(),
15857
+ owner: string().min(1)
15858
+ }), ClaimOutcomeSchema, {
15859
+ kind: "mutation",
15860
+ auth: "admin"
15861
+ }), method(object({ ownerPrefix: string().optional() }), ClaimsListingSchema, { auth: "admin" }), method(object({
15862
+ deviceId: number(),
15863
+ capName: string()
15864
+ }), record(string(), unknown()).nullable(), { auth: "admin" }), object({
15680
15865
  deviceId: number(),
15681
15866
  capName: string(),
15682
15867
  slice: record(string(), unknown())
@@ -28988,7 +29173,7 @@ var ExtensionSourceSchema = object({
28988
29173
  /**
28989
29174
  * How one picked source stands. `missing` — no such device (deleted, or an id
28990
29175
  * that never existed); `foreign` — a device the slot's owner cannot read from
28991
- * (slice 2's cross-owner graft source); `unavailable` — it exists but is not a
29176
+ * (a source owned by another addon); `unavailable` — it exists but is not a
28992
29177
  * valid source now (a container, which is never a pick). A source in any of
28993
29178
  * these states is SHOWN, never silently dropped or re-pointed.
28994
29179
  */
@@ -29028,10 +29213,6 @@ var ExtensionSlotRowSchema = discriminatedUnion("state", [object({
29028
29213
  slot: ExtensionSlotIdSchema,
29029
29214
  /** Names the store written (D62), e.g. `virtual-doorbell:doorbellSources`. */
29030
29215
  authority: string(),
29031
- /** graft: its switch. association: null — removing every source is "off". */
29032
- enabled: boolean().nullable(),
29033
- /** graft: the cap registered on the host. association: null. */
29034
- projects: string().nullable(),
29035
29216
  activation: ExtensionSlotActivationSchema.nullable(),
29036
29217
  value: ExtensionSlotValueSchema,
29037
29218
  sources: array(ExtensionSlotSourceSchema),
@@ -29065,7 +29246,8 @@ var ExtensionCandidatesSchema = object({ candidates: array(object({
29065
29246
  /**
29066
29247
  * What a write did. `none` — applied in place. `host` / `parent` — the owner
29067
29248
  * reloaded the host (or, for an accessory child, its parent) because the
29068
- * write changed what the host REGISTERS (a graft switched on or off, slice 2).
29249
+ * write changed what the host REGISTERS. A slot never adds a capability to its
29250
+ * host — that is a composition (D663) — so today's owners answer `none`.
29069
29251
  */
29070
29252
  var ExtensionSetResultSchema = object({
29071
29253
  applied: literal(true),
@@ -30273,7 +30455,9 @@ var batteryCapability = {
30273
30455
  DeviceType.Camera,
30274
30456
  DeviceType.Sensor,
30275
30457
  DeviceType.Button,
30276
- DeviceType.Switch
30458
+ DeviceType.Switch,
30459
+ DeviceType.PetFeeder,
30460
+ DeviceType.Container
30277
30461
  ],
30278
30462
  methods: {
30279
30463
  /**
@@ -30300,6 +30484,19 @@ wakeForStream: method(object({
30300
30484
  awoke: boolean(),
30301
30485
  durationMs: number()
30302
30486
  }), { kind: "mutation" }) },
30487
+ /**
30488
+ * A composed provider (battery ADDED to a pet feeder or container that has
30489
+ * no wake surface) answers `wakeForStream` with the same "unavailable"
30490
+ * result the contract already defines above: "Returns `awoke: false` when
30491
+ * … the cap surface is unavailable." (D663).
30492
+ */
30493
+ composedMethods: { wakeForStream: {
30494
+ kind: "answer",
30495
+ value: {
30496
+ awoke: false,
30497
+ durationMs: 0
30498
+ }
30499
+ } },
30303
30500
  events: {
30304
30501
  /**
30305
30502
  * Emitted whenever the cached status changes (firmware push OR
@@ -30315,7 +30512,7 @@ onStatusChanged: { data: object({
30315
30512
  schema: BatteryStatusSchema,
30316
30513
  kind: "push",
30317
30514
  empty: {
30318
- percentage: 0,
30515
+ percentage: null,
30319
30516
  charging: "unknown",
30320
30517
  sleeping: false,
30321
30518
  lastUpdated: 0
@@ -31040,6 +31237,19 @@ var connectivityCapability = {
31040
31237
  * provider populates it by guessing (no HA inference). The UI renders a
31041
31238
  * "No consumables reported" placeholder when `items` is empty.
31042
31239
  */
31240
+ /** Units a remaining-life counter is reported in. Closed: the panel renders
31241
+ * each. */
31242
+ var ConsumableRemainingUnitSchema = _enum([
31243
+ "days",
31244
+ "hours",
31245
+ "cycles"
31246
+ ]);
31247
+ /** Remaining life as a COUNT, not a percentage (the PetKit desiccant reports
31248
+ * days). `value` null = unknown (D393). */
31249
+ var ConsumableRemainingSchema = object({
31250
+ value: number().min(0).nullable(),
31251
+ unit: ConsumableRemainingUnitSchema
31252
+ });
31043
31253
  /** A single consumable item. Either a continuous `level` (remaining
31044
31254
  * life %) or a discrete `status` may be known — both may be null when a
31045
31255
  * provider only knows the item exists. `level` and `status` are not
@@ -31056,7 +31266,10 @@ var ConsumableItemSchema = object({
31056
31266
  /** Ms epoch of the last replace, when known. */
31057
31267
  lastResetAt: number().nullable(),
31058
31268
  /** Whether `reset()` is meaningful for this item. */
31059
- resettable: boolean()
31269
+ resettable: boolean(),
31270
+ /** Remaining life as a count (days/hours/cycles), when a provider reports
31271
+ * it that way instead of — or alongside — `level`. Absent = not reported. */
31272
+ remaining: ConsumableRemainingSchema.optional()
31060
31273
  });
31061
31274
  var ConsumablesStatusSchema = object({
31062
31275
  items: array(ConsumableItemSchema),
@@ -31086,6 +31299,16 @@ reset: method(object({
31086
31299
  kind: "mutation",
31087
31300
  auth: "admin"
31088
31301
  }) },
31302
+ /**
31303
+ * A composed consumable (item added to a device with no native consumables
31304
+ * provider) refuses `reset` by name: "Only meaningful when the item's
31305
+ * `resettable` is true" — a composed item is never resettable until
31306
+ * commands (`forward`) arrive in slice 3 (D663).
31307
+ */
31308
+ composedMethods: { reset: {
31309
+ kind: "refuse",
31310
+ reason: "a composed consumable is not resettable until commands (forward) arrive — slice 3"
31311
+ } },
31089
31312
  status: {
31090
31313
  schema: ConsumablesStatusSchema,
31091
31314
  kind: "push",
@@ -40396,6 +40619,10 @@ Object.freeze({
40396
40619
  latest: {
40397
40620
  kind: "fixed",
40398
40621
  result: ["number", "null"]
40622
+ },
40623
+ number: {
40624
+ kind: "fixed",
40625
+ result: ["number", "null"]
40399
40626
  }
40400
40627
  });
40401
40628
  `${COMPOSED_DEVICE_STABLE_ID_PREFIX}`;
@@ -40595,38 +40822,31 @@ var DeviceConfig = class DeviceConfig {
40595
40822
  }));
40596
40823
  }
40597
40824
  };
40598
- /**
40599
- * Concrete implementation. Routes every successful write through
40600
- * `writer(capName, slice)` — the kernel hooks this up to
40601
- * `device-state.setCapSlice`, the canonical cross-layer write
40602
- * entrypoint, which handles disk persistence (debounced on the hub)
40603
- * and mirror updates.
40604
- *
40605
- * Schema validation runs in-process before the writer is called —
40606
- * the round-trip should never carry an invalid slice. `flush()`
40607
- * awaits any in-flight writer promises so shutdown is lossless.
40608
- *
40609
- * `initial` is the persisted blob loaded at boot. Slices for caps
40610
- * whose schema hasn't been installed yet are kept in-memory verbatim
40611
- * and validated when the cap registers later.
40612
- */
40613
40825
  var DeviceRuntimeState = class DeviceRuntimeState {
40614
40826
  writer;
40827
+ onWriteFailed;
40615
40828
  /** In-flight writer promises tracked so `flush()` can await them. */
40616
40829
  pendingWrites = /* @__PURE__ */ new Set();
40830
+ /**
40831
+ * Caps whose last write the hub refused. The equality gate in
40832
+ * `applyCapWrite` is bypassed for these, so the SAME value is re-sent on the
40833
+ * next write instead of being swallowed forever; a landing write clears it.
40834
+ */
40835
+ failedWrites = /* @__PURE__ */ new Set();
40617
40836
  /** Per-cap committed slice — after schema validation when known. */
40618
40837
  slices;
40619
40838
  /** Per-cap registered schema (set by `installCapSchema`). */
40620
40839
  schemas = /* @__PURE__ */ new Map();
40621
40840
  listeners = /* @__PURE__ */ new Set();
40622
40841
  capListeners = /* @__PURE__ */ new Map();
40623
- constructor(initial, writer) {
40842
+ constructor(initial, writer, onWriteFailed) {
40624
40843
  this.writer = writer;
40844
+ this.onWriteFailed = onWriteFailed;
40625
40845
  this.slices = /* @__PURE__ */ new Map();
40626
40846
  for (const [k, v] of Object.entries(initial)) if (v && typeof v === "object" && !Array.isArray(v)) this.slices.set(k, { ...v });
40627
40847
  }
40628
- static fromInitial(initial, writer) {
40629
- return new DeviceRuntimeState(initial, writer);
40848
+ static fromInitial(initial, writer, onWriteFailed) {
40849
+ return new DeviceRuntimeState(initial, writer, onWriteFailed);
40630
40850
  }
40631
40851
  installCapSchema(capName, schema) {
40632
40852
  const existing = this.schemas.get(capName);
@@ -40670,10 +40890,15 @@ var DeviceRuntimeState = class DeviceRuntimeState {
40670
40890
  ...value
40671
40891
  } : { ...value };
40672
40892
  const parsed = schema.parse(next);
40673
- if (shallowEqual(current, parsed)) return;
40893
+ if (shallowEqual(current, parsed) && !this.failedWrites.has(capName)) return;
40674
40894
  this.slices.set(capName, parsed);
40675
40895
  this.fireListeners([capName]);
40676
- const writePromise = this.writer(capName, { ...parsed }).catch(() => {});
40896
+ const writePromise = this.writer(capName, { ...parsed }).then(() => {
40897
+ this.failedWrites.delete(capName);
40898
+ }).catch((err) => {
40899
+ this.failedWrites.add(capName);
40900
+ this.onWriteFailed?.(capName, err instanceof Error ? err.message : String(err));
40901
+ });
40677
40902
  this.pendingWrites.add(writePromise);
40678
40903
  writePromise.finally(() => {
40679
40904
  this.pendingWrites.delete(writePromise);
@@ -40950,7 +41175,16 @@ var BaseDevice = class {
40950
41175
  });
40951
41176
  };
40952
41177
  const initial = ctx.initialRuntimeState ?? {};
40953
- this.runtimeState = DeviceRuntimeState.fromInitial(initial, writer);
41178
+ const onWriteFailed = (cap, error) => {
41179
+ ctx.logger.warn("runtime-state write refused by the hub — the slice is not mirrored; it is re-sent on the next write", {
41180
+ tags: { deviceId: ctx.id },
41181
+ meta: {
41182
+ cap,
41183
+ error
41184
+ }
41185
+ });
41186
+ };
41187
+ this.runtimeState = DeviceRuntimeState.fromInitial(initial, writer, onWriteFailed);
40954
41188
  ctx.bindRuntimeState?.(this.runtimeState);
40955
41189
  ctx.registerNativeCap?.(deviceStatusCapability, {});
40956
41190
  const seed = {
@@ -43896,6 +44130,12 @@ Object.freeze({
43896
44130
  addonId: null,
43897
44131
  access: "view"
43898
44132
  },
44133
+ "coreBlocks.getCustomization": {
44134
+ capName: "core-blocks",
44135
+ capScope: "system",
44136
+ addonId: null,
44137
+ access: "view"
44138
+ },
43899
44139
  "coreBlocks.getTypeDefs": {
43900
44140
  capName: "core-blocks",
43901
44141
  capScope: "system",
@@ -45018,6 +45258,12 @@ Object.freeze({
45018
45258
  addonId: null,
45019
45259
  access: "create"
45020
45260
  },
45261
+ "deviceState.claimFields": {
45262
+ capName: "device-state",
45263
+ capScope: "system",
45264
+ addonId: null,
45265
+ access: "create"
45266
+ },
45021
45267
  "deviceState.getAllSnapshots": {
45022
45268
  capName: "device-state",
45023
45269
  capScope: "system",
@@ -45030,12 +45276,36 @@ Object.freeze({
45030
45276
  addonId: null,
45031
45277
  access: "view"
45032
45278
  },
45279
+ "deviceState.getNativeCapSlice": {
45280
+ capName: "device-state",
45281
+ capScope: "system",
45282
+ addonId: null,
45283
+ access: "view"
45284
+ },
45033
45285
  "deviceState.getSnapshot": {
45034
45286
  capName: "device-state",
45035
45287
  capScope: "system",
45036
45288
  addonId: null,
45037
45289
  access: "view"
45038
45290
  },
45291
+ "deviceState.listClaims": {
45292
+ capName: "device-state",
45293
+ capScope: "system",
45294
+ addonId: null,
45295
+ access: "view"
45296
+ },
45297
+ "deviceState.patchOwnedFields": {
45298
+ capName: "device-state",
45299
+ capScope: "system",
45300
+ addonId: null,
45301
+ access: "create"
45302
+ },
45303
+ "deviceState.releaseClaim": {
45304
+ capName: "device-state",
45305
+ capScope: "system",
45306
+ addonId: null,
45307
+ access: "create"
45308
+ },
45039
45309
  "deviceState.setCapSlice": {
45040
45310
  capName: "device-state",
45041
45311
  capScope: "system",
@@ -50551,16 +50821,36 @@ Object.freeze({
50551
50821
  form: "single",
50552
50822
  optional: false
50553
50823
  }],
50824
+ "deviceState.claimFields": [{
50825
+ name: "deviceId",
50826
+ form: "single",
50827
+ optional: false
50828
+ }],
50554
50829
  "deviceState.getCapSlice": [{
50555
50830
  name: "deviceId",
50556
50831
  form: "single",
50557
50832
  optional: false
50558
50833
  }],
50834
+ "deviceState.getNativeCapSlice": [{
50835
+ name: "deviceId",
50836
+ form: "single",
50837
+ optional: false
50838
+ }],
50559
50839
  "deviceState.getSnapshot": [{
50560
50840
  name: "deviceId",
50561
50841
  form: "single",
50562
50842
  optional: false
50563
50843
  }],
50844
+ "deviceState.patchOwnedFields": [{
50845
+ name: "deviceId",
50846
+ form: "single",
50847
+ optional: false
50848
+ }],
50849
+ "deviceState.releaseClaim": [{
50850
+ name: "deviceId",
50851
+ form: "single",
50852
+ optional: false
50853
+ }],
50564
50854
  "deviceState.setCapSlice": [{
50565
50855
  name: "deviceId",
50566
50856
  form: "single",
@@ -54858,4 +55148,4 @@ function buildClipModelRegistry(entries) {
54858
55148
  /** The built-in catalog's registry. */
54859
55149
  var BUILTIN_CLIP_MODELS = buildClipModelRegistry(CLIP_IMAGE_MODELS);
54860
55150
  //#endregion
54861
- export { NcRuleSchema as $, EventCategory as $n, isSourceCap as $t, LabelAttributionSchema as A, canonicalHash as An, audioMetricsCapability as At, NC_DEFAULT_SNOOZE_MINUTES as B, normalizeNodeId as Bn, embeddingRefusalOf as Bt, EMBEDDING_REQUIREMENTS_FAILED as C, systemEventFilterApplies as Cn, TimelapseRuleInputSchema as Ct, FIRST_LEVEL_MACRO_CLASSES as D, vectorDimFromBase64 as Dn, addonWidgetsSourceCapability as Dt, EVENT_OWNER_TYPES as E, templateVarsFor as En, TrackSourceSchema as Et, MOTION_CLOSE_AFTER_MS as F, customAction as Fn, deriveRecordingMode as Ft, NC_TAXONOMY as G, discriminatedUnion as Gn, evictionPolicyOfLocation as Gt, NC_SNOOZE_MAX_MINUTES as H, _enum as Hn, embeddingRequirementsUnavailableError as Ht, MediaFileKindEnum as I, defineCustomActions as In, describeSystemEventDeliveryProblem as It, NcAlarmSettingsPatchSchema as J, object as Jn, isDetectionMacroClass as Jt, NC_TEMPLATE_VARS as K, literal as Kn, faceGalleryCapability as Kt, NC_ALARM_SYSTEM_EVENT_KINDS as L, hydrateSchema as Ln, describeSystemEventFamilyProblem as Lt, MAX_ARCHIVED_DEBUG_NOTES as M, CamProfileSchema as Mn, buildEventKindDescriptor as Mt, MAX_BIRTH_DECISION_RECORDS as N, DeviceType as Nn, consumableLowPercentOf as Nt, FULL_IMAGE_BBOX as O, zoneAnalyticsCapability as On, alarmPanelCapability as Ot, MAX_BIRTH_LATENCY_PROXY_MS as P, createEvent as Pn, cosineSimilarity as Pt, NcRulePatchSchema as Q, unknown as Qn, isScheduleActive as Qt, NC_CONDITION_CATALOG as R, isDeviceScopedCap as Rn, embeddingEncoderCapability as Rt, EMBEDDING_MODEL_FAILED as S, systemEventFamilyProblems as Sn, TIMELAPSE_DENSE_FLOOR_SEC as St, EVENT_KIND_BY_CAP as T, templateCarriesSummary as Tn, TimelapseRuleSchema as Tt, NC_SYSTEM_EVENT_KIND_GROUP_SPECS as U, array as Un, encodeVectorBase64 as Ut, NC_SENSOR_TRANSITION_KINDS as V, sleep as Vn, embeddingRequirementsFailedError as Vt, NC_TANK_SYSTEM_EVENT_KINDS as W, boolean as Wn, evaluateSensorEdge as Wt, NcConditionsSchema as X, record as Xn, isKnownTemplateVar as Xt, NcConditionDescriptorSchema as Y, partialRecord as Yn, isEventOwnerType as Yt, NcRuleInputSchema as Z, string as Zn, isMediaOwnerType as Zt, DEFAULT_FIRST_SIGHTING_FRESHNESS_MS as _, sensorRaisedOf as _n, SCENE_CONFIRM_DEFAULT_TIMEOUT_MS as _t, CLIP_EMBEDDING_STEP_ID as a, notificationRulesCapability as an, NcSnoozeSuppressedSchema as at, DETECTION_PIPELINE_CAP_NAME as b, subKindsOf as bn, SCENE_DIVERGED as bt, resolveClusterModelPin as c, pipelineAnalyticsCapability as cn, NcTemplatePreviewInputSchema as ct, ArchivedDebugNoteSchema as d, readTimelapseGeneratedAt as dn, OpsLogEntrySchema as dt, isSystemDelivery as en, NcRuleTargetSchema as et, BaseDevice as f, resolveClipSearchMinScore as fn, PoolMemoryWatchdog as ft, DEFAULT_EVENT_COLOR as g, sceneMonitorCapability as gn, RetrainStatusSchema as gt, CoverStateSchema as h, ruleBelongsToAlarm as hn, RebuildObjectEmbeddingsRefusalSchema as ht, CLIP_TEXT_MODELS as i, mayWriteToLocation as in, NcSnoozeSchema as it, LockStateSchema as j, BaseAddon as jn, audioModeOf as jt, FailureCounters as k, errMsg as kn, assertTimelapseCadences as kt, AUDIO_MACRO_LABELS as l, plateGalleryCapability as ln, NcTemplatePreviewSchema as lt, COCO_TO_MACRO as m, resolvePoolMemoryPolicy as mn, RECORDING_SOURCE_CAMSTACK_ADDON as mt, DEFAULT_CLIP_MODEL as n, isVectorIndexDimConflict as nn, NcScheduleSchema as nt, FACE_EMBEDDING_STEP_ID as o, occupancyScope as on, NcSystemEventKindSchema as ot, BirthDecisionRecordSchema as p, resolveLocationMode as pn, RECORDING_EXPORT_MAX_READ_BYTES as pt, NcAlarmConfigSchema as q, number as qn, failureContributionCapability as qt, CLIP_IMAGE_MODELS as r, kebabToCamel as rn, NcSnoozeInputSchema as rt, readClusterModelPinStrict as s, parseProcStatus as sn, NcTaxonomySchema as st, BUILTIN_CLIP_MODELS as t, isSystemEventSummaryRule as tn, NcRuleUpdateInputSchema as tt, AlarmArmModeSchema as u, readDeviceStateFrom as un, NcTemplateVarDescriptorSchema as ut, DEFAULT_TIMELAPSE_PREVIEW_TEXT as v, servedTemplateVars as vn, SCENE_DEFAULT_ANCHOR_THRESHOLD as vt, EMBEDDING_REQUIREMENTS_UNAVAILABLE as w, systemEventSummaryScheduleOf as wn, TimelapseRulePatchSchema as wt, DeclaredDevices as x, systemEventDeliveryProblems as xn, SceneMonitorSchema as xt, DETECTION_MACRO_CLASSES as y, storageOccupancyCapability as yn, SCENE_DEFAULT_UNCOVERED_POLICY as yt, NC_CONSUMABLE_SYSTEM_EVENT_KINDS as z, nodePin as zn, embeddingModelFailedError as zt };
55151
+ export { NcRuleSchema as $, EventCategory as $n, isSourceCap as $t, LabelAttributionSchema as A, DeviceType as An, audioMetricsCapability as At, NC_DEFAULT_SNOOZE_MINUTES as B, normalizeNodeId as Bn, embeddingRefusalOf as Bt, EMBEDDING_REQUIREMENTS_FAILED as C, systemEventFilterApplies as Cn, TimelapseRuleInputSchema as Ct, FIRST_LEVEL_MACRO_CLASSES as D, vectorDimFromBase64 as Dn, addonWidgetsSourceCapability as Dt, EVENT_OWNER_TYPES as E, templateVarsFor as En, TrackSourceSchema as Et, MOTION_CLOSE_AFTER_MS as F, customAction as Fn, deriveRecordingMode as Ft, NC_TAXONOMY as G, discriminatedUnion as Gn, evictionPolicyOfLocation as Gt, NC_SNOOZE_MAX_MINUTES as H, _enum as Hn, embeddingRequirementsUnavailableError as Ht, MediaFileKindEnum as I, defineCustomActions as In, describeSystemEventDeliveryProblem as It, NcAlarmSettingsPatchSchema as J, object as Jn, isDetectionMacroClass as Jt, NC_TEMPLATE_VARS as K, literal as Kn, faceGalleryCapability as Kt, NC_ALARM_SYSTEM_EVENT_KINDS as L, hydrateSchema as Ln, describeSystemEventFamilyProblem as Lt, MAX_ARCHIVED_DEBUG_NOTES as M, BaseAddon as Mn, buildEventKindDescriptor as Mt, MAX_BIRTH_DECISION_RECORDS as N, CamProfileSchema as Nn, consumableLowPercentOf as Nt, FULL_IMAGE_BBOX as O, zoneAnalyticsCapability as On, alarmPanelCapability as Ot, MAX_BIRTH_LATENCY_PROXY_MS as P, createEvent as Pn, cosineSimilarity as Pt, NcRulePatchSchema as Q, unknown as Qn, isScheduleActive as Qt, NC_CONDITION_CATALOG as R, isDeviceScopedCap as Rn, embeddingEncoderCapability as Rt, EMBEDDING_MODEL_FAILED as S, systemEventFamilyProblems as Sn, TIMELAPSE_DENSE_FLOOR_SEC as St, EVENT_KIND_BY_CAP as T, templateCarriesSummary as Tn, TimelapseRuleSchema as Tt, NC_SYSTEM_EVENT_KIND_GROUP_SPECS as U, array as Un, encodeVectorBase64 as Ut, NC_SENSOR_TRANSITION_KINDS as V, sleep as Vn, embeddingRequirementsFailedError as Vt, NC_TANK_SYSTEM_EVENT_KINDS as W, boolean as Wn, evaluateSensorEdge as Wt, NcConditionsSchema as X, record as Xn, isKnownTemplateVar as Xt, NcConditionDescriptorSchema as Y, partialRecord as Yn, isEventOwnerType as Yt, NcRuleInputSchema as Z, string as Zn, isMediaOwnerType as Zt, DEFAULT_FIRST_SIGHTING_FRESHNESS_MS as _, sensorRaisedOf as _n, SCENE_CONFIRM_DEFAULT_TIMEOUT_MS as _t, CLIP_EMBEDDING_STEP_ID as a, notificationRulesCapability as an, NcSnoozeSuppressedSchema as at, DETECTION_PIPELINE_CAP_NAME as b, subKindsOf as bn, SCENE_DIVERGED as bt, resolveClusterModelPin as c, pipelineAnalyticsCapability as cn, NcTemplatePreviewInputSchema as ct, ArchivedDebugNoteSchema as d, readTimelapseGeneratedAt as dn, OpsLogEntrySchema as dt, isSystemDelivery as en, NcRuleTargetSchema as et, BaseDevice as f, resolveClipSearchMinScore as fn, PoolMemoryWatchdog as ft, DEFAULT_EVENT_COLOR as g, sceneMonitorCapability as gn, RetrainStatusSchema as gt, CoverStateSchema as h, ruleBelongsToAlarm as hn, RebuildObjectEmbeddingsRefusalSchema as ht, CLIP_TEXT_MODELS as i, mayWriteToLocation as in, NcSnoozeSchema as it, LockStateSchema as j, errMsg as jn, audioModeOf as jt, FailureCounters as k, canonicalHash as kn, assertTimelapseCadences as kt, AUDIO_MACRO_LABELS as l, plateGalleryCapability as ln, NcTemplatePreviewSchema as lt, COCO_TO_MACRO as m, resolvePoolMemoryPolicy as mn, RECORDING_SOURCE_CAMSTACK_ADDON as mt, DEFAULT_CLIP_MODEL as n, isVectorIndexDimConflict as nn, NcScheduleSchema as nt, FACE_EMBEDDING_STEP_ID as o, occupancyScope as on, NcSystemEventKindSchema as ot, BirthDecisionRecordSchema as p, resolveLocationMode as pn, RECORDING_EXPORT_MAX_READ_BYTES as pt, NcAlarmConfigSchema as q, number as qn, failureContributionCapability as qt, CLIP_IMAGE_MODELS as r, kebabToCamel as rn, NcSnoozeInputSchema as rt, readClusterModelPinStrict as s, parseProcStatus as sn, NcTaxonomySchema as st, BUILTIN_CLIP_MODELS as t, isSystemEventSummaryRule as tn, NcRuleUpdateInputSchema as tt, AlarmArmModeSchema as u, readDeviceStateFrom as un, NcTemplateVarDescriptorSchema as ut, DEFAULT_TIMELAPSE_PREVIEW_TEXT as v, servedTemplateVars as vn, SCENE_DEFAULT_ANCHOR_THRESHOLD as vt, EMBEDDING_REQUIREMENTS_UNAVAILABLE as w, systemEventSummaryScheduleOf as wn, TimelapseRulePatchSchema as wt, DeclaredDevices as x, systemEventDeliveryProblems as xn, SceneMonitorSchema as xt, DETECTION_MACRO_CLASSES as y, storageOccupancyCapability as yn, SCENE_DEFAULT_UNCOVERED_POLICY as yt, NC_CONSUMABLE_SYSTEM_EVENT_KINDS as z, nodePin as zn, embeddingModelFailedError as zt };