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