@betterdb/semantic-cache 0.2.0 → 0.4.0

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/README.md CHANGED
@@ -161,6 +161,24 @@ Cost savings scale with the model. Observed values from live examples:
161
161
  | `@betterdb/semantic-cache/embed/cohere` | `embed-english-v3.0` | 1024 |
162
162
  | `@betterdb/semantic-cache/embed/ollama` | `nomic-embed-text` | 768 |
163
163
 
164
+ ### Discovery markers
165
+
166
+ Starting in `0.2.0`, `initialize()` writes a small advisory record to a shared `__betterdb:caches` hash on the Valkey instance so Monitor (and other tooling) can enumerate caches without configuration. A 60s-TTL heartbeat key is refreshed every 30s; `flush()` and `dispose()` remove the heartbeat immediately. No sensitive data is ever written — only cache metadata (type, prefix, version, capabilities, configured thresholds).
167
+
168
+ Opt out by passing `discovery: { enabled: false }`. See `SemanticCacheOptions.discovery` for the full set of knobs.
169
+
170
+ If your Valkey runs with ACLs, grant the library's user access to the `__betterdb:*` prefix:
171
+
172
+ ```
173
+ ACL SETUSER <user> +@write +@read ~__betterdb:* ~<your-cache-prefix>:*
174
+ ```
175
+
176
+ Discovery writes are best-effort — if the ACL denies them, the cache still functions and the `semantic_cache_discovery_write_failed_total` counter increments so operators can alert.
177
+
178
+ ### `cache.dispose()`
179
+
180
+ Graceful shutdown: stops the heartbeat and deletes this instance's heartbeat key so Monitor marks the cache offline immediately. Does not drop the index or delete entries. Call from your SIGTERM handler alongside `client.quit()`.
181
+
164
182
  ## API
165
183
 
166
184
  ### `cache.initialize()`
@@ -215,7 +233,15 @@ Returns `{ name, numDocs, dimension, indexingState }`.
215
233
 
216
234
  ### `cache.flush()`
217
235
 
218
- Drops the index and all keys. Call `initialize()` again to rebuild.
236
+ Drops the index and all entries. Call `initialize()` again to rebuild. Also stops the discovery heartbeat and deletes its heartbeat key, but preserves the registry entry in `__betterdb:caches` so Monitor retains history.
237
+
238
+ ### `cache.shutdown()`
239
+
240
+ Stops the analytics client, cancels the stats snapshot timer, and disposes the discovery heartbeat. Safe to call multiple times.
241
+
242
+ ### `cache.dispose()`
243
+
244
+ Graceful shutdown of the discovery layer for in-process caches without destroying data. Stops the discovery heartbeat and deletes the heartbeat key; does not touch the index or entries.
219
245
 
220
246
  ### `cache.thresholdEffectiveness(options?)`
221
247
 
