@camstack/addon-advanced-notifier 1.1.3 → 1.1.5

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 (3) hide show
  1. package/dist/addon.js +136 -12
  2. package/dist/addon.mjs +136 -12
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -4632,7 +4632,7 @@ function _instanceof(cls, params = {}) {
4632
4632
  return inst;
4633
4633
  }
4634
4634
  //#endregion
4635
- //#region ../types/dist/sleep-BV7rLc6Y.mjs
4635
+ //#region ../types/dist/sleep-NOH4yRwj.mjs
4636
4636
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4637
4637
  EventCategory["SystemBoot"] = "system.boot";
4638
4638
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -5747,15 +5747,24 @@ var BaseAddon = class {
5747
5747
  } catch {}
5748
5748
  }
5749
5749
  /**
5750
- * Resolve the shared models directory path via the storage capability.
5751
- * Falls back to `camstack-data/models` if storage is unavailable.
5752
- * Used by inference addons (detection-pipeline, audio-classifier, embedding-encoder).
5750
+ * Resolve the shared, node-local models directory: `<nodeDataDir>/models`.
5751
+ *
5752
+ * This is the SINGLE centralized models-dir resolver for every inference addon
5753
+ * (detection-pipeline, audio-analyzer, embedding-encoder, model-studio). It is
5754
+ * anchored at the node's own writable data root (`ctx.nodeDataDir`, i.e.
5755
+ * `CAMSTACK_DATA` ?? the kernel boot dir) — never the hub-singleton `storage`
5756
+ * cap, which returns the hub's `/data/models` to every node and breaks any node
5757
+ * whose real data dir differs (e.g. the macOS electron agent).
5758
+ *
5759
+ * `node:path`/`node:fs` are imported dynamically so the `@camstack/types` root
5760
+ * barrel (re-exported into browser bundles) stays free of node builtins.
5753
5761
  */
5754
5762
  async resolveModelsDir() {
5755
- return this.ctx.api.storage.resolve.query({
5756
- location: "models",
5757
- relativePath: ""
5758
- }).catch(() => "camstack-data/models");
5763
+ const { join } = await import("node:path");
5764
+ const { promises: fsp } = await import("node:fs");
5765
+ const dir = join(this.ctx.nodeDataDir, "models");
5766
+ await fsp.mkdir(dir, { recursive: true });
5767
+ return dir;
5759
5768
  }
5760
5769
  /**
5761
5770
  * Access the runtime capability registry for in-process provider lookups.
@@ -7113,6 +7122,20 @@ var EncodeProfileSchema = object({
7113
7122
  */
7114
7123
  outputArgs: array(string()).optional()
7115
7124
  });
