memory-lancedb-pro 1.0.24 → 1.0.26

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.
@@ -2,7 +2,7 @@
2
2
  "id": "memory-lancedb-pro",
3
3
  "name": "Memory (LanceDB Pro)",
4
4
  "description": "Enhanced LanceDB-backed long-term memory with hybrid retrieval, multi-scope isolation, long-context chunking, and management CLI",
5
- "version": "1.0.23",
5
+ "version": "1.0.26",
6
6
  "kind": "memory",
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -18,8 +18,18 @@
18
18
  },
19
19
  "apiKey": {
20
20
  "oneOf": [
21
- { "type": "string" },
22
- { "type": "array", "items": { "type": "string" }, "minItems": 1 }
21
+ {
22
+ "type": "string",
23
+ "minLength": 1
24
+ },
25
+ {
26
+ "type": "array",
27
+ "items": {
28
+ "type": "string",
29
+ "minLength": 1
30
+ },
31
+ "minItems": 1
32
+ }
23
33
  ],
24
34
  "description": "Single API key or array of keys for round-robin rotation"
25
35
  },
@@ -189,6 +199,20 @@
189
199
  "maximum": 365,
190
200
  "default": 60,
191
201
  "description": "Time decay half-life in days. Old entries lose score gradually. Floor at 0.5x. Set 0 to disable."
202
+ },
203
+ "reinforcementFactor": {
204
+ "type": "number",
205
+ "minimum": 0,
206
+ "maximum": 2,
207
+ "default": 0.5,
208
+ "description": "Access reinforcement factor for time decay. Frequently recalled memories decay slower. 0 to disable."
209
+ },
210
+ "maxHalfLifeMultiplier": {
211
+ "type": "number",
212
+ "minimum": 1,
213
+ "maximum": 10,
214
+ "default": 3,
215
+ "description": "Maximum half-life multiplier from access reinforcement. Prevents frequently accessed memories from becoming immortal."
192
216
  }
193
217
  }
194
218
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-lancedb-pro",
3
- "version": "1.0.24",
3
+ "version": "1.0.26",
4
4
  "description": "OpenClaw enhanced LanceDB memory plugin with hybrid retrieval (Vector + BM25), cross-encoder rerank, multi-scope isolation, long-context chunking, and management CLI",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Access Tracker