@@ -8,15 +8,22 @@ export declare class SemanticCache {
8
8
  private readonly entryPrefix;
9
9
  private readonly statsKey;
10
10
  private readonly similarityWindowKey;
11
- private readonly defaultThreshold;
11
+ private readonly configKey;
12
+ private defaultThreshold;
12
13
  private readonly defaultTtl;
13
- private readonly categoryThresholds;
14
+ private categoryThresholds;
14
15
  private readonly uncertaintyBand;
15
16
  private readonly telemetry;
16
17
  private readonly costTable;
17
18
  private readonly embeddingCacheEnabled;
18
19
  private readonly embeddingCacheTtl;
19
20
  private readonly embedKeyPrefix;
21
+ private readonly discoveryOptions;
22
+ private readonly _initialDefaultThreshold;
23
+ private readonly _initialCategoryThresholds;
24
+ private readonly configRefreshOptions;
25
+ private configRefreshTimer;
26
+ private discovery;
20
27
  private _initialized;
21
28
  private _dimension;
22
29
  private _hasBinaryRefs;
@@ -40,8 +47,18 @@ export declare class SemanticCache {
40
47
  constructor(options: SemanticCacheOptions);
41
48
  initialize(): Promise<void>;
42
49
  flush(): Promise<void>;
43
- /** Shut down the analytics client and cancel the stats timer. */
50
+ /**
51
+ * Shut down the analytics client, cancel the stats timer, and stop the
52
+ * discovery heartbeat. Safe to call multiple times.
53
+ */
44
54
  shutdown(): Promise<void>;
55
+ /**
56
+ * Graceful shutdown of the discovery layer — stops the heartbeat and
57
+ * deletes this instance's heartbeat key so Monitor marks the cache offline
58
+ * immediately. Does NOT touch the registry hash, the FT index, or any
59
+ * entries. Safe to call multiple times.
60
+ */
61
+ dispose(): Promise<void>;
45
62
  check(prompt: string | ContentBlock[], options?: CacheCheckOptions): Promise<CacheCheckResult>;
46
63
  store(prompt: string | ContentBlock[], response: string, options?: CacheStoreOptions): Promise<string>;
47
64
  /**
@@ -82,8 +99,29 @@ export declare class SemanticCache {
82
99
  thresholdEffectivenessAll(options?: {
83
100
  minSamples?: number;
84
101
  }): Promise<ThresholdEffectivenessResult[]>;
102
+ /**
103
+ * Refresh threshold config from Valkey. Returns true on a successful HGETALL,
104
+ * false if the call threw.
105
+ *
106
+ * Field semantics:
107
+ * - "threshold" -> updates defaultThreshold
108
+ * - "threshold:{category}" -> updates categoryThresholds[category]
109
+ * - "threshold:" (empty) -> ignored
110
+ * - non-numeric values -> ignored
111
+ * - out-of-range values -> ignored (must be 0 <= x <= 2)
112
+ *
113
+ * Categories present in memory but absent from the hash fall back to their
114
+ * constructor values (or are removed if no constructor override existed).
115
+ * The default threshold likewise falls back to its constructor value if
116
+ * `threshold` is absent from the hash.
117
+ */
118
+ refreshConfig(): Promise<boolean>;
85
119
  /** @internal Default similarity threshold. */
86
120
  get _defaultThreshold(): number;
121
+ /** @internal Test-only getter. */
122
+ get _categoryThresholds(): Readonly<Record<string, number>>;
123
+ /** @internal Test-only getter. */
124
+ get _configRefreshIntervalMs(): number;
87
125
  /**
88
126
  * Execute a stable FT.SEARCH for use by adapters (e.g. LangGraph).
89
127
  * SORTBY inserted_at ASC gives stable ordering across paginated calls.
@@ -98,7 +136,9 @@ export declare class SemanticCache {
98
136
  vector: number[];
99
137
  durationSec: number;
100
138
  }>;
139
+ private startConfigRefresh;
101
140
  private _doInitialize;
141
+ private registerDiscovery;
102
142
  private initAnalyticsSafe;
103
143
  private captureStatsSnapshot;
104
144
  private ensureIndexAndGetDimension;
@@ -10,7 +10,9 @@ const utils_1 = require("./utils");
10
10
  const defaultCostTable_1 = require("./defaultCostTable");
11
11
  const cluster_1 = require("./cluster");
12
12
  const analytics_1 = require("./analytics");
13
+ const discovery_1 = require("./discovery");
13
14
  const INVALIDATE_BATCH_SIZE = 1000;
15
+ const PACKAGE_VERSION = require('../package.json').version;
14
16
  function errMsg(err) {
15
17
  return err instanceof Error ? err.message : String(err);
16
18
  }
@@ -22,6 +24,7 @@ class SemanticCache {
22
24
  entryPrefix;
23
25
  statsKey;
24
26
  similarityWindowKey;
27
+ configKey;
25
28
  defaultThreshold;
26
29
  defaultTtl;
27
30
  categoryThresholds;
@@ -31,6 +34,12 @@ class SemanticCache {
31
34
  embeddingCacheEnabled;
32
35
  embeddingCacheTtl;
33
36
  embedKeyPrefix;
37
+ discoveryOptions;
38
+ _initialDefaultThreshold;
39
+ _initialCategoryThresholds;
40
+ configRefreshOptions;
41
+ configRefreshTimer;
42
+ discovery = null;
34
43
  _initialized = false;
35
44
  _dimension = 0;
36
45
  _hasBinaryRefs = false;
@@ -59,6 +68,7 @@ class SemanticCache {
59
68
  this.entryPrefix = `${this.name}:entry:`;
60
69
  this.statsKey = `${this.name}:__stats`;
61
70
  this.similarityWindowKey = `${this.name}:__similarity_window`;
71
+ this.configKey = `${this.name}:__config`;
62
72
  this.embedKeyPrefix = `${this.name}:embed:`;
63
73
  this.defaultThreshold = options.defaultThreshold ?? 0.1;
64
74
  this.defaultTtl = options.defaultTtl;
@@ -85,6 +95,16 @@ class SemanticCache {
85
95
  });
86
96
  this.analyticsOpts = options.analytics;
87
97
  this.usesDefaultCostTable = useDefault;
98
+ this.discoveryOptions = options.discovery ?? {};
99
+ // Capture constructor values as fallback when __config fields are absent
100
+ this._initialDefaultThreshold = this.defaultThreshold;
101
+ this._initialCategoryThresholds = { ...this.categoryThresholds };
102
+ // Refresh options
103
+ const refresh = options.configRefresh ?? {};
104
+ this.configRefreshOptions = {
105
+ enabled: refresh.enabled ?? true,
106
+ intervalMs: Math.max(1000, refresh.intervalMs ?? 30_000),
107
+ };
88
108
  }
89
109
  // -- Lifecycle --
90
110
  async initialize() {
@@ -102,6 +122,14 @@ class SemanticCache {
102
122
  this._initialized = false;
103
123
  this._initPromise = null;
104
124
  this._initGeneration++;
125
+ // Capture and null the discovery ref synchronously, before any await,
126
+ // so a concurrent _doInitialize() (started after _initGeneration++) can't
127
+ // race in and have its new manager overwritten by this flush.
128
+ const discoveryToStop = this.discovery;
129
+ this.discovery = null;
130
+ if (discoveryToStop) {
131
+ await discoveryToStop.stop({ deleteHeartbeat: true });
132
+ }
105
133
  // Valkey Search 1.2 does not support the DD (Delete Documents) flag on
106
134
  // FT.DROPINDEX. Drop the index first, then clean up keys separately.
107
135
  try {
@@ -126,14 +154,41 @@ class SemanticCache {
126
154
  await this.client.del(this.similarityWindowKey);
127
155
  this.analytics.capture('cache_flush');
128
156
  }
129
- /** Shut down the analytics client and cancel the stats timer. */
157
+ /**
158
+ * Shut down the analytics client, cancel the stats timer, and stop the
159
+ * discovery heartbeat. Safe to call multiple times.
160
+ */
130
161
  async shutdown() {
131
162
  this.shutdownCalled = true;
163
+ if (this.configRefreshTimer) {
164
+ clearInterval(this.configRefreshTimer);
165
+ this.configRefreshTimer = undefined;
166
+ }
132
167
  if (this.statsTimer) {
133
168
  clearInterval(this.statsTimer);
134
169
  this.statsTimer = undefined;
135
170
  }
136
171
  await this.analytics.shutdown();
172
+ await this.dispose();
173
+ }
174
+ /**
175
+ * Graceful shutdown of the discovery layer — stops the heartbeat and
176
+ * deletes this instance's heartbeat key so Monitor marks the cache offline
177
+ * immediately. Does NOT touch the registry hash, the FT index, or any
178
+ * entries. Safe to call multiple times.
179
+ */
180
+ async dispose() {
181
+ if (this.configRefreshTimer) {
182
+ clearInterval(this.configRefreshTimer);
183
+ this.configRefreshTimer = undefined;
184
+ }
185
+ if (this._initPromise) {
186
+ await this._initPromise.catch(() => { });
187
+ }
188
+ if (this.discovery) {
189
+ await this.discovery.stop({ deleteHeartbeat: true });
190
+ this.discovery = null;
191
+ }
137
192
  }
138
193
  // -- Public operations --
139
194
  async check(prompt, options) {
@@ -769,9 +824,64 @@ class SemanticCache {
769
824
  ]);
770
825
  return results;
771
826
  }
827
+ /**
828
+ * Refresh threshold config from Valkey. Returns true on a successful HGETALL,
829
+ * false if the call threw.
830
+ *
831
+ * Field semantics:
832
+ * - "threshold" -> updates defaultThreshold
833
+ * - "threshold:{category}" -> updates categoryThresholds[category]
834
+ * - "threshold:" (empty) -> ignored
835
+ * - non-numeric values -> ignored
836
+ * - out-of-range values -> ignored (must be 0 <= x <= 2)
837
+ *
838
+ * Categories present in memory but absent from the hash fall back to their
839
+ * constructor values (or are removed if no constructor override existed).
840
+ * The default threshold likewise falls back to its constructor value if
841
+ * `threshold` is absent from the hash.
842
+ */
843
+ async refreshConfig() {
844
+ let raw = null;
845
+ try {
846
+ raw = await this.client.hgetall(this.configKey);
847
+ }
848
+ catch {
849
+ return false;
850
+ }
851
+ let nextDefault = this._initialDefaultThreshold;
852
+ const nextCategory = { ...this._initialCategoryThresholds };
853
+ if (raw) {
854
+ for (const [field, value] of Object.entries(raw)) {
855
+ const parsed = Number(value);
856
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 2) {
857
+ continue;
858
+ }
859
+ if (field === 'threshold') {
860
+ nextDefault = parsed;
861
+ }
862
+ else if (field.startsWith('threshold:')) {
863
+ const category = field.slice('threshold:'.length);
864
+ if (category.length > 0) {
865
+ nextCategory[category] = parsed;
866
+ }
867
+ }
868
+ }
869
+ }
870
+ this.defaultThreshold = nextDefault;
871
+ this.categoryThresholds = nextCategory;
872
+ return true;
873
+ }
772
874
  // -- Internal helpers exposed to package adapters --
