@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
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
/**
|
|
3
|
+
* LanceDB Storage Adapter — Default backend for Mnemo.
|
|
4
|
+
*
|
|
5
|
+
* Implements StorageAdapter using @lancedb/lancedb.
|
|
6
|
+
* This is the reference implementation; other backends should
|
|
7
|
+
* produce equivalent behavior.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
StorageAdapter,
|
|
12
|
+
MemoryRecord,
|
|
13
|
+
SearchResult,
|
|
14
|
+
QueryOptions,
|
|
15
|
+
} from "../storage-adapter.js";
|
|
16
|
+
import { registerAdapter } from "../storage-adapter.js";
|
|
17
|
+
|
|
18
|
+
/** Strict allowlist sanitizer — prevents SQL injection in LanceDB filters */
|
|
19
|
+
function sanitize(value: string): string {
|
|
20
|
+
if (typeof value !== "string") return "";
|
|
21
|
+
return value.replace(/[^a-zA-Z0-9\-_.:@ \u4e00-\u9fff\u3400-\u4dbf]/g, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Dynamic import to avoid hard dependency at module level
|
|
25
|
+
let _lancedb: typeof import("@lancedb/lancedb") | null = null;
|
|
26
|
+
|
|
27
|
+
async function loadLanceDB() {
|
|
28
|
+
if (!_lancedb) {
|
|
29
|
+
_lancedb = await import("@lancedb/lancedb");
|
|
30
|
+
}
|
|
31
|
+
return _lancedb;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TABLE_NAME = "memories";
|
|
35
|
+
|
|
36
|
+
export class LanceDBAdapter implements StorageAdapter {
|
|
37
|
+
readonly name = "lancedb";
|
|
38
|
+
|
|
39
|
+
private db: any = null;
|
|
40
|
+
private table: any = null;
|
|
41
|
+
private ftsReady = false;
|
|
42
|
+
private vectorDim = 0;
|
|
43
|
+
|
|
44
|
+
async connect(dbPath: string): Promise<void> {
|
|
45
|
+
const lancedb = await loadLanceDB();
|
|
46
|
+
this.db = await lancedb.connect(dbPath);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async ensureTable(vectorDimensions: number): Promise<void> {
|
|
50
|
+
this.vectorDim = vectorDimensions;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
this.table = await this.db.openTable(TABLE_NAME);
|
|
54
|
+
} catch {
|
|
55
|
+
// Table doesn't exist — create with schema
|
|
56
|
+
const lancedb = await loadLanceDB();
|
|
57
|
+
const schemaEntry: MemoryRecord = {
|
|
58
|
+
id: "__schema__",
|
|
59
|
+
text: "",
|
|
60
|
+
vector: new Array(vectorDimensions).fill(0),
|
|
61
|
+
timestamp: 0,
|
|
62
|
+
scope: "global",
|
|
63
|
+
importance: 0,
|
|
64
|
+
category: "other",
|
|
65
|
+
metadata: "{}",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
this.table = await this.db.createTable(TABLE_NAME, [schemaEntry]);
|
|
70
|
+
await this.table.delete('id = "__schema__"');
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (String(err).includes("already exists")) {
|
|
73
|
+
this.table = await this.db.openTable(TABLE_NAME);
|
|
74
|
+
} else {
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Validate dimensions
|
|
81
|
+
const sample = await this.table.query().limit(1).toArray();
|
|
82
|
+
if (sample.length > 0 && sample[0]?.vector?.length) {
|
|
83
|
+
const existing = sample[0].vector.length;
|
|
84
|
+
if (existing !== vectorDimensions) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Vector dimension mismatch: table=${existing}, config=${vectorDimensions}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Create FTS index
|
|
92
|
+
await this.ensureFullTextIndex();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async add(records: MemoryRecord[]): Promise<void> {
|
|
96
|
+
if (!this.table) throw new Error("Table not initialized");
|
|
97
|
+
await this.table.add(records);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async update(id: string, record: MemoryRecord): Promise<void> {
|
|
101
|
+
if (!this.table) throw new Error("Table not initialized");
|
|
102
|
+
await this.table.delete(`id = '${sanitize(id)}'`);
|
|
103
|
+
await this.table.add([record]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async delete(filter: string): Promise<void> {
|
|
107
|
+
if (!this.table) throw new Error("Table not initialized");
|
|
108
|
+
await this.table.delete(filter);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async vectorSearch(
|
|
112
|
+
vector: number[],
|
|
113
|
+
limit: number,
|
|
114
|
+
minScore = 0,
|
|
115
|
+
scopeFilter?: string[],
|
|
116
|
+
): Promise<SearchResult[]> {
|
|
117
|
+
if (!this.table) throw new Error("Table not initialized");
|
|
118
|
+
|
|
119
|
+
let query = this.table.vectorSearch(vector).distanceType("cosine").limit(limit * 3);
|
|
120
|
+
|
|
121
|
+
if (scopeFilter?.length) {
|
|
122
|
+
const scopeExpr = scopeFilter.map((s) => `'${sanitize(s)}'`).join(", ");
|
|
123
|
+
query = query.where(`scope IN (${scopeExpr})`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const raw = await query.toArray();
|
|
127
|
+
|
|
128
|
+
return raw
|
|
129
|
+
.map((row: any) => {
|
|
130
|
+
const distance = row._distance ?? row.distance ?? 1;
|
|
131
|
+
const score = 1 / (1 + distance);
|
|
132
|
+
return { record: this.toRecord(row), score };
|
|
133
|
+
})
|
|
134
|
+
.filter((r: SearchResult) => r.score >= minScore)
|
|
135
|
+
.slice(0, limit);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async fullTextSearch(
|
|
139
|
+
queryText: string,
|
|
140
|
+
limit: number,
|
|
141
|
+
scopeFilter?: string[],
|
|
142
|
+
): Promise<SearchResult[]> {
|
|
143
|
+
if (!this.table || !this.ftsReady) return [];
|
|
144
|
+
|
|
145
|
+
let query = this.table.search(queryText, "fts").limit(limit * 2);
|
|
146
|
+
|
|
147
|
+
if (scopeFilter?.length) {
|
|
148
|
+
const scopeExpr = scopeFilter.map((s) => `'${sanitize(s)}'`).join(", ");
|
|
149
|
+
query = query.where(`scope IN (${scopeExpr})`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const raw = await query.toArray();
|
|
153
|
+
|
|
154
|
+
return raw
|
|
155
|
+
.map((row: any) => {
|
|
156
|
+
const score = row._relevance_score ?? row.score ?? 0.5;
|
|
157
|
+
return { record: this.toRecord(row), score };
|
|
158
|
+
})
|
|
159
|
+
.slice(0, limit);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async query(options: QueryOptions): Promise<MemoryRecord[]> {
|
|
163
|
+
if (!this.table) throw new Error("Table not initialized");
|
|
164
|
+
|
|
165
|
+
let q = this.table.query();
|
|
166
|
+
|
|
167
|
+
if (options.select?.length) {
|
|
168
|
+
q = q.select(options.select);
|
|
169
|
+
}
|
|
170
|
+
if (options.where) {
|
|
171
|
+
q = q.where(options.where);
|
|
172
|
+
}
|
|
173
|
+
if (options.limit) {
|
|
174
|
+
q = q.limit(options.limit);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const raw = await q.toArray();
|
|
178
|
+
return raw.map((row: any) => this.toRecord(row));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async count(filter?: string): Promise<number> {
|
|
182
|
+
if (!this.table) throw new Error("Table not initialized");
|
|
183
|
+
let q = this.table.query();
|
|
184
|
+
if (filter) q = q.where(filter);
|
|
185
|
+
const rows = await q.toArray();
|
|
186
|
+
return rows.length;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async ensureFullTextIndex(): Promise<void> {
|
|
190
|
+
if (!this.table) return;
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const indices = await this.table.listIndices();
|
|
194
|
+
const hasFts = indices?.some(
|
|
195
|
+
(idx: any) => idx.indexType === "FTS" || idx.columns?.includes("text"),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
if (!hasFts) {
|
|
199
|
+
const lancedb = await loadLanceDB();
|
|
200
|
+
await this.table.createIndex("text", {
|
|
201
|
+
config: (lancedb as any).Index.fts(),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
this.ftsReady = true;
|
|
205
|
+
} catch {
|
|
206
|
+
this.ftsReady = false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
hasFullTextSearch(): boolean {
|
|
211
|
+
return this.ftsReady;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async close(): Promise<void> {
|
|
215
|
+
this.table = null;
|
|
216
|
+
this.db = null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Helpers ──
|
|
220
|
+
|
|
221
|
+
private toRecord(row: any): MemoryRecord {
|
|
222
|
+
return {
|
|
223
|
+
id: row.id,
|
|
224
|
+
text: row.text,
|
|
225
|
+
vector: row.vector ? Array.from(row.vector) : [],
|
|
226
|
+
timestamp: row.timestamp ?? 0,
|
|
227
|
+
scope: row.scope ?? "global",
|
|
228
|
+
importance: row.importance ?? 0.5,
|
|
229
|
+
category: row.category ?? "other",
|
|
230
|
+
metadata: row.metadata ?? "{}",
|
|
231
|
+
...row, // preserve extra fields
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Auto-register ──
|
|
237
|
+
registerAdapter("lancedb", () => new LanceDBAdapter());
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
/**
|
|
3
|
+
* PGVector Storage Adapter for Mnemo
|
|
4
|
+
*
|
|
5
|
+
* Requirements:
|
|
6
|
+
* npm install pg pgvector
|
|
7
|
+
* PostgreSQL with pgvector extension enabled
|
|
8
|
+
*
|
|
9
|
+
* Config:
|
|
10
|
+
* storage: "pgvector"
|
|
11
|
+
* storageConfig: { connectionString: "postgres://user:pass@localhost:5432/mnemo" }
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
StorageAdapter,
|
|
16
|
+
MemoryRecord,
|
|
17
|
+
SearchResult,
|
|
18
|
+
QueryOptions,
|
|
19
|
+
} from "../storage-adapter.js";
|
|
20
|
+
import { registerAdapter } from "../storage-adapter.js";
|
|
21
|
+
|
|
22
|
+
const TABLE = "mnemo_memories";
|
|
23
|
+
|
|
24
|
+
export class PGVectorAdapter implements StorageAdapter {
|
|
25
|
+
readonly name = "pgvector";
|
|
26
|
+
|
|
27
|
+
private pool: any = null;
|
|
28
|
+
private vectorDim = 0;
|
|
29
|
+
private connectionString: string;
|
|
30
|
+
|
|
31
|
+
constructor(config?: Record<string, unknown>) {
|
|
32
|
+
this.connectionString = (config?.connectionString as string) ||
|
|
33
|
+
"postgres://localhost:5432/mnemo";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async connect(dbPath: string): Promise<void> {
|
|
37
|
+
const { Pool } = await import("pg");
|
|
38
|
+
this.pool = new Pool({
|
|
39
|
+
connectionString: dbPath || this.connectionString,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Enable pgvector extension
|
|
43
|
+
await this.pool.query("CREATE EXTENSION IF NOT EXISTS vector");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async ensureTable(vectorDimensions: number): Promise<void> {
|
|
47
|
+
this.vectorDim = vectorDimensions;
|
|
48
|
+
|
|
49
|
+
await this.pool.query(`
|
|
50
|
+
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
51
|
+
id TEXT PRIMARY KEY,
|
|
52
|
+
text TEXT NOT NULL,
|
|
53
|
+
vector vector(${vectorDimensions}),
|
|
54
|
+
timestamp BIGINT DEFAULT 0,
|
|
55
|
+
scope TEXT DEFAULT 'global',
|
|
56
|
+
importance REAL DEFAULT 0.5,
|
|
57
|
+
category TEXT DEFAULT 'other',
|
|
58
|
+
metadata JSONB DEFAULT '{}'::jsonb
|
|
59
|
+
)
|
|
60
|
+
`);
|
|
61
|
+
|
|
62
|
+
// Create HNSW index for vector search
|
|
63
|
+
await this.pool.query(`
|
|
64
|
+
CREATE INDEX IF NOT EXISTS ${TABLE}_vector_idx
|
|
65
|
+
ON ${TABLE} USING hnsw (vector vector_cosine_ops)
|
|
66
|
+
`).catch(() => {}); // ignore if already exists
|
|
67
|
+
|
|
68
|
+
// Create GIN index for full-text search
|
|
69
|
+
await this.pool.query(`
|
|
70
|
+
CREATE INDEX IF NOT EXISTS ${TABLE}_text_idx
|
|
71
|
+
ON ${TABLE} USING gin (to_tsvector('simple', text))
|
|
72
|
+
`).catch(() => {});
|
|
73
|
+
|
|
74
|
+
// Create index on scope for filtering
|
|
75
|
+
await this.pool.query(`
|
|
76
|
+
CREATE INDEX IF NOT EXISTS ${TABLE}_scope_idx ON ${TABLE} (scope)
|
|
77
|
+
`).catch(() => {});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async add(records: MemoryRecord[]): Promise<void> {
|
|
81
|
+
for (const r of records) {
|
|
82
|
+
await this.pool.query(
|
|
83
|
+
`INSERT INTO ${TABLE} (id, text, vector, timestamp, scope, importance, category, metadata)
|
|
84
|
+
VALUES ($1, $2, $3::vector, $4, $5, $6, $7, $8)
|
|
85
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
86
|
+
text = EXCLUDED.text,
|
|
87
|
+
vector = EXCLUDED.vector,
|
|
88
|
+
timestamp = EXCLUDED.timestamp,
|
|
89
|
+
scope = EXCLUDED.scope,
|
|
90
|
+
importance = EXCLUDED.importance,
|
|
91
|
+
category = EXCLUDED.category,
|
|
92
|
+
metadata = EXCLUDED.metadata`,
|
|
93
|
+
[r.id, r.text, `[${r.vector.join(",")}]`, r.timestamp, r.scope, r.importance, r.category, r.metadata],
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async update(id: string, record: MemoryRecord): Promise<void> {
|
|
99
|
+
await this.add([record]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async delete(filter: string): Promise<void> {
|
|
103
|
+
const idMatch = filter.match(/id\s*=\s*'([^']+)'/);
|
|
104
|
+
if (idMatch) {
|
|
105
|
+
await this.pool.query(`DELETE FROM ${TABLE} WHERE id = $1`, [idMatch[1]]);
|
|
106
|
+
} else {
|
|
107
|
+
// Pass filter as-is for simple SQL WHERE clauses
|
|
108
|
+
await this.pool.query(`DELETE FROM ${TABLE} WHERE ${filter}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async vectorSearch(
|
|
113
|
+
vector: number[],
|
|
114
|
+
limit: number,
|
|
115
|
+
minScore = 0,
|
|
116
|
+
scopeFilter?: string[],
|
|
117
|
+
): Promise<SearchResult[]> {
|
|
118
|
+
const vectorStr = `[${vector.join(",")}]`;
|
|
119
|
+
let query = `
|
|
120
|
+
SELECT *, 1 - (vector <=> $1::vector) AS score
|
|
121
|
+
FROM ${TABLE}
|
|
122
|
+
WHERE 1 - (vector <=> $1::vector) >= $2
|
|
123
|
+
`;
|
|
124
|
+
const params: any[] = [vectorStr, minScore];
|
|
125
|
+
|
|
126
|
+
if (scopeFilter?.length) {
|
|
127
|
+
query += ` AND scope = ANY($3)`;
|
|
128
|
+
params.push(scopeFilter);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
query += ` ORDER BY vector <=> $1::vector LIMIT $${params.length + 1}`;
|
|
132
|
+
params.push(limit);
|
|
133
|
+
|
|
134
|
+
const result = await this.pool.query(query, params);
|
|
135
|
+
|
|
136
|
+
return result.rows.map((row: any) => ({
|
|
137
|
+
record: this.toRecord(row),
|
|
138
|
+
score: parseFloat(row.score),
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async fullTextSearch(
|
|
143
|
+
queryText: string,
|
|
144
|
+
limit: number,
|
|
145
|
+
scopeFilter?: string[],
|
|
146
|
+
): Promise<SearchResult[]> {
|
|
147
|
+
// Use PostgreSQL full-text search with ts_rank
|
|
148
|
+
let query = `
|
|
149
|
+
SELECT *, ts_rank(to_tsvector('simple', text), plainto_tsquery('simple', $1)) AS score
|
|
150
|
+
FROM ${TABLE}
|
|
151
|
+
WHERE to_tsvector('simple', text) @@ plainto_tsquery('simple', $1)
|
|
152
|
+
`;
|
|
153
|
+
const params: any[] = [queryText];
|
|
154
|
+
|
|
155
|
+
if (scopeFilter?.length) {
|
|
156
|
+
query += ` AND scope = ANY($2)`;
|
|
157
|
+
params.push(scopeFilter);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
query += ` ORDER BY score DESC LIMIT $${params.length + 1}`;
|
|
161
|
+
params.push(limit);
|
|
162
|
+
|
|
163
|
+
const result = await this.pool.query(query, params);
|
|
164
|
+
|
|
165
|
+
return result.rows.map((row: any) => ({
|
|
166
|
+
record: this.toRecord(row),
|
|
167
|
+
score: parseFloat(row.score),
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async query(options: QueryOptions): Promise<MemoryRecord[]> {
|
|
172
|
+
let query = `SELECT * FROM ${TABLE}`;
|
|
173
|
+
if (options.where) query += ` WHERE ${options.where}`;
|
|
174
|
+
query += ` LIMIT ${options.limit || 100}`;
|
|
175
|
+
|
|
176
|
+
const result = await this.pool.query(query);
|
|
177
|
+
return result.rows.map((row: any) => this.toRecord(row));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async count(filter?: string): Promise<number> {
|
|
181
|
+
let query = `SELECT COUNT(*) FROM ${TABLE}`;
|
|
182
|
+
if (filter) query += ` WHERE ${filter}`;
|
|
183
|
+
const result = await this.pool.query(query);
|
|
184
|
+
return parseInt(result.rows[0].count);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async ensureFullTextIndex(): Promise<void> {
|
|
188
|
+
// Created in ensureTable
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
hasFullTextSearch(): boolean {
|
|
192
|
+
return true; // PostgreSQL has native full-text search
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async close(): Promise<void> {
|
|
196
|
+
if (this.pool) {
|
|
197
|
+
await this.pool.end();
|
|
198
|
+
this.pool = null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Helpers ──
|
|
203
|
+
|
|
204
|
+
private toRecord(row: any): MemoryRecord {
|
|
205
|
+
return {
|
|
206
|
+
id: row.id,
|
|
207
|
+
text: row.text,
|
|
208
|
+
vector: row.vector ? (typeof row.vector === "string" ? JSON.parse(row.vector) : Array.from(row.vector)) : [],
|
|
209
|
+
timestamp: parseInt(row.timestamp) || 0,
|
|
210
|
+
scope: row.scope ?? "global",
|
|
211
|
+
importance: parseFloat(row.importance) || 0.5,
|
|
212
|
+
category: row.category ?? "other",
|
|
213
|
+
metadata: typeof row.metadata === "object" ? JSON.stringify(row.metadata) : (row.metadata ?? "{}"),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
registerAdapter("pgvector", (config) => new PGVectorAdapter(config));
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
/**
|
|
3
|
+
* Qdrant Storage Adapter for Mnemo
|
|
4
|
+
*
|
|
5
|
+
* Requirements:
|
|
6
|
+
* npm install @qdrant/js-client-rest
|
|
7
|
+
*
|
|
8
|
+
* Config:
|
|
9
|
+
* storage: "qdrant"
|
|
10
|
+
* storageConfig: { url: "http://localhost:6333", apiKey?: "..." }
|
|
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 QdrantAdapter implements StorageAdapter {
|
|
24
|
+
readonly name = "qdrant";
|
|
25
|
+
|
|
26
|
+
private client: any = null;
|
|
27
|
+
private url: string = "http://localhost:6333";
|
|
28
|
+
private apiKey?: string;
|
|
29
|
+
private vectorDim = 0;
|
|
30
|
+
|
|
31
|
+
constructor(config?: Record<string, unknown>) {
|
|
32
|
+
if (config?.url) this.url = config.url as string;
|
|
33
|
+
if (config?.apiKey) this.apiKey = config.apiKey as string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async connect(): Promise<void> {
|
|
37
|
+
const { QdrantClient } = await import("@qdrant/js-client-rest");
|
|
38
|
+
this.client = new QdrantClient({
|
|
39
|
+
url: this.url,
|
|
40
|
+
...(this.apiKey ? { apiKey: this.apiKey } : {}),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async ensureTable(vectorDimensions: number): Promise<void> {
|
|
45
|
+
this.vectorDim = vectorDimensions;
|
|
46
|
+
const collections = await this.client.getCollections();
|
|
47
|
+
const exists = collections.collections.some((c: any) => c.name === COLLECTION);
|
|
48
|
+
|
|
49
|
+
if (!exists) {
|
|
50
|
+
await this.client.createCollection(COLLECTION, {
|
|
51
|
+
vectors: { size: vectorDimensions, distance: "Cosine" },
|
|
52
|
+
});
|
|
53
|
+
// Create payload indices for filtering
|
|
54
|
+
await this.client.createPayloadIndex(COLLECTION, {
|
|
55
|
+
field_name: "scope",
|
|
56
|
+
field_schema: "keyword",
|
|
57
|
+
});
|
|
58
|
+
await this.client.createPayloadIndex(COLLECTION, {
|
|
59
|
+
field_name: "category",
|
|
60
|
+
field_schema: "keyword",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async add(records: MemoryRecord[]): Promise<void> {
|
|
66
|
+
const points = records.map((r) => ({
|
|
67
|
+
id: r.id,
|
|
68
|
+
vector: r.vector,
|
|
69
|
+
payload: {
|
|
70
|
+
text: r.text,
|
|
71
|
+
timestamp: r.timestamp,
|
|
72
|
+
scope: r.scope,
|
|
73
|
+
importance: r.importance,
|
|
74
|
+
category: r.category,
|
|
75
|
+
metadata: r.metadata,
|
|
76
|
+
},
|
|
77
|
+
}));
|
|
78
|
+
await this.client.upsert(COLLECTION, { points });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async update(id: string, record: MemoryRecord): Promise<void> {
|
|
82
|
+
await this.add([record]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async delete(filter: string): Promise<void> {
|
|
86
|
+
// Parse simple "id = 'xxx'" filter
|
|
87
|
+
const idMatch = filter.match(/id\s*=\s*'([^']+)'/);
|
|
88
|
+
if (idMatch) {
|
|
89
|
+
await this.client.delete(COLLECTION, {
|
|
90
|
+
points: [idMatch[1]],
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async vectorSearch(
|
|
96
|
+
vector: number[],
|
|
97
|
+
limit: number,
|
|
98
|
+
minScore = 0,
|
|
99
|
+
scopeFilter?: string[],
|
|
100
|
+
): Promise<SearchResult[]> {
|
|
101
|
+
const filter = scopeFilter?.length
|
|
102
|
+
? { must: [{ key: "scope", match: { any: scopeFilter } }] }
|
|
103
|
+
: undefined;
|
|
104
|
+
|
|
105
|
+
const results = await this.client.search(COLLECTION, {
|
|
106
|
+
vector,
|
|
107
|
+
limit,
|
|
108
|
+
with_payload: true,
|
|
109
|
+
score_threshold: minScore,
|
|
110
|
+
...(filter ? { filter } : {}),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return results.map((r: any) => ({
|
|
114
|
+
record: this.toRecord(r.id, r.payload),
|
|
115
|
+
score: r.score,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async fullTextSearch(
|
|
120
|
+
_query: string,
|
|
121
|
+
_limit: number,
|
|
122
|
+
_scopeFilter?: string[],
|
|
123
|
+
): Promise<SearchResult[]> {
|
|
124
|
+
// Qdrant doesn't have native BM25 — fall back to empty
|
|
125
|
+
// Users should pair with a separate FTS engine or use vector search
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async query(options: QueryOptions): Promise<MemoryRecord[]> {
|
|
130
|
+
const filter = options.where
|
|
131
|
+
? this.parseFilter(options.where)
|
|
132
|
+
: undefined;
|
|
133
|
+
|
|
134
|
+
const result = await this.client.scroll(COLLECTION, {
|
|
135
|
+
limit: options.limit || 100,
|
|
136
|
+
with_payload: true,
|
|
137
|
+
with_vectors: true,
|
|
138
|
+
...(filter ? { filter } : {}),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return result.points.map((p: any) => this.toRecord(p.id, p.payload, p.vector));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async count(filter?: string): Promise<number> {
|
|
145
|
+
const result = await this.client.count(COLLECTION, {
|
|
146
|
+
...(filter ? { filter: this.parseFilter(filter) } : {}),
|
|
147
|
+
exact: true,
|
|
148
|
+
});
|
|
149
|
+
return result.count;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async ensureFullTextIndex(): Promise<void> {
|
|
153
|
+
// Qdrant uses payload indices, not FTS indices
|
|
154
|
+
// Text search via Qdrant requires external FTS or payload keyword match
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
hasFullTextSearch(): boolean {
|
|
158
|
+
return false; // Qdrant doesn't have native BM25
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async close(): Promise<void> {
|
|
162
|
+
this.client = null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Helpers ──
|
|
166
|
+
|
|
167
|
+
private toRecord(id: string, payload: any, vector?: number[]): MemoryRecord {
|
|
168
|
+
return {
|
|
169
|
+
id,
|
|
170
|
+
text: payload.text ?? "",
|
|
171
|
+
vector: vector ? Array.from(vector) : [],
|
|
172
|
+
timestamp: payload.timestamp ?? 0,
|
|
173
|
+
scope: payload.scope ?? "global",
|
|
174
|
+
importance: payload.importance ?? 0.5,
|
|
175
|
+
category: payload.category ?? "other",
|
|
176
|
+
metadata: payload.metadata ?? "{}",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private parseFilter(where: string): any {
|
|
181
|
+
// Simple parser for common filters
|
|
182
|
+
const scopeMatch = where.match(/scope\s+IN\s*\(([^)]+)\)/i);
|
|
183
|
+
if (scopeMatch) {
|
|
184
|
+
const scopes = scopeMatch[1].split(",").map((s) => s.trim().replace(/'/g, ""));
|
|
185
|
+
return { must: [{ key: "scope", match: { any: scopes } }] };
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
registerAdapter("qdrant", (config) => new QdrantAdapter(config));
|