7125
+ /** Cosine similarity between two embedding vectors */
7126
+ function cosineSimilarity(a, b) {
7127
+ if (a.length !== b.length) return 0;
7128
+ let dotProduct = 0;
7129
+ let normA = 0;
7130
+ let normB = 0;
7131
+ for (let i = 0; i < a.length; i++) {
7132
+ dotProduct += a[i] * b[i];
7133
+ normA += a[i] * a[i];
7134
+ normB += b[i] * b[i];
7135
+ }
7136
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
7137
+ return denom === 0 ? 0 : dotProduct / denom;
7138
+ }
7116
7139
  var YAMNET_TO_MACRO = {
7117
7140
  mapping: {
7118
7141
  Speech: "speech",
@@ -12651,7 +12674,8 @@ method(object({
12651
12674
  sessionId: string().optional()
12652
12675
  }), ConvertResultSchema, {
12653
12676
  kind: "mutation",
12654
- auth: "admin"
12677
+ auth: "admin",
12678
+ timeoutMs: 6e5
12655
12679
  });
12656
12680
  var AddonHttpRouteSchema = object({
12657
12681
  method: _enum([
@@ -14195,7 +14219,14 @@ var NotificationRuleConditionsSchema = object({
14195
14219
  /** Match against `event.data.eventType` token (e.g. `'press_long'`). When non-empty, only events
14196
14220
  * carrying a matching `data.eventType` string pass this condition. Rules without this field are
14197
14221
  * unaffected (back-compat). Distinct from `rule.eventTypes` which holds EventCategory strings. */
14198
- eventTypeTokens: array(string()).readonly().optional()
14222
+ eventTypeTokens: array(string()).readonly().optional(),
14223
+ /** Match detections whose CLIP image embedding is semantically similar to this free-text
14224
+ * description. Requires the embedding-encoder cap to have pre-warmed the text vector.
14225
+ * `minSimilarity` is the cosine similarity threshold in [0, 1]. */
14226
+ clipDescription: object({
14227
+ text: string().min(1),
14228
+ minSimilarity: number().min(0).max(1)
14229
+ }).optional()
14199
14230
  });
14200
14231
  var NotificationRuleTemplateSchema = object({
14201
14232
  title: string(),
@@ -14449,6 +14480,16 @@ var TrackedDetectionSchema = object({
14449
14480
  zones: array(string()).readonly(),
14450
14481
  state: TrackStateSchema
14451
14482
  });
14483
+ var ScoredObjectEventSchema = ObjectEventSchema.extend({ score: number() });
14484
+ var SearchObjectEventsInput = object({
14485
+ text: string(),
14486
+ deviceId: number().optional(),
14487
+ since: number().optional(),
14488
+ until: number().optional(),
14489
+ classFilter: string().optional(),
14490
+ limit: number().default(50),
14491
+ minScore: number().min(0).max(1).default(.2)
14492
+ });
14452
14493
  DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).readonly()), method(object({
14453
14494
  deviceId: number(),
14454
14495
  trackId: string()
@@ -14483,7 +14524,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
14483
14524
  }), method(object({
14484
14525
  eventId: string(),
14485
14526
  kind: MediaFileKindEnum.optional()
14486
- }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileSchema).readonly()), object({
14527
+ }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
14487
14528
  deviceId: number(),
14488
14529
  timestamp: number(),
14489
14530
  frameWidth: number(),
@@ -19133,6 +19174,12 @@ Object.freeze({
19133
19174
  addonId: null,
19134
19175
  access: "create"
19135
19176
  },
19177
+ "pipelineAnalytics.searchObjectEvents": {
19178
+ capName: "pipeline-analytics",
19179
+ capScope: "device",
19180
+ addonId: null,
19181
+ access: "view"
19182
+ },
19136
19183
  "pipelineExecutor.cacheFrameInPool": {
19137
19184
  capName: "pipeline-executor",
19138
19185
  capScope: "system",
@@ -21055,6 +21102,17 @@ var RuleStore = class {
21055
21102
  //#endregion
21056
21103
  //#region src/rules/rule-engine.ts
21057
21104
  var RuleEngine = class {
21105
+ clip;
21106
+ constructor(clip) {
21107
+ this.clip = {
21108
+ textEmbeddingCache: clip?.textEmbeddingCache ?? /* @__PURE__ */ new Map(),
21109
+ encoderModelId: clip?.encoderModelId
21110
+ };
21111
+ }
21112
+ /** Update the active encoder's modelId after the cache is warmed. */
21113
+ updateEncoderModelId(modelId) {
21114
+ this.clip.encoderModelId = modelId;
21115
+ }
21058
21116
  evaluate(event, rules) {
21059
21117
  return rules.filter((rule) => this.matchesRule(event, rule));
21060
21118
  }
@@ -21099,6 +21157,17 @@ var RuleEngine = class {
21099
21157
  if (typeof rawToken !== "string") return false;
21100
21158
  if (!conditions.eventTypeTokens.includes(rawToken)) return false;
21101
21159
  }
21160
+ if (conditions.clipDescription !== void 0) {
21161
+ const { text, minSimilarity } = conditions.clipDescription;
21162
+ const eventModelId = data["embeddingModelId"];
21163
+ if (typeof eventModelId !== "string") return false;
21164
+ if (this.clip.encoderModelId !== void 0 && eventModelId !== this.clip.encoderModelId) return false;
21165
+ const textVec = this.clip.textEmbeddingCache.get(text);
21166
+ if (textVec === void 0) return false;
21167
+ const rawEmbedding = data["embedding"];
21168
+ if (!Array.isArray(rawEmbedding)) return false;
21169
+ if (cosineSimilarity(new Float32Array(textVec), new Float32Array(rawEmbedding)) < minSimilarity) return false;
21170
+ }
21102
21171
  return true;
21103
21172
  }
21104
21173
  };
@@ -21223,6 +21292,8 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21223
21292
  outputs = [];
21224
21293
  auditLog;
21225
21294
  unsubscribers = [];
21295
+ clipTextCache = /* @__PURE__ */ new Map();
21296
+ clipEncoderModelId = void 0;
21226
21297
  notifier = {
21227
21298
  getRules: async () => ({ rules: this.cachedRules }),
21228
21299
  upsertRule: async ({ rule }) => {
@@ -21234,6 +21305,8 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21234
21305
  ...this.cachedRules.slice(idx + 1)
21235
21306
  ];
21236
21307
  else this.cachedRules = [...this.cachedRules, rule];
21308
+ const api = this.ctx.api;
21309
+ if (api) await this.warmClipCache(this.cachedRules, api);
21237
21310
  return { success: true };
21238
21311
  },
21239
21312
  deleteRule: async ({ ruleId }) => {
@@ -21262,7 +21335,10 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21262
21335
  }
21263
21336
  };
21264
21337
  async onInitialize() {
21265
- this.ruleEngine = new RuleEngine();
21338
+ this.ruleEngine = new RuleEngine({
21339
+ textEmbeddingCache: this.clipTextCache,
21340
+ encoderModelId: this.clipEncoderModelId
21341
+ });
21266
21342
  this.cooldown = new CooldownTracker();
21267
21343
  this.consoleOutput = new ConsoleOutput(this.ctx.logger.child("console-output"));
21268
21344
  this.outputs = [this.consoleOutput];
@@ -21272,6 +21348,7 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21272
21348
  this.auditLog = new AuditLog(api);
21273
21349
  this.cachedRules = await this.ruleStore.getRules();
21274
21350
  this.ctx.logger.info("Loaded notification rules", { meta: { count: this.cachedRules.length } });
21351
+ await this.warmClipCache(this.cachedRules, api);
21275
21352
  } else this.ctx.logger.warn("No ctx.api — rules will not be persisted");
21276
21353
  for (const category of EVENT_CATEGORIES) {
21277
21354
  const unsubscribe = this.ctx.eventBus.subscribe({ category }, (event) => {
@@ -21341,6 +21418,53 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21341
21418
  ]
21342
21419
  }] });
21343
21420
  }
21421
+ /**
21422
+ * Pre-warm the CLIP text embedding cache for all `clipDescription.text`
21423
+ * values present in the current rule set.
21424
+ *
21425
+ * Called once at boot (after rules are loaded) and again after any
21426
+ * `upsertRule` mutation. Fetches the encoder's `modelId` via `getInfo` so
21427
+ * the evaluation gate (`embeddingModelId` on the detection event) is always
21428
+ * aligned with the active CLIP variant.
21429
+ *
21430
+ * This is the ONLY async boundary for CLIP in the notifier; `evaluateConditions`
21431
+ * in `RuleEngine` stays synchronous by doing an O(1) `Map.get()` lookup.
21432
+ */
21433
+ async warmClipCache(rules, api) {
21434
+ const texts = /* @__PURE__ */ new Set();
21435
+ for (const rule of rules) {
21436
+ const text = rule.conditions.clipDescription?.text;
21437
+ if (text !== void 0) texts.add(text);
21438
+ }
21439
+ if (texts.size === 0) return;
21440
+ const info = await api.embeddingEncoder.getInfo.query().catch((err) => {
21441
+ this.ctx.logger.warn("warmClipCache: embeddingEncoder.getInfo failed", { meta: { error: String(err) } });
21442
+ return null;
21443
+ });
21444
+ if (!info?.ready) {
21445
+ this.ctx.logger.debug("warmClipCache: embedding encoder not ready — cache not warmed", { meta: { pendingTexts: texts.size } });
21446
+ return;
21447
+ }
21448
+ this.ruleEngine.updateEncoderModelId(info.modelId);
21449
+ this.clipEncoderModelId = info.modelId;
21450
+ for (const text of texts) {
21451
+ if (this.clipTextCache.has(text)) continue;
21452
+ const result = await api.embeddingEncoder.encodeText.query({ text }).catch((err) => {
21453
+ this.ctx.logger.warn("warmClipCache: encodeText failed", { meta: {
21454
+ text,
21455
+ error: String(err)
21456
+ } });
21457
+ return null;
21458
+ });
21459
+ if (result) {
21460
+ this.clipTextCache.set(text, result.embedding);
21461
+ this.ctx.logger.debug("warmClipCache: cached text embedding", { meta: {
21462
+ text,
21463
+ modelId: info.modelId
21464
+ } });
21465
+ }
21466
+ }
21467
+ }
21344
21468
  async handleEvent(event) {
21345
21469
  const matchedRules = this.ruleEngine.evaluate(event, this.cachedRules);
21346
21470
  for (const rule of matchedRules) {
package/dist/addon.mjs CHANGED
@@ -4628,7 +4628,7 @@ function _instanceof(cls, params = {}) {
4628
4628
  return inst;
4629
4629
  }
4630
4630
  //#endregion
4631
- //#region ../types/dist/sleep-BV7rLc6Y.mjs
4631
+ //#region ../types/dist/sleep-NOH4yRwj.mjs
4632
4632
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4633
4633
  EventCategory["SystemBoot"] = "system.boot";
4634
4634
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -5743,15 +5743,24 @@ var BaseAddon = class {
5743
5743
  } catch {}
5744
5744
  }
5745
5745
  /**
5746
- * Resolve the shared models directory path via the storage capability.
5747
- * Falls back to `camstack-data/models` if storage is unavailable.
5748
- * Used by inference addons (detection-pipeline, audio-classifier, embedding-encoder).
5746
+ * Resolve the shared, node-local models directory: `<nodeDataDir>/models`.
5747
+ *
5748
+ * This is the SINGLE centralized models-dir resolver for every inference addon
5749
+ * (detection-pipeline, audio-analyzer, embedding-encoder, model-studio). It is
5750
+ * anchored at the node's own writable data root (`ctx.nodeDataDir`, i.e.
5751
+ * `CAMSTACK_DATA` ?? the kernel boot dir) — never the hub-singleton `storage`
5752
+ * cap, which returns the hub's `/data/models` to every node and breaks any node
5753
+ * whose real data dir differs (e.g. the macOS electron agent).
5754
+ *
5755
+ * `node:path`/`node:fs` are imported dynamically so the `@camstack/types` root
5756
+ * barrel (re-exported into browser bundles) stays free of node builtins.
5749
5757
  */
5750
5758
  async resolveModelsDir() {
5751
- return this.ctx.api.storage.resolve.query({
5752
- location: "models",
5753
- relativePath: ""
5754
- }).catch(() => "camstack-data/models");
5759
+ const { join } = await import("node:path");
5760
+ const { promises: fsp } = await import("node:fs");
5761
+ const dir = join(this.ctx.nodeDataDir, "models");
5762
+ await fsp.mkdir(dir, { recursive: true });
5763
+ return dir;
5755
5764
  }
5756
5765
  /**
5757
5766
  * Access the runtime capability registry for in-process provider lookups.
@@ -7109,6 +7118,20 @@ var EncodeProfileSchema = object({
7109
7118
  */
7110
7119
  outputArgs: array(string()).optional()
7111
7120
  });
7121
+ /** Cosine similarity between two embedding vectors */
7122
+ function cosineSimilarity(a, b) {
7123
+ if (a.length !== b.length) return 0;
7124
+ let dotProduct = 0;
7125
+ let normA = 0;
7126
+ let normB = 0;
7127
+ for (let i = 0; i < a.length; i++) {
7128
+ dotProduct += a[i] * b[i];
7129
+ normA += a[i] * a[i];
7130
+ normB += b[i] * b[i];
7131
+ }
7132
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
7133
+ return denom === 0 ? 0 : dotProduct / denom;
7134
+ }
7112
7135
  var YAMNET_TO_MACRO = {
7113
7136
  mapping: {
7114
7137
  Speech: "speech",
@@ -12647,7 +12670,8 @@ method(object({
12647
12670
  sessionId: string().optional()
12648
12671
  }), ConvertResultSchema, {
12649
12672
  kind: "mutation",
12650
- auth: "admin"
12673
+ auth: "admin",
12674
+ timeoutMs: 6e5
12651
12675
  });
12652
12676
  var AddonHttpRouteSchema = object({
12653
12677
  method: _enum([
@@ -14191,7 +14215,14 @@ var NotificationRuleConditionsSchema = object({
14191
14215
  /** Match against `event.data.eventType` token (e.g. `'press_long'`). When non-empty, only events
14192
14216
  * carrying a matching `data.eventType` string pass this condition. Rules without this field are
14193
14217
  * unaffected (back-compat). Distinct from `rule.eventTypes` which holds EventCategory strings. */
14194
- eventTypeTokens: array(string()).readonly().optional()
14218
+ eventTypeTokens: array(string()).readonly().optional(),
14219
+ /** Match detections whose CLIP image embedding is semantically similar to this free-text
14220
+ * description. Requires the embedding-encoder cap to have pre-warmed the text vector.
14221
+ * `minSimilarity` is the cosine similarity threshold in [0, 1]. */
14222
+ clipDescription: object({
14223
+ text: string().min(1),
14224
+ minSimilarity: number().min(0).max(1)
14225
+ }).optional()
14195
14226
  });
14196
14227
  var NotificationRuleTemplateSchema = object({
14197
14228
  title: string(),
@@ -14445,6 +14476,16 @@ var TrackedDetectionSchema = object({
14445
14476
  zones: array(string()).readonly(),
14446
14477
  state: TrackStateSchema
14447
14478
  });
14479
+ var ScoredObjectEventSchema = ObjectEventSchema.extend({ score: number() });
14480
+ var SearchObjectEventsInput = object({
14481
+ text: string(),
14482
+ deviceId: number().optional(),
14483
+ since: number().optional(),
14484
+ until: number().optional(),
14485
+ classFilter: string().optional(),
14486
+ limit: number().default(50),
14487
+ minScore: number().min(0).max(1).default(.2)
14488
+ });
14448
14489
  DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).readonly()), method(object({
14449
14490
  deviceId: number(),
14450
14491
  trackId: string()
@@ -14479,7 +14520,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
14479
14520
  }), method(object({
14480
14521
  eventId: string(),
14481
14522
  kind: MediaFileKindEnum.optional()
14482
- }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileSchema).readonly()), object({
14523
+ }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
14483
14524
  deviceId: number(),
14484
14525
  timestamp: number(),
14485
14526
  frameWidth: number(),
@@ -19129,6 +19170,12 @@ Object.freeze({
19129
19170
  addonId: null,
19130
19171
  access: "create"
19131
19172
  },
19173
+ "pipelineAnalytics.searchObjectEvents": {
19174
+ capName: "pipeline-analytics",
19175
+ capScope: "device",
19176
+ addonId: null,
19177
+ access: "view"
19178
+ },
19132
19179
  "pipelineExecutor.cacheFrameInPool": {
19133
19180
  capName: "pipeline-executor",
19134
19181
  capScope: "system",
@@ -21051,6 +21098,17 @@ var RuleStore = class {
21051
21098
  //#endregion
21052
21099
  //#region src/rules/rule-engine.ts
21053
21100
  var RuleEngine = class {
21101
+ clip;
21102
+ constructor(clip) {
21103
+ this.clip = {
21104
+ textEmbeddingCache: clip?.textEmbeddingCache ?? /* @__PURE__ */ new Map(),
21105
+ encoderModelId: clip?.encoderModelId
21106
+ };
21107
+ }
21108
+ /** Update the active encoder's modelId after the cache is warmed. */
21109
+ updateEncoderModelId(modelId) {
21110
+ this.clip.encoderModelId = modelId;
21111
+ }
21054
21112
  evaluate(event, rules) {
21055
21113
  return rules.filter((rule) => this.matchesRule(event, rule));
21056
21114
  }
@@ -21095,6 +21153,17 @@ var RuleEngine = class {
21095
21153
  if (typeof rawToken !== "string") return false;
21096
21154
  if (!conditions.eventTypeTokens.includes(rawToken)) return false;
21097
21155
  }
21156
+ if (conditions.clipDescription !== void 0) {
21157
+ const { text, minSimilarity } = conditions.clipDescription;
21158
+ const eventModelId = data["embeddingModelId"];
21159
+ if (typeof eventModelId !== "string") return false;
21160
+ if (this.clip.encoderModelId !== void 0 && eventModelId !== this.clip.encoderModelId) return false;
21161
+ const textVec = this.clip.textEmbeddingCache.get(text);
21162
+ if (textVec === void 0) return false;
21163
+ const rawEmbedding = data["embedding"];
21164
+ if (!Array.isArray(rawEmbedding)) return false;
21165
+ if (cosineSimilarity(new Float32Array(textVec), new Float32Array(rawEmbedding)) < minSimilarity) return false;
21166
+ }
21098
21167
  return true;
21099
21168
  }
21100
21169
  };
@@ -21219,6 +21288,8 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21219
21288
  outputs = [];
21220
21289
  auditLog;
21221
21290
  unsubscribers = [];
21291
+ clipTextCache = /* @__PURE__ */ new Map();
21292
+ clipEncoderModelId = void 0;
21222
21293
  notifier = {
21223
21294
  getRules: async () => ({ rules: this.cachedRules }),
21224
21295
  upsertRule: async ({ rule }) => {
@@ -21230,6 +21301,8 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21230
21301
  ...this.cachedRules.slice(idx + 1)
21231
21302
  ];
21232
21303
  else this.cachedRules = [...this.cachedRules, rule];
21304
+ const api = this.ctx.api;
21305
+ if (api) await this.warmClipCache(this.cachedRules, api);
21233
21306
  return { success: true };
21234
21307
  },
21235
21308
  deleteRule: async ({ ruleId }) => {
@@ -21258,7 +21331,10 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21258
21331
  }
21259
21332
  };
21260
21333
  async onInitialize() {
21261
- this.ruleEngine = new RuleEngine();
21334
+ this.ruleEngine = new RuleEngine({
21335
+ textEmbeddingCache: this.clipTextCache,
21336
+ encoderModelId: this.clipEncoderModelId
21337
+ });
21262
21338
  this.cooldown = new CooldownTracker();
21263
21339
  this.consoleOutput = new ConsoleOutput(this.ctx.logger.child("console-output"));
21264
21340
  this.outputs = [this.consoleOutput];
@@ -21268,6 +21344,7 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21268
21344
  this.auditLog = new AuditLog(api);
21269
21345
  this.cachedRules = await this.ruleStore.getRules();
21270
21346
  this.ctx.logger.info("Loaded notification rules", { meta: { count: this.cachedRules.length } });
21347
+ await this.warmClipCache(this.cachedRules, api);
21271
21348
  } else this.ctx.logger.warn("No ctx.api — rules will not be persisted");
21272
21349
  for (const category of EVENT_CATEGORIES) {
21273
21350
  const unsubscribe = this.ctx.eventBus.subscribe({ category }, (event) => {
@@ -21337,6 +21414,53 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21337
21414
  ]
21338
21415
  }] });
21339
21416
  }
21417
+ /**
21418
+ * Pre-warm the CLIP text embedding cache for all `clipDescription.text`
21419
+ * values present in the current rule set.
21420
+ *
21421
+ * Called once at boot (after rules are loaded) and again after any
21422
+ * `upsertRule` mutation. Fetches the encoder's `modelId` via `getInfo` so
21423
+ * the evaluation gate (`embeddingModelId` on the detection event) is always
21424
+ * aligned with the active CLIP variant.
21425
+ *
21426
+ * This is the ONLY async boundary for CLIP in the notifier; `evaluateConditions`
21427
+ * in `RuleEngine` stays synchronous by doing an O(1) `Map.get()` lookup.
21428
+ */
21429
+ async warmClipCache(rules, api) {
21430
+ const texts = /* @__PURE__ */ new Set();
21431
+ for (const rule of rules) {
21432
+ const text = rule.conditions.clipDescription?.text;
21433
+ if (text !== void 0) texts.add(text);
21434
+ }
21435
+ if (texts.size === 0) return;
21436
+ const info = await api.embeddingEncoder.getInfo.query().catch((err) => {
21437
+ this.ctx.logger.warn("warmClipCache: embeddingEncoder.getInfo failed", { meta: { error: String(err) } });
21438
+ return null;
21439
+ });
21440
+ if (!info?.ready) {
21441
+ this.ctx.logger.debug("warmClipCache: embedding encoder not ready — cache not warmed", { meta: { pendingTexts: texts.size } });
21442
+ return;
21443
+ }
21444
+ this.ruleEngine.updateEncoderModelId(info.modelId);
21445
+ this.clipEncoderModelId = info.modelId;
21446
+ for (const text of texts) {
21447
+ if (this.clipTextCache.has(text)) continue;
21448
+ const result = await api.embeddingEncoder.encodeText.query({ text }).catch((err) => {
21449
+ this.ctx.logger.warn("warmClipCache: encodeText failed", { meta: {
21450
+ text,
21451
+ error: String(err)
21452
+ } });
21453
+ return null;
21454
+ });
21455
+ if (result) {
21456
+ this.clipTextCache.set(text, result.embedding);
21457
+ this.ctx.logger.debug("warmClipCache: cached text embedding", { meta: {
21458
+ text,
21459
+ modelId: info.modelId
21460
+ } });
21461
+ }
21462
+ }
21463
+ }
21340
21464
  async handleEvent(event) {
21341
21465
  const matchedRules = this.ruleEngine.evaluate(event, this.cachedRules);
21342
21466
  for (const rule of matchedRules) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-advanced-notifier",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "Rules-based notification engine for CamStack",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",