773
875
  /** @internal Default similarity threshold. */
774
876
  get _defaultThreshold() { return this.defaultThreshold; }
877
+ /** @internal Test-only getter. */
878
+ get _categoryThresholds() {
879
+ return this.categoryThresholds;
880
+ }
881
+ /** @internal Test-only getter. */
882
+ get _configRefreshIntervalMs() {
883
+ return this.configRefreshOptions.intervalMs;
884
+ }
775
885
  /**
776
886
  * Execute a stable FT.SEARCH for use by adapters (e.g. LangGraph).
777
887
  * SORTBY inserted_at ASC gives stable ordering across paginated calls.
@@ -788,19 +898,86 @@ class SemanticCache {
788
898
  return this.embed(text);
789
899
  }
790
900
  // -- Private helpers --
901
+ startConfigRefresh() {
902
+ if (!this.configRefreshOptions.enabled) {
903
+ return;
904
+ }
905
+ const tick = () => {
906
+ this.refreshConfig()
907
+ .then((ok) => {
908
+ if (!ok) {
909
+ this.telemetry.metrics.configRefreshFailed
910
+ .labels({ cache_name: this.name })
911
+ .inc();
912
+ }
913
+ })
914
+ .catch(() => {
915
+ this.telemetry.metrics.configRefreshFailed
916
+ .labels({ cache_name: this.name })
917
+ .inc();
918
+ });
919
+ };
920
+ // Synchronous first refresh: process started immediately after a proposal
921
+ // was applied picks up the change without waiting for the first tick.
922
+ tick();
923
+ this.configRefreshTimer = setInterval(tick, this.configRefreshOptions.intervalMs);
924
+ if (typeof this.configRefreshTimer.unref === 'function') {
925
+ this.configRefreshTimer.unref();
926
+ }
927
+ }
791
928
  async _doInitialize() {
792
929
  const gen = this._initGeneration;
793
930
  return this.traced('initialize', async () => {
794
931
  const { dim, hasBinaryRefs } = await this.ensureIndexAndGetDimension();
795
- if (this._initGeneration !== gen)
932
+ if (this._initGeneration !== gen) {
796
933
  return;
934
+ }
797
935
  this._dimension = dim;
798
936
  this._hasBinaryRefs = hasBinaryRefs;
937
+ // registerDiscovery() may throw SemanticCacheUsageError on a name
938
+ // collision. Mark the cache initialized only after discovery succeeds
939
+ // so a colliding caller cannot subsequently call check()/store()
940
+ // against another owner's keys.
941
+ const manager = await this.registerDiscovery();
942
+ if (this._initGeneration !== gen) {
943
+ if (manager) {
944
+ await manager.stop({ deleteHeartbeat: true });
945
+ }
946
+ return;
947
+ }
948
+ this.discovery = manager;
799
949
  this._initialized = true;
950
+ this.startConfigRefresh();
800
951
  // Fire analytics init once (not on every flush+initialize cycle)
801
952
  this.initAnalyticsSafe().catch(() => { });
802
953
  });
803
954
  }
955
+ async registerDiscovery() {
956
+ if (this.discoveryOptions.enabled === false) {
957
+ return null;
958
+ }
959
+ const metadata = (0, discovery_1.buildSemanticMetadata)({
960
+ name: this.name,
961
+ version: PACKAGE_VERSION,
962
+ defaultThreshold: this.defaultThreshold,
963
+ categoryThresholds: this.categoryThresholds,
964
+ uncertaintyBand: this.uncertaintyBand,
965
+ includeCategories: this.discoveryOptions.includeCategories ?? true,
966
+ });
967
+ const manager = new discovery_1.DiscoveryManager({
968
+ client: this.client,
969
+ name: this.name,
970
+ metadata,
971
+ heartbeatIntervalMs: this.discoveryOptions.heartbeatIntervalMs,
972
+ onWriteFailed: () => {
973
+ this.telemetry.metrics.discoveryWriteFailed
974
+ .labels({ cache_name: this.name })
975
+ .inc();
976
+ },
977
+ });
978
+ await manager.register();
979
+ return manager;
980
+ }
804
981
  async initAnalyticsSafe() {
805
982
  if (this.analyticsInitiated)
806
983
  return;
@@ -0,0 +1,67 @@
1
+ import type { Valkey } from './types';
2
+ export declare const PROTOCOL_VERSION = 1;
3
+ export declare const REGISTRY_KEY = "__betterdb:caches";
4
+ export declare const PROTOCOL_KEY = "__betterdb:protocol";
5
+ export declare const HEARTBEAT_KEY_PREFIX = "__betterdb:heartbeat:";
6
+ export declare const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000;
7
+ export declare const HEARTBEAT_TTL_SECONDS = 60;
8
+ export declare const CACHE_TYPE: "semantic_cache";
9
+ export type CacheType = typeof CACHE_TYPE;
10
+ export interface DiscoveryOptions {
11
+ enabled?: boolean;
12
+ heartbeatIntervalMs?: number;
13
+ includeCategories?: boolean;
14
+ }
15
+ export interface MarkerMetadata {
16
+ type: CacheType;
17
+ prefix: string;
18
+ version: string;
19
+ protocol_version: number;
20
+ capabilities: string[];
21
+ stats_key: string;
22
+ started_at: string;
23
+ pid?: number;
24
+ hostname?: string;
25
+ [extra: string]: unknown;
26
+ }
27
+ export interface BuildSemanticMetadataInput {
28
+ name: string;
29
+ version: string;
30
+ defaultThreshold: number;
31
+ categoryThresholds: Record<string, number>;
32
+ uncertaintyBand: number;
33
+ includeCategories: boolean;
34
+ }
35
+ export declare function buildSemanticMetadata(input: BuildSemanticMetadataInput): MarkerMetadata;
36
+ export interface DiscoveryLogger {
37
+ warn: (msg: string) => void;
38
+ debug: (msg: string) => void;
39
+ }
40
+ export interface DiscoveryManagerDeps {
41
+ client: Valkey;
42
+ name: string;
43
+ metadata: MarkerMetadata;
44
+ heartbeatIntervalMs?: number;
45
+ logger?: DiscoveryLogger;
46
+ onWriteFailed?: () => void;
47
+ }
48
+ export declare class DiscoveryManager {
49
+ private readonly client;
50
+ private readonly name;
51
+ private readonly metadata;
52
+ private readonly heartbeatIntervalMs;
53
+ private readonly heartbeatKey;
54
+ private readonly logger;
55
+ private readonly onWriteFailed;
56
+ private heartbeatHandle;
57
+ constructor(deps: DiscoveryManagerDeps);
58
+ register(): Promise<void>;
59
+ stop(opts: {
60
+ deleteHeartbeat: boolean;
61
+ }): Promise<void>;
62
+ tickHeartbeat(): Promise<void>;
63
+ private startHeartbeat;
64
+ private safeHget;
65
+ private safeCall;
66
+ private checkCollision;
67
+ }
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DiscoveryManager = exports.CACHE_TYPE = exports.HEARTBEAT_TTL_SECONDS = exports.DEFAULT_HEARTBEAT_INTERVAL_MS = exports.HEARTBEAT_KEY_PREFIX = exports.PROTOCOL_KEY = exports.REGISTRY_KEY = exports.PROTOCOL_VERSION = void 0;
4
+ exports.buildSemanticMetadata = buildSemanticMetadata;
5
+ const node_os_1 = require("node:os");
6
+ const errors_1 = require("./errors");
7
+ exports.PROTOCOL_VERSION = 1;
8
+ exports.REGISTRY_KEY = '__betterdb:caches';
9
+ exports.PROTOCOL_KEY = '__betterdb:protocol';
10
+ exports.HEARTBEAT_KEY_PREFIX = '__betterdb:heartbeat:';
11
+ exports.DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
12
+ exports.HEARTBEAT_TTL_SECONDS = 60;
13
+ exports.CACHE_TYPE = 'semantic_cache';
14
+ function buildSemanticMetadata(input) {
15
+ const metadata = {
16
+ type: exports.CACHE_TYPE,
17
+ prefix: input.name,
18
+ version: input.version,
19
+ protocol_version: exports.PROTOCOL_VERSION,
20
+ capabilities: ['invalidate', 'similarity_distribution', 'threshold_adjust'],
21
+ index_name: `${input.name}:idx`,
22
+ stats_key: `${input.name}:__stats`,
23
+ config_key: `${input.name}:__config`,
24
+ default_threshold: input.defaultThreshold,
25
+ uncertainty_band: input.uncertaintyBand,
26
+ started_at: new Date().toISOString(),
27
+ pid: process.pid,
28
+ hostname: (0, node_os_1.hostname)(),
29
+ };
30
+ if (input.includeCategories && Object.keys(input.categoryThresholds).length > 0) {
31
+ metadata.category_thresholds = { ...input.categoryThresholds };
32
+ }
33
+ return metadata;
34
+ }
35
+ const noopLogger = {
36
+ warn: () => { },
37
+ debug: () => { },
38
+ };
39
+ function errMsg(err) {
40
+ return err instanceof Error ? err.message : String(err);
41
+ }
42
+ class DiscoveryManager {
43
+ client;
44
+ name;
45
+ metadata;
46
+ heartbeatIntervalMs;
47
+ heartbeatKey;
48
+ logger;
49
+ onWriteFailed;
50
+ heartbeatHandle = null;
51
+ constructor(deps) {
52
+ this.client = deps.client;
53
+ this.name = deps.name;
54
+ this.metadata = deps.metadata;
55
+ this.heartbeatIntervalMs = deps.heartbeatIntervalMs ?? exports.DEFAULT_HEARTBEAT_INTERVAL_MS;
56
+ this.heartbeatKey = `${exports.HEARTBEAT_KEY_PREFIX}${deps.name}`;
57
+ this.logger = deps.logger ?? noopLogger;
58
+ this.onWriteFailed = deps.onWriteFailed ?? (() => { });
59
+ }
60
+ async register() {
61
+ const existingJson = await this.safeHget();
62
+ if (existingJson !== null) {
63
+ this.checkCollision(existingJson);
64
+ }
65
+ await this.safeCall(() => this.client.hset(exports.REGISTRY_KEY, this.name, JSON.stringify(this.metadata)), 'HSET registry');
66
+ await this.safeCall(() => this.client.set(exports.PROTOCOL_KEY, String(exports.PROTOCOL_VERSION), 'NX'), 'SET protocol');
67
+ await this.tickHeartbeat();
68
+ this.startHeartbeat();
69
+ }
70
+ async stop(opts) {
71
+ if (this.heartbeatHandle) {
72
+ clearInterval(this.heartbeatHandle);
73
+ this.heartbeatHandle = null;
74
+ }
75
+ if (!opts.deleteHeartbeat) {
76
+ return;
77
+ }
78
+ try {
79
+ await this.client.del(this.heartbeatKey);
80
+ }
81
+ catch (err) {
82
+ this.logger.debug(`discovery: DEL heartbeat failed: ${errMsg(err)}`);
83
+ }
84
+ }
85
+ async tickHeartbeat() {
86
+ const now = new Date().toISOString();
87
+ try {
88
+ await this.client.set(this.heartbeatKey, now, 'EX', exports.HEARTBEAT_TTL_SECONDS);
89
+ }
90
+ catch (err) {
91
+ this.logger.debug(`discovery: heartbeat SET failed: ${errMsg(err)}`);
92
+ this.onWriteFailed();
93
+ }
94
+ }
95
+ startHeartbeat() {
96
+ if (this.heartbeatHandle) {
97
+ clearInterval(this.heartbeatHandle);
98
+ }
99
+ const handle = setInterval(() => {
100
+ void this.tickHeartbeat();
101
+ }, this.heartbeatIntervalMs);
102
+ handle.unref?.();
103
+ this.heartbeatHandle = handle;
104
+ }
105
+ async safeHget() {
106
+ try {
107
+ return await this.client.hget(exports.REGISTRY_KEY, this.name);
108
+ }
109
+ catch (err) {
110
+ this.logger.warn(`discovery: HGET registry failed: ${errMsg(err)}`);
111
+ this.onWriteFailed();
112
+ return null;
113
+ }
114
+ }
115
+ async safeCall(fn, label) {
116
+ try {
117
+ await fn();
118
+ }
119
+ catch (err) {
120
+ this.logger.warn(`discovery: ${label} failed: ${errMsg(err)}`);
121
+ this.onWriteFailed();
122
+ }
123
+ }
124
+ checkCollision(existingJson) {
125
+ let parsed;
126
+ try {
127
+ parsed = JSON.parse(existingJson);
128
+ }
129
+ catch {
130
+ return;
131
+ }
132
+ if (parsed.type && parsed.type !== exports.CACHE_TYPE) {
133
+ throw new errors_1.SemanticCacheUsageError(`cache name collision: '${this.name}' is already registered as type '${String(parsed.type)}' on this Valkey instance`);
134
+ }
135
+ if (parsed.version && parsed.version !== this.metadata.version) {
136
+ this.logger.warn(`discovery: overwriting marker for '${this.name}' (existing version ${String(parsed.version)}, this version ${this.metadata.version})`);
137
+ }
138
+ }
139
+ }
140
+ exports.DiscoveryManager = DiscoveryManager;
package/dist/index.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export { SemanticCache } from './SemanticCache';
2
2
  export type { ThresholdEffectivenessResult } from './SemanticCache';
3
3
  export { DEFAULT_COST_TABLE } from './defaultCostTable';
4
- export type { SemanticCacheOptions, CacheCheckOptions, CacheStoreOptions, CacheCheckResult, CacheStats, IndexInfo, InvalidateResult, CacheConfidence, EmbedFn, ModelCost, RerankOptions, } from './types';
4
+ export type { SemanticCacheOptions, CacheCheckOptions, CacheStoreOptions, CacheCheckResult, CacheStats, IndexInfo, InvalidateResult, CacheConfidence, EmbedFn, ModelCost, RerankOptions, ConfigRefreshOptions, } from './types';
5
5
  export { SemanticCacheUsageError, EmbeddingError, ValkeyCommandError, } from './errors';
6
6
  export type { ContentBlock, TextBlock, BinaryBlock, ToolCallBlock, ToolResultBlock, ReasoningBlock, BlockHints, } from './utils';
7
+ export { escapeTag } from './utils';
7
8
  export type { BinaryRef, BinaryNormalizer, NormalizerConfig } from './normalizer';
8
9
  export { hashBase64, hashBytes, hashUrl, fetchAndHash, passthrough, composeNormalizer, defaultNormalizer, } from './normalizer';
10
+ export type { DiscoveryOptions } from './discovery';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.defaultNormalizer = exports.composeNormalizer = exports.passthrough = exports.fetchAndHash = exports.hashUrl = exports.hashBytes = exports.hashBase64 = exports.ValkeyCommandError = exports.EmbeddingError = exports.SemanticCacheUsageError = exports.DEFAULT_COST_TABLE = exports.SemanticCache = void 0;
3
+ exports.defaultNormalizer = exports.composeNormalizer = exports.passthrough = exports.fetchAndHash = exports.hashUrl = exports.hashBytes = exports.hashBase64 = exports.escapeTag = exports.ValkeyCommandError = exports.EmbeddingError = exports.SemanticCacheUsageError = exports.DEFAULT_COST_TABLE = exports.SemanticCache = void 0;
4
4
  var SemanticCache_1 = require("./SemanticCache");
5
5
  Object.defineProperty(exports, "SemanticCache", { enumerable: true, get: function () { return SemanticCache_1.SemanticCache; } });
6
6
  var defaultCostTable_1 = require("./defaultCostTable");
@@ -9,6 +9,8 @@ var errors_1 = require("./errors");
9
9
  Object.defineProperty(exports, "SemanticCacheUsageError", { enumerable: true, get: function () { return errors_1.SemanticCacheUsageError; } });
10
10
  Object.defineProperty(exports, "EmbeddingError", { enumerable: true, get: function () { return errors_1.EmbeddingError; } });
11
11
  Object.defineProperty(exports, "ValkeyCommandError", { enumerable: true, get: function () { return errors_1.ValkeyCommandError; } });
12
+ var utils_1 = require("./utils");
13
+ Object.defineProperty(exports, "escapeTag", { enumerable: true, get: function () { return utils_1.escapeTag; } });
12
14
  var normalizer_1 = require("./normalizer");
13
15
  Object.defineProperty(exports, "hashBase64", { enumerable: true, get: function () { return normalizer_1.hashBase64; } });
14
16
  Object.defineProperty(exports, "hashBytes", { enumerable: true, get: function () { return normalizer_1.hashBytes; } });
@@ -13,6 +13,8 @@ interface CacheMetrics {
13
13
  costSavedTotal: Counter;
14
14
  embeddingCacheTotal: Counter;
15
15
  staleModelEvictions: Counter;
16
+ discoveryWriteFailed: Counter;
17
+ configRefreshFailed: Counter;
16
18
  }
17
19
  export interface Telemetry {
18
20
  tracer: Tracer;
package/dist/telemetry.js CHANGED
@@ -57,6 +57,16 @@ function createTelemetry(opts) {
57
57
  help: 'Entries evicted due to staleAfterModelChange detection',
58
58
  labelNames: ['cache_name'],
59
59
  });
60
+ const discoveryWriteFailed = getOrCreateCounter(registry, {
61
+ name: `${opts.prefix}_discovery_write_failed_total`,
62
+ help: 'Count of failed discovery-marker writes (best-effort HGET/HSET/SET operations against __betterdb:* keys)',
63
+ labelNames: ['cache_name'],
64
+ });
65
+ const configRefreshFailed = getOrCreateCounter(registry, {
66
+ name: `${opts.prefix}_config_refresh_failed_total`,
67
+ help: 'Count of failed periodic config refreshes (HGETALL on __config).',
68
+ labelNames: ['cache_name'],
69
+ });
60
70
  return {
61
71
  tracer,
62
72
  metrics: {
@@ -67,6 +77,8 @@ function createTelemetry(opts) {
67
77
  costSavedTotal,
68
78
  embeddingCacheTotal,
69
79
  staleModelEvictions,
80
+ discoveryWriteFailed,
81
+ configRefreshFailed,
70
82
  },
71
83
  };
72
84
  }
package/dist/types.d.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import type Valkey from 'iovalkey';
2
2
  import type { Registry } from 'prom-client';
3
+ import type { DiscoveryOptions } from './discovery';
3
4
  export type { Valkey };
5
+ export interface ConfigRefreshOptions {
6
+ /** Enable periodic config refresh from Valkey. Default: true. */
7
+ enabled?: boolean;
8
+ /** Refresh interval in milliseconds. Default: 30000. Minimum: 1000. */
9
+ intervalMs?: number;
10
+ }
4
11
  export type EmbedFn = (text: string) => Promise<number[]>;
