@mnemoai/core 1.1.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/index.ts +3395 -0
- package/openclaw.plugin.json +815 -0
- package/package.json +59 -0
- package/src/access-tracker.ts +341 -0
- package/src/adapters/README.md +78 -0
- package/src/adapters/chroma.ts +206 -0
- package/src/adapters/lancedb.ts +237 -0
- package/src/adapters/pgvector.ts +218 -0
- package/src/adapters/qdrant.ts +191 -0
- package/src/adaptive-retrieval.ts +90 -0
- package/src/audit-log.ts +238 -0
- package/src/chunker.ts +254 -0
- package/src/config.ts +271 -0
- package/src/decay-engine.ts +238 -0
- package/src/embedder.ts +735 -0
- package/src/extraction-prompts.ts +339 -0
- package/src/license.ts +258 -0
- package/src/llm-client.ts +125 -0
- package/src/mcp-server.ts +415 -0
- package/src/memory-categories.ts +71 -0
- package/src/memory-upgrader.ts +388 -0
- package/src/migrate.ts +364 -0
- package/src/mnemo.ts +142 -0
- package/src/noise-filter.ts +97 -0
- package/src/noise-prototypes.ts +164 -0
- package/src/observability.ts +81 -0
- package/src/query-tracker.ts +57 -0
- package/src/reflection-event-store.ts +98 -0
- package/src/reflection-item-store.ts +112 -0
- package/src/reflection-mapped-metadata.ts +84 -0
- package/src/reflection-metadata.ts +23 -0
- package/src/reflection-ranking.ts +33 -0
- package/src/reflection-retry.ts +181 -0
- package/src/reflection-slices.ts +265 -0
- package/src/reflection-store.ts +602 -0
- package/src/resonance-state.ts +85 -0
- package/src/retriever.ts +1510 -0
- package/src/scopes.ts +375 -0
- package/src/self-improvement-files.ts +143 -0
- package/src/semantic-gate.ts +121 -0
- package/src/session-recovery.ts +138 -0
- package/src/smart-extractor.ts +923 -0
- package/src/smart-metadata.ts +561 -0
- package/src/storage-adapter.ts +153 -0
- package/src/store.ts +1330 -0
- package/src/tier-manager.ts +189 -0
- package/src/tools.ts +1292 -0
- package/src/wal-recovery.ts +172 -0
- package/test/core.test.mjs +301 -0
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mnemoai/core",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Cognitive science-based AI memory framework — Weibull decay, triple-path retrieval, multi-backend storage",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"ai-memory",
|
|
9
|
+
"long-term-memory",
|
|
10
|
+
"vector-search",
|
|
11
|
+
"bm25",
|
|
12
|
+
"knowledge-graph",
|
|
13
|
+
"hybrid-retrieval",
|
|
14
|
+
"rerank",
|
|
15
|
+
"weibull-decay",
|
|
16
|
+
"cognitive-science",
|
|
17
|
+
"rag",
|
|
18
|
+
"agent-memory",
|
|
19
|
+
"lancedb",
|
|
20
|
+
"qdrant",
|
|
21
|
+
"chroma",
|
|
22
|
+
"pgvector"
|
|
23
|
+
],
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/Methux/mnemo.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://m-nemo.ai",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/Methux/mnemo/issues"
|
|
31
|
+
},
|
|
32
|
+
"author": "Mnemo Contributors",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@lancedb/lancedb": "^0.26.2",
|
|
36
|
+
"@sinclair/typebox": "0.34.48",
|
|
37
|
+
"openai": "^6.21.0"
|
|
38
|
+
},
|
|
39
|
+
"optionalDependencies": {
|
|
40
|
+
"@qdrant/js-client-rest": "^1.12.0",
|
|
41
|
+
"chromadb": "^1.9.0",
|
|
42
|
+
"pg": "^8.13.0"
|
|
43
|
+
},
|
|
44
|
+
"openclaw": {
|
|
45
|
+
"extensions": [
|
|
46
|
+
"./index.ts"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"test": "node --test test/*.test.mjs",
|
|
51
|
+
"test:openclaw-host": "node test/openclaw-host-functional.mjs",
|
|
52
|
+
"version": "node scripts/sync-plugin-version.mjs openclaw.plugin.json package.json && git add openclaw.plugin.json"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"commander": "^14.0.0",
|
|
56
|
+
"jiti": "^2.6.0",
|
|
57
|
+
"typescript": "^5.9.3"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// SPDX-License-Identifier: LicenseRef-Mnemo-Pro
|
|
2
|
+
/**
|
|
3
|
+
* Access Tracker
|
|
4
|
+
*
|
|
5
|
+
* Tracks memory access patterns to support reinforcement-based decay.
|
|
6
|
+
* Frequently accessed memories decay more slowly (longer effective half-life).
|
|
7
|
+
*
|
|
8
|
+
* Key exports:
|
|
9
|
+
* - parseAccessMetadata — extract accessCount/lastAccessedAt from metadata JSON
|
|
10
|
+
* - buildUpdatedMetadata — merge access fields into existing metadata JSON
|
|
11
|
+
* - computeEffectiveHalfLife — compute reinforced half-life from access history
|
|
12
|
+
* - AccessTracker — debounced write-back tracker for batch metadata updates
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { MemoryStore } from "./store.js";
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Types
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
export interface AccessMetadata {
|
|
22
|
+
readonly accessCount: number;
|
|
23
|
+
readonly lastAccessedAt: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AccessTrackerOptions {
|
|
27
|
+
readonly store: MemoryStore;
|
|
28
|
+
readonly logger: {
|
|
29
|
+
warn: (...args: unknown[]) => void;
|
|
30
|
+
info?: (...args: unknown[]) => void;
|
|
31
|
+
};
|
|
32
|
+
readonly debounceMs?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// Constants
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
const MIN_ACCESS_COUNT = 0;
|
|
40
|
+
const MAX_ACCESS_COUNT = 10_000;
|
|
41
|
+
|
|
42
|
+
/** Access count itself decays with a 30-day half-life */
|
|
43
|
+
const ACCESS_DECAY_HALF_LIFE_DAYS = 30;
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// Utility
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
function clampAccessCount(value: number): number {
|
|
50
|
+
if (!Number.isFinite(value)) return MIN_ACCESS_COUNT;
|
|
51
|
+
return Math.min(
|
|
52
|
+
MAX_ACCESS_COUNT,
|
|
53
|
+
Math.max(MIN_ACCESS_COUNT, Math.floor(value)),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ============================================================================
|
|
58
|
+
// Metadata Parsing
|
|
59
|
+
// ============================================================================
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse access-related fields from a metadata JSON string.
|
|
63
|
+
*
|
|
64
|
+
* Handles: undefined, empty string, malformed JSON, negative numbers,
|
|
65
|
+
* numbers exceeding 10000. Always returns a valid AccessMetadata.
|
|
66
|
+
*/
|
|
67
|
+
export function parseAccessMetadata(
|
|
68
|
+
metadata: string | undefined,
|
|
69
|
+
): AccessMetadata {
|
|
70
|
+
if (metadata === undefined || metadata === "") {
|
|
71
|
+
return { accessCount: 0, lastAccessedAt: 0 };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let parsed: unknown;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(metadata);
|
|
77
|
+
} catch {
|
|
78
|
+
return { accessCount: 0, lastAccessedAt: 0 };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
82
|
+
return { accessCount: 0, lastAccessedAt: 0 };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const obj = parsed as Record<string, unknown>;
|
|
86
|
+
|
|
87
|
+
// Support both camelCase and snake_case keys (beta smart-memory uses snake_case).
|
|
88
|
+
const rawCountAny = obj.accessCount ?? obj.access_count;
|
|
89
|
+
const rawCount =
|
|
90
|
+
typeof rawCountAny === "number" ? rawCountAny : Number(rawCountAny ?? 0);
|
|
91
|
+
|
|
92
|
+
const rawLastAny = obj.lastAccessedAt ?? obj.last_accessed_at;
|
|
93
|
+
const rawLastAccessed =
|
|
94
|
+
typeof rawLastAny === "number" ? rawLastAny : Number(rawLastAny ?? 0);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
accessCount: clampAccessCount(rawCount),
|
|
98
|
+
lastAccessedAt:
|
|
99
|
+
Number.isFinite(rawLastAccessed) && rawLastAccessed >= 0
|
|
100
|
+
? rawLastAccessed
|
|
101
|
+
: 0,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ============================================================================
|
|
106
|
+
// Metadata Building
|
|
107
|
+
// ============================================================================
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Merge an access-count increment into existing metadata JSON.
|
|
111
|
+
*
|
|
112
|
+
* Preserves ALL existing fields in the metadata object — only overwrites
|
|
113
|
+
* `accessCount` and `lastAccessedAt`. Returns a new JSON string.
|
|
114
|
+
*/
|
|
115
|
+
export function buildUpdatedMetadata(
|
|
116
|
+
existingMetadata: string | undefined,
|
|
117
|
+
accessDelta: number,
|
|
118
|
+
): string {
|
|
119
|
+
let existing: Record<string, unknown> = {};
|
|
120
|
+
|
|
121
|
+
if (existingMetadata !== undefined && existingMetadata !== "") {
|
|
122
|
+
try {
|
|
123
|
+
const parsed = JSON.parse(existingMetadata);
|
|
124
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
125
|
+
existing = { ...parsed };
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// malformed JSON — start fresh but preserve nothing
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const prev = parseAccessMetadata(existingMetadata);
|
|
133
|
+
const newCount = clampAccessCount(prev.accessCount + accessDelta);
|
|
134
|
+
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
|
|
137
|
+
return JSON.stringify({
|
|
138
|
+
...existing,
|
|
139
|
+
// Write both camelCase and snake_case for compatibility.
|
|
140
|
+
accessCount: newCount,
|
|
141
|
+
lastAccessedAt: now,
|
|
142
|
+
access_count: newCount,
|
|
143
|
+
last_accessed_at: now,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ============================================================================
|
|
148
|
+
// Effective Half-Life Computation
|
|
149
|
+
// ============================================================================
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Compute the effective half-life for a memory based on its access history.
|
|
153
|
+
*
|
|
154
|
+
* The access count itself decays over time (30-day half-life for access
|
|
155
|
+
* freshness), so stale accesses contribute less reinforcement. The extension
|
|
156
|
+
* uses a logarithmic curve (`Math.log1p`) to provide diminishing returns.
|
|
157
|
+
*
|
|
158
|
+
* @param baseHalfLife - Base half-life in days (e.g. 30)
|
|
159
|
+
* @param accessCount - Raw number of times the memory was accessed
|
|
160
|
+
* @param lastAccessedAt - Timestamp (ms) of last access
|
|
161
|
+
* @param reinforcementFactor - Scaling factor for reinforcement (0 = disabled)
|
|
162
|
+
* @param maxMultiplier - Hard cap: result <= baseHalfLife * maxMultiplier
|
|
163
|
+
* @returns Effective half-life in days
|
|
164
|
+
*/
|
|
165
|
+
export function computeEffectiveHalfLife(
|
|
166
|
+
baseHalfLife: number,
|
|
167
|
+
accessCount: number,
|
|
168
|
+
lastAccessedAt: number,
|
|
169
|
+
reinforcementFactor: number,
|
|
170
|
+
maxMultiplier: number,
|
|
171
|
+
): number {
|
|
172
|
+
// Short-circuit: no reinforcement or no accesses
|
|
173
|
+
if (reinforcementFactor === 0 || accessCount <= 0) {
|
|
174
|
+
return baseHalfLife;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
const daysSinceLastAccess = Math.max(
|
|
179
|
+
0,
|
|
180
|
+
(now - lastAccessedAt) / (1000 * 60 * 60 * 24),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Access freshness decays exponentially with 30-day half-life
|
|
184
|
+
const accessFreshness = Math.exp(
|
|
185
|
+
-daysSinceLastAccess * (Math.LN2 / ACCESS_DECAY_HALF_LIFE_DAYS),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// Effective access count after freshness decay
|
|
189
|
+
const effectiveAccessCount = accessCount * accessFreshness;
|
|
190
|
+
|
|
191
|
+
// Logarithmic extension for diminishing returns
|
|
192
|
+
const extension =
|
|
193
|
+
baseHalfLife * reinforcementFactor * Math.log1p(effectiveAccessCount);
|
|
194
|
+
|
|
195
|
+
const result = baseHalfLife + extension;
|
|
196
|
+
|
|
197
|
+
// Hard cap
|
|
198
|
+
const cap = baseHalfLife * maxMultiplier;
|
|
199
|
+
return Math.min(result, cap);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ============================================================================
|
|
203
|
+
// AccessTracker Class
|
|
204
|
+
// ============================================================================
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Debounced write-back tracker for memory access events.
|
|
208
|
+
*
|
|
209
|
+
* `recordAccess()` is synchronous (Map update only, no I/O). Pending deltas
|
|
210
|
+
* accumulate until `flush()` is called (or by a future scheduled callback).
|
|
211
|
+
* On flush, each pending entry is read via `store.getById()`, its metadata
|
|
212
|
+
* is merged with the accumulated access delta, and written back via
|
|
213
|
+
* `store.update()`.
|
|
214
|
+
*/
|
|
215
|
+
export class AccessTracker {
|
|
216
|
+
private readonly pending: Map<string, number> = new Map();
|
|
217
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
218
|
+
private flushPromise: Promise<void> | null = null;
|
|
219
|
+
private readonly debounceMs: number;
|
|
220
|
+
private readonly store: MemoryStore;
|
|
221
|
+
private readonly logger: {
|
|
222
|
+
warn: (...args: unknown[]) => void;
|
|
223
|
+
info?: (...args: unknown[]) => void;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
constructor(options: AccessTrackerOptions) {
|
|
227
|
+
this.store = options.store;
|
|
228
|
+
this.logger = options.logger;
|
|
229
|
+
this.debounceMs = options.debounceMs ?? 5_000;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Record one access for each of the given memory IDs.
|
|
234
|
+
* Synchronous — only updates the in-memory pending map.
|
|
235
|
+
*/
|
|
236
|
+
recordAccess(ids: readonly string[]): void {
|
|
237
|
+
for (const id of ids) {
|
|
238
|
+
const current = this.pending.get(id) ?? 0;
|
|
239
|
+
this.pending.set(id, current + 1);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Reset debounce timer
|
|
243
|
+
this.resetTimer();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Return a snapshot of all pending (id -> delta) entries.
|
|
248
|
+
*/
|
|
249
|
+
getPendingUpdates(): Map<string, number> {
|
|
250
|
+
return new Map(this.pending);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Flush pending access deltas to the store.
|
|
255
|
+
*
|
|
256
|
+
* If a flush is already in progress, awaits the current flush to complete.
|
|
257
|
+
* If new pending data accumulated during the in-flight flush, a follow-up
|
|
258
|
+
* flush is automatically triggered.
|
|
259
|
+
*/
|
|
260
|
+
async flush(): Promise<void> {
|
|
261
|
+
this.clearTimer();
|
|
262
|
+
|
|
263
|
+
// If a flush is in progress, wait for it to finish
|
|
264
|
+
if (this.flushPromise) {
|
|
265
|
+
await this.flushPromise;
|
|
266
|
+
// After the in-flight flush completes, check if new data accumulated
|
|
267
|
+
if (this.pending.size > 0) {
|
|
268
|
+
return this.flush();
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (this.pending.size === 0) return;
|
|
274
|
+
|
|
275
|
+
this.flushPromise = this.doFlush();
|
|
276
|
+
try {
|
|
277
|
+
await this.flushPromise;
|
|
278
|
+
} finally {
|
|
279
|
+
this.flushPromise = null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// If new data accumulated during flush, schedule a follow-up
|
|
283
|
+
if (this.pending.size > 0) {
|
|
284
|
+
this.resetTimer();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Tear down the tracker — cancel timers and clear pending state.
|
|
290
|
+
*/
|
|
291
|
+
destroy(): void {
|
|
292
|
+
this.clearTimer();
|
|
293
|
+
if (this.pending.size > 0) {
|
|
294
|
+
this.logger.warn(
|
|
295
|
+
`access-tracker: destroying with ${this.pending.size} pending writes`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
this.pending.clear();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// --------------------------------------------------------------------------
|
|
302
|
+
// Internal helpers
|
|
303
|
+
// --------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
private async doFlush(): Promise<void> {
|
|
306
|
+
const batch = new Map(this.pending);
|
|
307
|
+
this.pending.clear();
|
|
308
|
+
|
|
309
|
+
for (const [id, delta] of batch) {
|
|
310
|
+
try {
|
|
311
|
+
const current = await this.store.getById(id);
|
|
312
|
+
if (!current) continue;
|
|
313
|
+
|
|
314
|
+
const updatedMeta = buildUpdatedMetadata(current.metadata, delta);
|
|
315
|
+
await this.store.update(id, { metadata: updatedMeta });
|
|
316
|
+
} catch (err) {
|
|
317
|
+
// Requeue failed delta for retry on next flush
|
|
318
|
+
const existing = this.pending.get(id) ?? 0;
|
|
319
|
+
this.pending.set(id, existing + delta);
|
|
320
|
+
this.logger.warn(
|
|
321
|
+
`access-tracker: write-back failed for ${id.slice(0, 8)}:`,
|
|
322
|
+
err,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private resetTimer(): void {
|
|
329
|
+
this.clearTimer();
|
|
330
|
+
this.debounceTimer = setTimeout(() => {
|
|
331
|
+
void this.flush();
|
|
332
|
+
}, this.debounceMs);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private clearTimer(): void {
|
|
336
|
+
if (this.debounceTimer !== null) {
|
|
337
|
+
clearTimeout(this.debounceTimer);
|
|
338
|
+
this.debounceTimer = null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Storage Adapters
|
|
2
|
+
|
|
3
|
+
Mnemo supports pluggable storage backends via the `StorageAdapter` interface.
|
|
4
|
+
|
|
5
|
+
## Available Adapters
|
|
6
|
+
|
|
7
|
+
| Adapter | Status | BM25/FTS | Install | Best For |
|
|
8
|
+
|---------|--------|----------|---------|----------|
|
|
9
|
+
| **LanceDB** | Stable (default) | Built-in | — | Embedded, edge, single-node |
|
|
10
|
+
| **Qdrant** | Stable | No (vector only) | `npm i @qdrant/js-client-rest` | High-perf filtering, Rust speed |
|
|
11
|
+
| **Chroma** | Stable | Yes (queryTexts) | `npm i chromadb` | Prototyping, Python ecosystem |
|
|
12
|
+
| **PGVector** | Stable | Yes (pg tsvector) | `npm i pg pgvector` | Existing Postgres, full SQL |
|
|
13
|
+
| Weaviate | Planned | — | — | Hybrid search, GraphQL |
|
|
14
|
+
| Milvus | Planned | — | — | Billion-scale, GPU |
|
|
15
|
+
| Pinecone | Planned | — | — | Fully managed SaaS |
|
|
16
|
+
| SQLite-vec | Planned | — | — | Ultra-lightweight, edge |
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
import { createMnemo } from '@mnemo/core';
|
|
22
|
+
|
|
23
|
+
// Default — LanceDB (embedded, zero config)
|
|
24
|
+
const mnemo = await createMnemo({ storage: 'lancedb' });
|
|
25
|
+
|
|
26
|
+
// Qdrant (self-hosted or Qdrant Cloud)
|
|
27
|
+
const mnemo = await createMnemo({
|
|
28
|
+
storage: 'qdrant',
|
|
29
|
+
storageConfig: { url: 'http://localhost:6333' },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Chroma (embedded or server mode)
|
|
33
|
+
const mnemo = await createMnemo({
|
|
34
|
+
storage: 'chroma',
|
|
35
|
+
storageConfig: { url: 'http://localhost:8000' },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// PGVector (existing PostgreSQL)
|
|
39
|
+
const mnemo = await createMnemo({
|
|
40
|
+
storage: 'pgvector',
|
|
41
|
+
storageConfig: { connectionString: 'postgres://user:pass@localhost:5432/mnemo' },
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Choosing a Backend
|
|
46
|
+
|
|
47
|
+
| Scenario | Recommended |
|
|
48
|
+
|----------|-------------|
|
|
49
|
+
| Getting started / prototyping | **LanceDB** (zero setup) |
|
|
50
|
+
| Already running PostgreSQL | **PGVector** (no extra service) |
|
|
51
|
+
| Need fastest vector search | **Qdrant** (Rust, HNSW) |
|
|
52
|
+
| Python-heavy stack | **Chroma** (pip install) |
|
|
53
|
+
| Need BM25 + vector | **LanceDB** or **PGVector** |
|
|
54
|
+
| Air-gapped / edge deployment | **LanceDB** (embedded) |
|
|
55
|
+
|
|
56
|
+
## Creating a Custom Adapter
|
|
57
|
+
|
|
58
|
+
Implement the `StorageAdapter` interface and register it:
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
import { StorageAdapter, registerAdapter } from '@mnemo/core/storage-adapter';
|
|
62
|
+
|
|
63
|
+
class MyAdapter implements StorageAdapter {
|
|
64
|
+
readonly name = 'my-backend';
|
|
65
|
+
async connect(dbPath: string) { /* ... */ }
|
|
66
|
+
async ensureTable(dim: number) { /* ... */ }
|
|
67
|
+
async add(records: MemoryRecord[]) { /* ... */ }
|
|
68
|
+
async vectorSearch(vector, limit, minScore, scopeFilter) { /* ... */ }
|
|
69
|
+
async fullTextSearch(query, limit, scopeFilter) { /* ... */ }
|
|
70
|
+
// ... implement all methods from StorageAdapter
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
registerAdapter('my-backend', () => new MyAdapter());
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Interface Reference
|
|
77
|
+
|
|
78
|
+
See `storage-adapter.ts` for the full `StorageAdapter` interface with JSDoc.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
/**
|
|
3
|
+
* Chroma Storage Adapter for Mnemo
|
|
4
|
+
*
|
|
5
|
+
* Requirements:
|
|
6
|
+
* npm install chromadb
|
|
7
|
+
*
|
|
8
|
+
* Config:
|
|
9
|
+
* storage: "chroma"
|
|
10
|
+
* storageConfig: { url: "http://localhost:8000" } or { path: "./chroma-data" }
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
StorageAdapter,
|
|
15
|
+
MemoryRecord,
|
|
16
|
+
SearchResult,
|
|
17
|
+
QueryOptions,
|
|
18
|
+
} from "../storage-adapter.js";
|
|
19
|
+
import { registerAdapter } from "../storage-adapter.js";
|
|
20
|
+
|
|
21
|
+
const COLLECTION = "mnemo_memories";
|
|
22
|
+
|
|
23
|
+
export class ChromaAdapter implements StorageAdapter {
|
|
24
|
+
readonly name = "chroma";
|
|
25
|
+
|
|
26
|
+
private client: any = null;
|
|
27
|
+
private collection: any = null;
|
|
28
|
+
private config: Record<string, unknown>;
|
|
29
|
+
|
|
30
|
+
constructor(config?: Record<string, unknown>) {
|
|
31
|
+
this.config = config || {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async connect(dbPath: string): Promise<void> {
|
|
35
|
+
const chroma = await import("chromadb");
|
|
36
|
+
|
|
37
|
+
if (this.config.url) {
|
|
38
|
+
// Remote Chroma server
|
|
39
|
+
this.client = new chroma.ChromaClient({ path: this.config.url as string });
|
|
40
|
+
} else {
|
|
41
|
+
// Persistent local (Chroma supports persistent storage)
|
|
42
|
+
this.client = new chroma.ChromaClient({ path: dbPath || this.config.path as string });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async ensureTable(vectorDimensions: number): Promise<void> {
|
|
47
|
+
this.collection = await this.client.getOrCreateCollection({
|
|
48
|
+
name: COLLECTION,
|
|
49
|
+
metadata: { "hnsw:space": "cosine" },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async add(records: MemoryRecord[]): Promise<void> {
|
|
54
|
+
if (!this.collection) throw new Error("Collection not initialized");
|
|
55
|
+
|
|
56
|
+
await this.collection.upsert({
|
|
57
|
+
ids: records.map((r) => r.id),
|
|
58
|
+
embeddings: records.map((r) => r.vector),
|
|
59
|
+
documents: records.map((r) => r.text),
|
|
60
|
+
metadatas: records.map((r) => ({
|
|
61
|
+
timestamp: r.timestamp,
|
|
62
|
+
scope: r.scope,
|
|
63
|
+
importance: r.importance,
|
|
64
|
+
category: r.category,
|
|
65
|
+
metadata: r.metadata,
|
|
66
|
+
})),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async update(id: string, record: MemoryRecord): Promise<void> {
|
|
71
|
+
await this.add([record]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async delete(filter: string): Promise<void> {
|
|
75
|
+
if (!this.collection) throw new Error("Collection not initialized");
|
|
76
|
+
|
|
77
|
+
const idMatch = filter.match(/id\s*=\s*'([^']+)'/);
|
|
78
|
+
if (idMatch) {
|
|
79
|
+
await this.collection.delete({ ids: [idMatch[1]] });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async vectorSearch(
|
|
84
|
+
vector: number[],
|
|
85
|
+
limit: number,
|
|
86
|
+
minScore = 0,
|
|
87
|
+
scopeFilter?: string[],
|
|
88
|
+
): Promise<SearchResult[]> {
|
|
89
|
+
if (!this.collection) throw new Error("Collection not initialized");
|
|
90
|
+
|
|
91
|
+
const where = scopeFilter?.length
|
|
92
|
+
? { scope: { $in: scopeFilter } }
|
|
93
|
+
: undefined;
|
|
94
|
+
|
|
95
|
+
const results = await this.collection.query({
|
|
96
|
+
queryEmbeddings: [vector],
|
|
97
|
+
nResults: limit,
|
|
98
|
+
...(where ? { where } : {}),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!results.ids?.[0]) return [];
|
|
102
|
+
|
|
103
|
+
return results.ids[0].map((id: string, i: number) => {
|
|
104
|
+
// Chroma returns distances, convert to similarity
|
|
105
|
+
const distance = results.distances?.[0]?.[i] ?? 1;
|
|
106
|
+
const score = 1 - distance; // cosine distance → similarity
|
|
107
|
+
const meta = results.metadatas?.[0]?.[i] || {};
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
record: {
|
|
111
|
+
id,
|
|
112
|
+
text: results.documents?.[0]?.[i] ?? "",
|
|
113
|
+
vector: [], // Chroma doesn't return vectors by default
|
|
114
|
+
timestamp: meta.timestamp ?? 0,
|
|
115
|
+
scope: meta.scope ?? "global",
|
|
116
|
+
importance: meta.importance ?? 0.5,
|
|
117
|
+
category: meta.category ?? "other",
|
|
118
|
+
metadata: meta.metadata ?? "{}",
|
|
119
|
+
},
|
|
120
|
+
score,
|
|
121
|
+
};
|
|
122
|
+
}).filter((r: SearchResult) => r.score >= minScore);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async fullTextSearch(
|
|
126
|
+
query: string,
|
|
127
|
+
limit: number,
|
|
128
|
+
scopeFilter?: string[],
|
|
129
|
+
): Promise<SearchResult[]> {
|
|
130
|
+
if (!this.collection) return [];
|
|
131
|
+
|
|
132
|
+
const where = scopeFilter?.length
|
|
133
|
+
? { scope: { $in: scopeFilter } }
|
|
134
|
+
: undefined;
|
|
135
|
+
|
|
136
|
+
// Chroma supports document search via where_document
|
|
137
|
+
const results = await this.collection.query({
|
|
138
|
+
queryTexts: [query],
|
|
139
|
+
nResults: limit,
|
|
140
|
+
...(where ? { where } : {}),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (!results.ids?.[0]) return [];
|
|
144
|
+
|
|
145
|
+
return results.ids[0].map((id: string, i: number) => {
|
|
146
|
+
const distance = results.distances?.[0]?.[i] ?? 1;
|
|
147
|
+
const meta = results.metadatas?.[0]?.[i] || {};
|
|
148
|
+
return {
|
|
149
|
+
record: {
|
|
150
|
+
id,
|
|
151
|
+
text: results.documents?.[0]?.[i] ?? "",
|
|
152
|
+
vector: [],
|
|
153
|
+
timestamp: meta.timestamp ?? 0,
|
|
154
|
+
scope: meta.scope ?? "global",
|
|
155
|
+
importance: meta.importance ?? 0.5,
|
|
156
|
+
category: meta.category ?? "other",
|
|
157
|
+
metadata: meta.metadata ?? "{}",
|
|
158
|
+
},
|
|
159
|
+
score: 1 - distance,
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async query(options: QueryOptions): Promise<MemoryRecord[]> {
|
|
165
|
+
if (!this.collection) throw new Error("Collection not initialized");
|
|
166
|
+
|
|
167
|
+
const result = await this.collection.get({
|
|
168
|
+
limit: options.limit || 100,
|
|
169
|
+
include: ["documents", "metadatas", "embeddings"],
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return (result.ids || []).map((id: string, i: number) => {
|
|
173
|
+
const meta = result.metadatas?.[i] || {};
|
|
174
|
+
return {
|
|
175
|
+
id,
|
|
176
|
+
text: result.documents?.[i] ?? "",
|
|
177
|
+
vector: result.embeddings?.[i] ?? [],
|
|
178
|
+
timestamp: meta.timestamp ?? 0,
|
|
179
|
+
scope: meta.scope ?? "global",
|
|
180
|
+
importance: meta.importance ?? 0.5,
|
|
181
|
+
category: meta.category ?? "other",
|
|
182
|
+
metadata: meta.metadata ?? "{}",
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async count(): Promise<number> {
|
|
188
|
+
if (!this.collection) return 0;
|
|
189
|
+
return await this.collection.count();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async ensureFullTextIndex(): Promise<void> {
|
|
193
|
+
// Chroma handles text search natively via queryTexts
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
hasFullTextSearch(): boolean {
|
|
197
|
+
return true; // Chroma supports document-level text search
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async close(): Promise<void> {
|
|
201
|
+
this.collection = null;
|
|
202
|
+
this.client = null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
registerAdapter("chroma", (config) => new ChromaAdapter(config));
|