@camstack/addon-advanced-notifier 1.1.4 → 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.
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-_J5S7OEP.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";
@@ -7122,6 +7122,20 @@ var EncodeProfileSchema = object({
7122
7122
  */
7123
7123
  outputArgs: array(string()).optional()
7124
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
+ }
7125
7139
  var YAMNET_TO_MACRO = {
7126
7140
  mapping: {
7127
7141
  Speech: "speech",
@@ -12660,7 +12674,8 @@ method(object({
12660
12674
  sessionId: string().optional()
12661
12675
  }), ConvertResultSchema, {
12662
12676
  kind: "mutation",
12663
- auth: "admin"
12677
+ auth: "admin",
12678
+ timeoutMs: 6e5
12664
12679
  });
12665
12680
  var AddonHttpRouteSchema = object({
12666
12681
  method: _enum([
@@ -14204,7 +14219,14 @@ var NotificationRuleConditionsSchema = object({
14204
14219
  /** Match against `event.data.eventType` token (e.g. `'press_long'`). When non-empty, only events
14205
14220
  * carrying a matching `data.eventType` string pass this condition. Rules without this field are
14206
14221
  * unaffected (back-compat). Distinct from `rule.eventTypes` which holds EventCategory strings. */
14207
- 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()
14208
14230
  });
14209
14231
  var NotificationRuleTemplateSchema = object({
14210
14232
  title: string(),
@@ -14458,6 +14480,16 @@ var TrackedDetectionSchema = object({
14458
14480
  zones: array(string()).readonly(),
14459
14481
  state: TrackStateSchema
14460
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
+ });
14461
14493
  DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).readonly()), method(object({
14462
14494
  deviceId: number(),
14463
14495
  trackId: string()
@@ -14492,7 +14524,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
14492
14524
  }), method(object({
14493
14525
  eventId: string(),
14494
14526
  kind: MediaFileKindEnum.optional()
14495
- }), 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({
14496
14528
  deviceId: number(),
14497
14529
  timestamp: number(),
14498
14530
  frameWidth: number(),
@@ -19142,6 +19174,12 @@ Object.freeze({
19142
19174
  addonId: null,
19143
19175
  access: "create"
19144
19176
  },
19177
+ "pipelineAnalytics.searchObjectEvents": {
19178
+ capName: "pipeline-analytics",
19179
+ capScope: "device",
19180
+ addonId: null,
19181
+ access: "view"
19182
+ },
19145
19183
  "pipelineExecutor.cacheFrameInPool": {
19146
19184
  capName: "pipeline-executor",
19147
19185
  capScope: "system",
@@ -21064,6 +21102,17 @@ var RuleStore = class {
21064
21102
  //#endregion
21065
21103
  //#region src/rules/rule-engine.ts
21066
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
+ }
21067
21116
  evaluate(event, rules) {
21068
21117
  return rules.filter((rule) => this.matchesRule(event, rule));
21069
21118
  }
@@ -21108,6 +21157,17 @@ var RuleEngine = class {
21108
21157
  if (typeof rawToken !== "string") return false;
21109
21158
  if (!conditions.eventTypeTokens.includes(rawToken)) return false;
21110
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
+ }
21111
21171
  return true;
21112
21172
  }
21113
21173
  };
@@ -21232,6 +21292,8 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21232
21292
  outputs = [];
21233
21293
  auditLog;
21234
21294
  unsubscribers = [];
21295
+ clipTextCache = /* @__PURE__ */ new Map();
21296
+ clipEncoderModelId = void 0;
21235
21297
  notifier = {
21236
21298
  getRules: async () => ({ rules: this.cachedRules }),
21237
21299
  upsertRule: async ({ rule }) => {
@@ -21243,6 +21305,8 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21243
21305
  ...this.cachedRules.slice(idx + 1)
21244
21306
  ];
21245
21307
  else this.cachedRules = [...this.cachedRules, rule];
21308
+ const api = this.ctx.api;
21309
+ if (api) await this.warmClipCache(this.cachedRules, api);
21246
21310
  return { success: true };
21247
21311
  },
21248
21312
  deleteRule: async ({ ruleId }) => {
@@ -21271,7 +21335,10 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21271
21335
  }
21272
21336
  };
21273
21337
  async onInitialize() {
21274
- this.ruleEngine = new RuleEngine();
21338
+ this.ruleEngine = new RuleEngine({
21339
+ textEmbeddingCache: this.clipTextCache,
21340
+ encoderModelId: this.clipEncoderModelId
21341
+ });
21275
21342
  this.cooldown = new CooldownTracker();
21276
21343
  this.consoleOutput = new ConsoleOutput(this.ctx.logger.child("console-output"));
21277
21344
  this.outputs = [this.consoleOutput];
@@ -21281,6 +21348,7 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21281
21348
  this.auditLog = new AuditLog(api);
21282
21349
  this.cachedRules = await this.ruleStore.getRules();
21283
21350
  this.ctx.logger.info("Loaded notification rules", { meta: { count: this.cachedRules.length } });
21351
+ await this.warmClipCache(this.cachedRules, api);
21284
21352
  } else this.ctx.logger.warn("No ctx.api — rules will not be persisted");
21285
21353
  for (const category of EVENT_CATEGORIES) {
21286
21354
  const unsubscribe = this.ctx.eventBus.subscribe({ category }, (event) => {
@@ -21350,6 +21418,53 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21350
21418
  ]
21351
21419
  }] });
21352
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
+ }
21353
21468
  async handleEvent(event) {
21354
21469
  const matchedRules = this.ruleEngine.evaluate(event, this.cachedRules);
21355
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-_J5S7OEP.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";
@@ -7118,6 +7118,20 @@ var EncodeProfileSchema = object({
7118
7118
  */
7119
7119
  outputArgs: array(string()).optional()
7120
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
+ }
7121
7135
  var YAMNET_TO_MACRO = {
7122
7136
  mapping: {
7123
7137
  Speech: "speech",
@@ -12656,7 +12670,8 @@ method(object({
12656
12670
  sessionId: string().optional()
12657
12671
  }), ConvertResultSchema, {
12658
12672
  kind: "mutation",
12659
- auth: "admin"
12673
+ auth: "admin",
12674
+ timeoutMs: 6e5
12660
12675
  });
12661
12676
  var AddonHttpRouteSchema = object({
12662
12677
  method: _enum([
@@ -14200,7 +14215,14 @@ var NotificationRuleConditionsSchema = object({
14200
14215
  /** Match against `event.data.eventType` token (e.g. `'press_long'`). When non-empty, only events
14201
14216
  * carrying a matching `data.eventType` string pass this condition. Rules without this field are
14202
14217
  * unaffected (back-compat). Distinct from `rule.eventTypes` which holds EventCategory strings. */
14203
- 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()
14204
14226
  });
14205
14227
  var NotificationRuleTemplateSchema = object({
14206
14228
  title: string(),
@@ -14454,6 +14476,16 @@ var TrackedDetectionSchema = object({
14454
14476
  zones: array(string()).readonly(),
14455
14477
  state: TrackStateSchema
14456
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
+ });
14457
14489
  DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).readonly()), method(object({
14458
14490
  deviceId: number(),
14459
14491
  trackId: string()
@@ -14488,7 +14520,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
14488
14520
  }), method(object({
14489
14521
  eventId: string(),
14490
14522
  kind: MediaFileKindEnum.optional()
14491
- }), 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({
14492
14524
  deviceId: number(),
14493
14525
  timestamp: number(),
14494
14526
  frameWidth: number(),
@@ -19138,6 +19170,12 @@ Object.freeze({
19138
19170
  addonId: null,
19139
19171
  access: "create"
19140
19172
  },
19173
+ "pipelineAnalytics.searchObjectEvents": {
19174
+ capName: "pipeline-analytics",
19175
+ capScope: "device",
19176
+ addonId: null,
19177
+ access: "view"
19178
+ },
19141
19179
  "pipelineExecutor.cacheFrameInPool": {
19142
19180
  capName: "pipeline-executor",
19143
19181
  capScope: "system",
@@ -21060,6 +21098,17 @@ var RuleStore = class {
21060
21098
  //#endregion
21061
21099
  //#region src/rules/rule-engine.ts
21062
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
+ }
21063
21112
  evaluate(event, rules) {
21064
21113
  return rules.filter((rule) => this.matchesRule(event, rule));
21065
21114
  }
@@ -21104,6 +21153,17 @@ var RuleEngine = class {
21104
21153
  if (typeof rawToken !== "string") return false;
21105
21154
  if (!conditions.eventTypeTokens.includes(rawToken)) return false;
21106
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
+ }
21107
21167
  return true;
21108
21168
  }
21109
21169
  };
@@ -21228,6 +21288,8 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21228
21288
  outputs = [];
21229
21289
  auditLog;
21230
21290
  unsubscribers = [];
21291
+ clipTextCache = /* @__PURE__ */ new Map();
21292
+ clipEncoderModelId = void 0;
21231
21293
  notifier = {
21232
21294
  getRules: async () => ({ rules: this.cachedRules }),
21233
21295
  upsertRule: async ({ rule }) => {
@@ -21239,6 +21301,8 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21239
21301
  ...this.cachedRules.slice(idx + 1)
21240
21302
  ];
21241
21303
  else this.cachedRules = [...this.cachedRules, rule];
21304
+ const api = this.ctx.api;
21305
+ if (api) await this.warmClipCache(this.cachedRules, api);
21242
21306
  return { success: true };
21243
21307
  },
21244
21308
  deleteRule: async ({ ruleId }) => {
@@ -21267,7 +21331,10 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21267
21331
  }
21268
21332
  };
21269
21333
  async onInitialize() {
21270
- this.ruleEngine = new RuleEngine();
21334
+ this.ruleEngine = new RuleEngine({
21335
+ textEmbeddingCache: this.clipTextCache,
21336
+ encoderModelId: this.clipEncoderModelId
21337
+ });
21271
21338
  this.cooldown = new CooldownTracker();
21272
21339
  this.consoleOutput = new ConsoleOutput(this.ctx.logger.child("console-output"));
21273
21340
  this.outputs = [this.consoleOutput];
@@ -21277,6 +21344,7 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21277
21344
  this.auditLog = new AuditLog(api);
21278
21345
  this.cachedRules = await this.ruleStore.getRules();
21279
21346
  this.ctx.logger.info("Loaded notification rules", { meta: { count: this.cachedRules.length } });
21347
+ await this.warmClipCache(this.cachedRules, api);
21280
21348
  } else this.ctx.logger.warn("No ctx.api — rules will not be persisted");
21281
21349
  for (const category of EVENT_CATEGORIES) {
21282
21350
  const unsubscribe = this.ctx.eventBus.subscribe({ category }, (event) => {
@@ -21346,6 +21414,53 @@ var AdvancedNotifierAddon = class extends BaseAddon {
21346
21414
  ]
21347
21415
  }] });
21348
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
+ }
21349
21464
  async handleEvent(event) {
21350
21465
  const matchedRules = this.ruleEngine.evaluate(event, this.cachedRules);
21351
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.4",
3
+ "version": "1.1.5",
4
4
  "description": "Rules-based notification engine for CamStack",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",