5
12
  export interface ModelCost {
6
13
  inputPer1k: number;
@@ -92,6 +99,20 @@ export interface SemanticCacheOptions {
92
99
  /** Interval in ms for periodic stats snapshots. Default: 300_000 (5 min). 0 to disable. */
93
100
  statsIntervalMs?: number;
94
101
  };
102
+ /**
103
+ * Discovery-marker protocol controls. See
104
+ * docs/plans/specs/spec-semantic-cache-discovery-markers.md.
105
+ * Defaults: enabled=true, heartbeatIntervalMs=30000, includeCategories=true.
106
+ */
107
+ discovery?: DiscoveryOptions;
108
+ /**
109
+ * Periodic refresh of in-memory threshold config from Valkey.
110
+ * When enabled, the cache re-reads `{name}:__config` on the configured
111
+ * interval. Field `threshold` updates `defaultThreshold`; fields named
112
+ * `threshold:{category}` update `categoryThresholds[category]`.
113
+ * Defaults: enabled=true, intervalMs=30000.
114
+ */
115
+ configRefresh?: ConfigRefreshOptions;
95
116
  }
96
117
  export interface RerankOptions {
97
118
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@betterdb/semantic-cache",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Valkey-native semantic cache for LLM applications with built-in OpenTelemetry and Prometheus instrumentation",
5
5
  "keywords": [
6
6
  "valkey",