3
+ *
4
+ * Tracks memory access patterns to support reinforcement-based decay.
5
+ * Frequently accessed memories decay more slowly (longer effective half-life).
6
+ *
7
+ * Key exports:
8
+ * - parseAccessMetadata — extract accessCount/lastAccessedAt from metadata JSON
9
+ * - buildUpdatedMetadata — merge access fields into existing metadata JSON
10
+ * - computeEffectiveHalfLife — compute reinforced half-life from access history
11
+ * - AccessTracker — debounced write-back tracker for batch metadata updates
12
+ */
13
+
14
+ import type { MemoryStore } from "./store.js";
15
+
16
+ // ============================================================================
17
+ // Types
18
+ // ============================================================================
19
+
20
+ export interface AccessMetadata {
21
+ readonly accessCount: number;
22
+ readonly lastAccessedAt: number;
23
+ }
24
+
25
+ export interface AccessTrackerOptions {
26
+ readonly store: MemoryStore;
27
+ readonly logger: {
28
+ warn: (...args: unknown[]) => void;
29
+ info?: (...args: unknown[]) => void;
30
+ };
31
+ readonly debounceMs?: number;
32
+ }
33
+
34
+ // ============================================================================
35
+ // Constants
36
+ // ============================================================================
37
+
38
+ const MIN_ACCESS_COUNT = 0;
39
+ const MAX_ACCESS_COUNT = 10_000;
40
+
41
+ /** Access count itself decays with a 30-day half-life */
42
+ const ACCESS_DECAY_HALF_LIFE_DAYS = 30;
43
+
44
+ // ============================================================================
45
+ // Utility
46
+ // ============================================================================
47
+
48
+ function clampAccessCount(value: number): number {
49
+ if (!Number.isFinite(value)) return MIN_ACCESS_COUNT;
50
+ return Math.min(
51
+ MAX_ACCESS_COUNT,
52
+ Math.max(MIN_ACCESS_COUNT, Math.floor(value)),
53
+ );
54
+ }
55
+
56
+ // ============================================================================
57
+ // Metadata Parsing
58
+ // ============================================================================
59
+
60
+ /**
61
+ * Parse access-related fields from a metadata JSON string.
62
+ *
63
+ * Handles: undefined, empty string, malformed JSON, negative numbers,
64
+ * numbers exceeding 10000. Always returns a valid AccessMetadata.
65
+ */
66
+ export function parseAccessMetadata(
67
+ metadata: string | undefined,
68
+ ): AccessMetadata {
69
+ if (metadata === undefined || metadata === "") {
70
+ return { accessCount: 0, lastAccessedAt: 0 };
71
+ }
72
+
73
+ let parsed: unknown;
74
+ try {
75
+ parsed = JSON.parse(metadata);
76
+ } catch {
77
+ return { accessCount: 0, lastAccessedAt: 0 };
78
+ }
79
+
80
+ if (typeof parsed !== "object" || parsed === null) {
81
+ return { accessCount: 0, lastAccessedAt: 0 };
82
+ }
83
+
84
+ const obj = parsed as Record<string, unknown>;
85
+
86
+ const rawCount = typeof obj.accessCount === "number" ? obj.accessCount : 0;
87
+ const rawLastAccessed =
88
+ typeof obj.lastAccessedAt === "number" ? obj.lastAccessedAt : 0;
89
+
90
+ return {
91
+ accessCount: clampAccessCount(rawCount),
92
+ lastAccessedAt:
93
+ Number.isFinite(rawLastAccessed) && rawLastAccessed >= 0
94
+ ? rawLastAccessed
95
+ : 0,
96
+ };
97
+ }
98
+
99
+ // ============================================================================
100
+ // Metadata Building
101
+ // ============================================================================
102
+
103
+ /**
104
+ * Merge an access-count increment into existing metadata JSON.
105
+ *
106
+ * Preserves ALL existing fields in the metadata object — only overwrites
107
+ * `accessCount` and `lastAccessedAt`. Returns a new JSON string.
108
+ */
109
+ export function buildUpdatedMetadata(
110
+ existingMetadata: string | undefined,
111
+ accessDelta: number,
112
+ ): string {
113
+ let existing: Record<string, unknown> = {};
114
+
115
+ if (existingMetadata !== undefined && existingMetadata !== "") {
116
+ try {
117
+ const parsed = JSON.parse(existingMetadata);
118
+ if (typeof parsed === "object" && parsed !== null) {
119
+ existing = { ...parsed };
120
+ }
121
+ } catch {
122
+ // malformed JSON — start fresh but preserve nothing
123
+ }
124
+ }
125
+
126
+ const prev = parseAccessMetadata(existingMetadata);
127
+ const newCount = clampAccessCount(prev.accessCount + accessDelta);
128
+
129
+ return JSON.stringify({
130
+ ...existing,
131
+ accessCount: newCount,
132
+ lastAccessedAt: Date.now(),
133
+ });
134
+ }
135
+
136
+ // ============================================================================
137
+ // Effective Half-Life Computation
138
+ // ============================================================================
139
+
140
+ /**
141
+ * Compute the effective half-life for a memory based on its access history.
142
+ *
143
+ * The access count itself decays over time (30-day half-life for access
144
+ * freshness), so stale accesses contribute less reinforcement. The extension
145
+ * uses a logarithmic curve (`Math.log1p`) to provide diminishing returns.
146
+ *
147
+ * @param baseHalfLife - Base half-life in days (e.g. 30)
148
+ * @param accessCount - Raw number of times the memory was accessed
149
+ * @param lastAccessedAt - Timestamp (ms) of last access
150
+ * @param reinforcementFactor - Scaling factor for reinforcement (0 = disabled)
151
+ * @param maxMultiplier - Hard cap: result <= baseHalfLife * maxMultiplier
152
+ * @returns Effective half-life in days
153
+ */
154
+ export function computeEffectiveHalfLife(
155
+ baseHalfLife: number,
156
+ accessCount: number,
157
+ lastAccessedAt: number,
158
+ reinforcementFactor: number,
159
+ maxMultiplier: number,
160
+ ): number {
161
+ // Short-circuit: no reinforcement or no accesses
162
+ if (reinforcementFactor === 0 || accessCount <= 0) {
163
+ return baseHalfLife;
164
+ }
165
+
166
+ const now = Date.now();
167
+ const daysSinceLastAccess = Math.max(
168
+ 0,
169
+ (now - lastAccessedAt) / (1000 * 60 * 60 * 24),
170
+ );
171
+
172
+ // Access freshness decays exponentially with 30-day half-life
173
+ const accessFreshness = Math.exp(
174
+ -daysSinceLastAccess * (Math.LN2 / ACCESS_DECAY_HALF_LIFE_DAYS),
175
+ );
176
+
177
+ // Effective access count after freshness decay
178
+ const effectiveAccessCount = accessCount * accessFreshness;
179
+
180
+ // Logarithmic extension for diminishing returns
181
+ const extension =
182
+ baseHalfLife * reinforcementFactor * Math.log1p(effectiveAccessCount);
183
+
184
+ const result = baseHalfLife + extension;
185
+
186
+ // Hard cap
187
+ const cap = baseHalfLife * maxMultiplier;
188
+ return Math.min(result, cap);
189
+ }
190
+
191
+ // ============================================================================
192
+ // AccessTracker Class
193
+ // ============================================================================
194
+
195
+ /**
196
+ * Debounced write-back tracker for memory access events.
197
+ *
198
+ * `recordAccess()` is synchronous (Map update only, no I/O). Pending deltas
199
+ * accumulate until `flush()` is called (or by a future scheduled callback).
200
+ * On flush, each pending entry is read via `store.getById()`, its metadata
201
+ * is merged with the accumulated access delta, and written back via
202
+ * `store.update()`.
203
+ */
204
+ export class AccessTracker {
205
+ private readonly pending: Map<string, number> = new Map();
206
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null;
207
+ private flushPromise: Promise<void> | null = null;
208
+ private readonly debounceMs: number;
209
+ private readonly store: MemoryStore;
210
+ private readonly logger: {
211
+ warn: (...args: unknown[]) => void;
212
+ info?: (...args: unknown[]) => void;
213
+ };
214
+
215
+ constructor(options: AccessTrackerOptions) {
216
+ this.store = options.store;
217
+ this.logger = options.logger;
218
+ this.debounceMs = options.debounceMs ?? 5_000;
219
+ }
220
+
221
+ /**
222
+ * Record one access for each of the given memory IDs.
223
+ * Synchronous — only updates the in-memory pending map.
224
+ */
225
+ recordAccess(ids: readonly string[]): void {
226
+ for (const id of ids) {
227
+ const current = this.pending.get(id) ?? 0;
228
+ this.pending.set(id, current + 1);
229
+ }
230
+
231
+ // Reset debounce timer
232
+ this.resetTimer();
233
+ }
234
+
235
+ /**
236
+ * Return a snapshot of all pending (id -> delta) entries.
237
+ */
238
+ getPendingUpdates(): Map<string, number> {
239
+ return new Map(this.pending);
240
+ }
241
+
242
+ /**
243
+ * Flush pending access deltas to the store.
244
+ *
245
+ * If a flush is already in progress, awaits the current flush to complete.
246
+ * If new pending data accumulated during the in-flight flush, a follow-up
247
+ * flush is automatically triggered.
248
+ */
249
+ async flush(): Promise<void> {
250
+ this.clearTimer();
251
+
252
+ // If a flush is in progress, wait for it to finish
253
+ if (this.flushPromise) {
254
+ await this.flushPromise;
255
+ // After the in-flight flush completes, check if new data accumulated
256
+ if (this.pending.size > 0) {
257
+ return this.flush();
258
+ }
259
+ return;
260
+ }
261
+
262
+ if (this.pending.size === 0) return;
263
+
264
+ this.flushPromise = this.doFlush();
265
+ try {
266
+ await this.flushPromise;
267
+ } finally {
268
+ this.flushPromise = null;
269
+ }
270
+
271
+ // If new data accumulated during flush, schedule a follow-up
272
+ if (this.pending.size > 0) {
273
+ this.resetTimer();
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Tear down the tracker — cancel timers and clear pending state.
279
+ */
280
+ destroy(): void {
281
+ this.clearTimer();
282
+ if (this.pending.size > 0) {
283
+ this.logger.warn(
284
+ `access-tracker: destroying with ${this.pending.size} pending writes`,
285
+ );
286
+ }
287
+ this.pending.clear();
288
+ }
289
+
290
+ // --------------------------------------------------------------------------
291
+ // Internal helpers
292
+ // --------------------------------------------------------------------------
293
+
294
+ private async doFlush(): Promise<void> {
295
+ const batch = new Map(this.pending);
296
+ this.pending.clear();
297
+
298
+ for (const [id, delta] of batch) {
299
+ try {
300
+ const current = await this.store.getById(id);
301
+ if (!current) continue;
302
+
303
+ const updatedMeta = buildUpdatedMetadata(current.metadata, delta);
304
+ await this.store.update(id, { metadata: updatedMeta });
305
+ } catch (err) {
306
+ // Requeue failed delta for retry on next flush
307
+ const existing = this.pending.get(id) ?? 0;
308
+ this.pending.set(id, existing + delta);
309
+ this.logger.warn(
310
+ `access-tracker: write-back failed for ${id.slice(0, 8)}:`,
311
+ err,
312
+ );
313
+ }
314
+ }
315
+ }
316
+
317
+ private resetTimer(): void {
318
+ this.clearTimer();
319
+ this.debounceTimer = setTimeout(() => {
320
+ void this.flush();
321
+ }, this.debounceMs);
322
+ }
323
+
324
+ private clearTimer(): void {
325
+ if (this.debounceTimer !== null) {
326
+ clearTimeout(this.debounceTimer);
327
+ this.debounceTimer = null;
328
+ }
329
+ }
330
+ }
package/src/embedder.ts CHANGED
@@ -208,14 +208,28 @@ export class Embedder {
208
208
  return client;
209
209
  }
210
210
 
211
- /** Check whether an error is a rate-limit / quota-exceeded error. */
211
+ /** Check whether an error is a rate-limit / quota-exceeded / overload error. */
212
212
  private isRateLimitError(error: unknown): boolean {
213
- // OpenAI SDK typed error
214
- if (error && typeof error === "object" && "status" in error && (error as any).status === 429) {
215
- return true;
213
+ if (!error || typeof error !== "object") return false;
214
+
215
+ const err = error as Record<string, any>;
216
+
217
+ // HTTP status: 429 (rate limit) or 503 (service overload)
218
+ if (err.status === 429 || err.status === 503) return true;
219
+
220
+ // OpenAI SDK structured error code
221
+ if (err.code === "rate_limit_exceeded" || err.code === "insufficient_quota") return true;
222
+
223
+ // Nested error object (some providers)
224
+ const nested = err.error;
225
+ if (nested && typeof nested === "object") {
226
+ if (nested.type === "rate_limit_exceeded" || nested.type === "insufficient_quota") return true;
227
+ if (nested.code === "rate_limit_exceeded" || nested.code === "insufficient_quota") return true;
216
228
  }
229
+
230
+ // Fallback: message text matching
217
231
  const msg = error instanceof Error ? error.message : String(error);
218
- return /rate.limit|quota|too many requests|insufficient.*credit|429/i.test(msg);
232
+ return /rate.limit|quota|too many requests|insufficient.*credit|429|503.*overload/i.test(msg);
219
233
  }
220
234
 
221
235
  /**
@@ -231,19 +245,27 @@ export class Embedder {
231
245
  try {
232
246
  return await client.embeddings.create(payload);
233
247
  } catch (error) {
248
+ lastError = error instanceof Error ? error : new Error(String(error));
249
+
234
250
  if (this.isRateLimitError(error) && attempt < maxAttempts - 1) {
235
251
  console.log(
236
- `[memory-lancedb-pro] API key ${attempt + 1}/${maxAttempts} hit rate limit, rotating to next key...`
252
+ `[memory-lancedb-pro] Attempt ${attempt + 1}/${maxAttempts} hit rate limit, rotating to next key...`
237
253
  );
238
- lastError = error instanceof Error ? error : new Error(String(error));
239
254
  continue;
240
255
  }
241
- // Non-rate-limit error or last attempt — let caller handle
242
- throw error;
256
+
257
+ // Non-rate-limit error → don't retry, let caller handle (e.g. chunking)
258
+ if (!this.isRateLimitError(error)) {
259
+ throw error;
260
+ }
243
261
  }
244
262
  }
245
263
 
246
- throw lastError || new Error("All API keys exhausted (rate limited)");
264
+ // All keys exhausted with rate-limit errors
265
+ throw new Error(
266
+ `All ${maxAttempts} API keys exhausted (rate limited). Last error: ${lastError?.message || "unknown"}`,
267
+ { cause: lastError }
268
+ );
247
269
  }
248
270
 
249
271
  /** Number of API keys in the rotation pool. */