@akashabot/openclaw-memory-offline-core 0.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.
@@ -0,0 +1,62 @@
1
+ import Database from 'better-sqlite3';
2
+ export type MemConfig = {
3
+ dbPath: string;
4
+ provider?: 'ollama' | 'openai';
5
+ ollamaBaseUrl?: string;
6
+ ollamaTimeoutMs?: number;
7
+ openaiBaseUrl?: string;
8
+ openaiApiKey?: string;
9
+ openaiModel?: string;
10
+ embeddingModel?: string;
11
+ };
12
+ export type MemItem = {
13
+ id: string;
14
+ created_at: number;
15
+ source: string | null;
16
+ source_id: string | null;
17
+ title: string | null;
18
+ text: string;
19
+ tags: string | null;
20
+ meta: string | null;
21
+ };
22
+ export type InsertItemInput = Omit<MemItem, 'created_at'> & {
23
+ created_at?: number;
24
+ };
25
+ export type LexicalResult = {
26
+ item: MemItem;
27
+ lexicalScore: number;
28
+ };
29
+ export type HybridResult = LexicalResult & {
30
+ semanticScore: number | null;
31
+ score: number;
32
+ };
33
+ export declare function openDb(dbPath: string): Database.Database;
34
+ export declare function initSchema(db: Database.Database): void;
35
+ /**
36
+ * Minimal escaping for FTS5 queries.
37
+ *
38
+ * - If the query is made of simple word tokens, return it as-is.
39
+ * - Otherwise, wrap as a phrase query and escape double quotes.
40
+ */
41
+ export declare function escapeFts5Query(query: string): string;
42
+ /**
43
+ * Convenience wrapper for CLI usage: accepts meta as an object and stringifies it.
44
+ */
45
+ export declare function addItem(db: Database.Database, input: Omit<InsertItemInput, 'meta'> & {
46
+ meta?: unknown;
47
+ }): MemItem;
48
+ /**
49
+ * Convenience wrapper for CLI usage: returns the escaped query and lexical results.
50
+ */
51
+ export declare function searchItems(db: Database.Database, query: string, limit?: number): {
52
+ query: string;
53
+ escapedQuery: string;
54
+ results: LexicalResult[];
55
+ };
56
+ export declare function insertItem(db: Database.Database, input: InsertItemInput): MemItem;
57
+ export declare function lexicalSearch(db: Database.Database, query: string, limit?: number): LexicalResult[];
58
+ export declare function hybridSearch(db: Database.Database, cfg: MemConfig, query: string, opts?: {
59
+ topK?: number;
60
+ candidates?: number;
61
+ semanticWeight?: number;
62
+ }): Promise<HybridResult[]>;
package/dist/index.js ADDED
@@ -0,0 +1,318 @@
1
+ import Database from 'better-sqlite3';
2
+ export function openDb(dbPath) {
3
+ const db = new Database(dbPath);
4
+ db.pragma('journal_mode = WAL');
5
+ db.pragma('synchronous = NORMAL');
6
+ return db;
7
+ }
8
+ export function initSchema(db) {
9
+ db.exec(`
10
+ CREATE TABLE IF NOT EXISTS items (
11
+ id TEXT PRIMARY KEY,
12
+ created_at INTEGER NOT NULL,
13
+ source TEXT,
14
+ source_id TEXT,
15
+ title TEXT,
16
+ text TEXT NOT NULL,
17
+ tags TEXT,
18
+ meta TEXT
19
+ );
20
+
21
+ CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(
22
+ title,
23
+ text,
24
+ tags,
25
+ content='items',
26
+ content_rowid='rowid'
27
+ );
28
+
29
+ -- Keep the FTS index in sync with items.
30
+ CREATE TRIGGER IF NOT EXISTS items_ai AFTER INSERT ON items BEGIN
31
+ INSERT INTO items_fts(rowid, title, text, tags)
32
+ VALUES (new.rowid, new.title, new.text, new.tags);
33
+ END;
34
+
35
+ CREATE TRIGGER IF NOT EXISTS items_ad AFTER DELETE ON items BEGIN
36
+ INSERT INTO items_fts(items_fts, rowid, title, text, tags)
37
+ VALUES('delete', old.rowid, old.title, old.text, old.tags);
38
+ END;
39
+
40
+ CREATE TRIGGER IF NOT EXISTS items_au AFTER UPDATE ON items BEGIN
41
+ INSERT INTO items_fts(items_fts, rowid, title, text, tags)
42
+ VALUES('delete', old.rowid, old.title, old.text, old.tags);
43
+ INSERT INTO items_fts(rowid, title, text, tags)
44
+ VALUES (new.rowid, new.title, new.text, new.tags);
45
+ END;
46
+
47
+ CREATE TABLE IF NOT EXISTS embeddings (
48
+ item_id TEXT PRIMARY KEY,
49
+ model TEXT NOT NULL,
50
+ dims INTEGER NOT NULL,
51
+ vector BLOB NOT NULL,
52
+ updated_at INTEGER NOT NULL,
53
+ FOREIGN KEY(item_id) REFERENCES items(id)
54
+ );
55
+ `);
56
+ }
57
+ /**
58
+ * Minimal escaping for FTS5 queries.
59
+ *
60
+ * - If the query is made of simple word tokens, return it as-is.
61
+ * - Otherwise, wrap as a phrase query and escape double quotes.
62
+ */
63
+ export function escapeFts5Query(query) {
64
+ const q = query.trim();
65
+ if (!q)
66
+ return '""';
67
+ const simple = /^[\p{L}\p{N}_]+(?:\s+[\p{L}\p{N}_]+)*$/u;
68
+ if (simple.test(q))
69
+ return q;
70
+ return `"${q.replace(/"/g, '""')}"`;
71
+ }
72
+ /**
73
+ * Convenience wrapper for CLI usage: accepts meta as an object and stringifies it.
74
+ */
75
+ export function addItem(db, input) {
76
+ return insertItem(db, {
77
+ ...input,
78
+ meta: input.meta === undefined ? null : typeof input.meta === 'string' ? input.meta : JSON.stringify(input.meta),
79
+ });
80
+ }
81
+ /**
82
+ * Convenience wrapper for CLI usage: returns the escaped query and lexical results.
83
+ */
84
+ export function searchItems(db, query, limit = 10) {
85
+ const escapedQuery = escapeFts5Query(query);
86
+ const results = lexicalSearch(db, escapedQuery, limit);
87
+ return { query, escapedQuery, results };
88
+ }
89
+ export function insertItem(db, input) {
90
+ const now = input.created_at ?? Date.now();
91
+ const stmt = db.prepare(`
92
+ INSERT INTO items (id, created_at, source, source_id, title, text, tags, meta)
93
+ VALUES (@id, @created_at, @source, @source_id, @title, @text, @tags, @meta)
94
+ `);
95
+ stmt.run({
96
+ id: input.id,
97
+ created_at: now,
98
+ source: input.source ?? null,
99
+ source_id: input.source_id ?? null,
100
+ title: input.title ?? null,
101
+ text: input.text,
102
+ tags: input.tags ?? null,
103
+ meta: input.meta ?? null,
104
+ });
105
+ return {
106
+ id: input.id,
107
+ created_at: now,
108
+ source: input.source ?? null,
109
+ source_id: input.source_id ?? null,
110
+ title: input.title ?? null,
111
+ text: input.text,
112
+ tags: input.tags ?? null,
113
+ meta: input.meta ?? null,
114
+ };
115
+ }
116
+ export function lexicalSearch(db, query, limit = 10) {
117
+ const rows = db
118
+ .prepare(`
119
+ SELECT
120
+ i.id,
121
+ i.created_at,
122
+ i.source,
123
+ i.source_id,
124
+ i.title,
125
+ i.text,
126
+ i.tags,
127
+ i.meta,
128
+ bm25(items_fts) AS bm25
129
+ FROM items_fts
130
+ JOIN items i ON i.rowid = items_fts.rowid
131
+ WHERE items_fts MATCH ?
132
+ ORDER BY bm25 ASC
133
+ LIMIT ?
134
+ `)
135
+ .all(query, limit);
136
+ return rows.map((r) => ({
137
+ item: {
138
+ id: r.id,
139
+ created_at: r.created_at,
140
+ source: r.source,
141
+ source_id: r.source_id,
142
+ title: r.title,
143
+ text: r.text,
144
+ tags: r.tags,
145
+ meta: r.meta,
146
+ },
147
+ // bm25: lower is better; flip sign so higher is better
148
+ lexicalScore: -Number(r.bm25),
149
+ }));
150
+ }
151
+ function cosine(a, b) {
152
+ if (a.length !== b.length)
153
+ throw new Error(`cosine: length mismatch ${a.length} vs ${b.length}`);
154
+ let dot = 0;
155
+ let na = 0;
156
+ let nb = 0;
157
+ for (let i = 0; i < a.length; i++) {
158
+ const av = a[i];
159
+ const bv = b[i];
160
+ dot += av * bv;
161
+ na += av * av;
162
+ nb += bv * bv;
163
+ }
164
+ const denom = Math.sqrt(na) * Math.sqrt(nb);
165
+ return denom === 0 ? 0 : dot / denom;
166
+ }
167
+ async function fetchEmbeddingOllama(cfg, input) {
168
+ const baseUrl = cfg.ollamaBaseUrl ?? 'http://127.0.0.1:11434';
169
+ const model = cfg.embeddingModel ?? 'bge-m3';
170
+ const timeoutMs = cfg.ollamaTimeoutMs ?? 3000;
171
+ const res = await fetch(`${baseUrl.replace(/\/$/, '')}/v1/embeddings`, {
172
+ method: 'POST',
173
+ headers: { 'content-type': 'application/json' },
174
+ body: JSON.stringify({ model, input }),
175
+ signal: AbortSignal.timeout(timeoutMs),
176
+ });
177
+ if (!res.ok) {
178
+ const body = await res.text().catch(() => '');
179
+ throw new Error(`Ollama embeddings failed: ${res.status} ${res.statusText} ${body}`);
180
+ }
181
+ const json = (await res.json());
182
+ const embedding = json?.data?.[0]?.embedding;
183
+ if (!Array.isArray(embedding))
184
+ throw new Error('Ollama embeddings: missing data[0].embedding');
185
+ const vec = new Float32Array(embedding.length);
186
+ for (let i = 0; i < embedding.length; i++)
187
+ vec[i] = Number(embedding[i]);
188
+ return { vector: vec, dims: vec.length, model };
189
+ }
190
+ async function fetchEmbeddingOpenAI(cfg, input) {
191
+ const baseUrl = cfg.openaiBaseUrl ?? 'https://api.openai.com';
192
+ const apiKey = cfg.openaiApiKey;
193
+ if (!apiKey) {
194
+ throw new Error('OpenAI embeddings: missing openaiApiKey in MemConfig');
195
+ }
196
+ const model = cfg.openaiModel ?? (cfg.embeddingModel || 'text-embedding-3-small');
197
+ const timeoutMs = cfg.ollamaTimeoutMs ?? 3000;
198
+ const res = await fetch(`${baseUrl.replace(/\/$/, '')}/v1/embeddings`, {
199
+ method: 'POST',
200
+ headers: {
201
+ 'content-type': 'application/json',
202
+ Authorization: `Bearer ${apiKey}`,
203
+ },
204
+ body: JSON.stringify({ model, input }),
205
+ signal: AbortSignal.timeout(timeoutMs),
206
+ });
207
+ if (!res.ok) {
208
+ const body = await res.text().catch(() => '');
209
+ throw new Error(`OpenAI embeddings failed: ${res.status} ${res.statusText} ${body}`);
210
+ }
211
+ const json = (await res.json());
212
+ const embedding = json?.data?.[0]?.embedding;
213
+ if (!Array.isArray(embedding))
214
+ throw new Error('OpenAI embeddings: missing data[0].embedding');
215
+ const vec = new Float32Array(embedding.length);
216
+ for (let i = 0; i < embedding.length; i++)
217
+ vec[i] = Number(embedding[i]);
218
+ return { vector: vec, dims: vec.length, model };
219
+ }
220
+ async function fetchEmbedding(cfg, input) {
221
+ const provider = cfg.provider ?? 'ollama';
222
+ if (provider === 'openai') {
223
+ return fetchEmbeddingOpenAI(cfg, input);
224
+ }
225
+ // Default: Ollama
226
+ return fetchEmbeddingOllama(cfg, input);
227
+ }
228
+ function vectorToBlob(vec) {
229
+ return Buffer.from(vec.buffer.slice(vec.byteOffset, vec.byteOffset + vec.byteLength));
230
+ }
231
+ function blobToVector(blob) {
232
+ return new Float32Array(blob.buffer, blob.byteOffset, Math.floor(blob.byteLength / 4));
233
+ }
234
+ async function getOrCreateItemEmbedding(db, cfg, itemId, text) {
235
+ const model = cfg.embeddingModel ?? 'bge-m3';
236
+ const row = db
237
+ .prepare('SELECT model, dims, vector FROM embeddings WHERE item_id = ? AND model = ?')
238
+ .get(itemId, model);
239
+ if (row?.vector) {
240
+ return { model: row.model, dims: Number(row.dims), vector: blobToVector(row.vector) };
241
+ }
242
+ // If Ollama is unavailable, degrade gracefully (no semantic score).
243
+ try {
244
+ const emb = await fetchEmbedding(cfg, text);
245
+ const blob = vectorToBlob(emb.vector);
246
+ db.prepare(`INSERT INTO embeddings (item_id, model, dims, vector, updated_at)
247
+ VALUES (?, ?, ?, ?, ?)
248
+ ON CONFLICT(item_id) DO UPDATE SET
249
+ model=excluded.model,
250
+ dims=excluded.dims,
251
+ vector=excluded.vector,
252
+ updated_at=excluded.updated_at`).run(itemId, emb.model, emb.dims, blob, Date.now());
253
+ return emb;
254
+ }
255
+ catch {
256
+ return null;
257
+ }
258
+ }
259
+ export async function hybridSearch(db, cfg, query, opts) {
260
+ const topK = opts?.topK ?? 10;
261
+ const candidates = opts?.candidates ?? Math.max(50, topK);
262
+ const w = opts?.semanticWeight ?? 0.7;
263
+ // Candidates: lexical hits + recents (merged).
264
+ const lexHits = lexicalSearch(db, query, candidates);
265
+ const recentRows = db
266
+ .prepare(`SELECT id, created_at, source, source_id, title, text, tags, meta
267
+ FROM items
268
+ ORDER BY created_at DESC
269
+ LIMIT ?`)
270
+ .all(candidates);
271
+ const recent = recentRows.map((r) => ({
272
+ item: {
273
+ id: r.id,
274
+ created_at: r.created_at,
275
+ source: r.source,
276
+ source_id: r.source_id,
277
+ title: r.title,
278
+ text: r.text,
279
+ tags: r.tags,
280
+ meta: r.meta,
281
+ },
282
+ lexicalScore: 0,
283
+ }));
284
+ const merged = [];
285
+ const seen = new Set();
286
+ for (const r of [...lexHits, ...recent]) {
287
+ if (seen.has(r.item.id))
288
+ continue;
289
+ seen.add(r.item.id);
290
+ merged.push(r);
291
+ }
292
+ let lex = merged;
293
+ if (lex.length === 0)
294
+ return [];
295
+ let queryEmb = null;
296
+ try {
297
+ queryEmb = await fetchEmbedding(cfg, query);
298
+ }
299
+ catch {
300
+ // Ollama unreachable => lexical-only results.
301
+ return lex.slice(0, topK).map((r) => ({ ...r, semanticScore: null, score: r.lexicalScore }));
302
+ }
303
+ const lexScores = lex.map((r) => r.lexicalScore);
304
+ const minLex = Math.min(...lexScores);
305
+ const maxLex = Math.max(...lexScores);
306
+ const denomLex = maxLex - minLex || 1;
307
+ const out = [];
308
+ for (const r of lex) {
309
+ const itemEmb = await getOrCreateItemEmbedding(db, cfg, r.item.id, r.item.text);
310
+ const sem = itemEmb ? cosine(queryEmb.vector, itemEmb.vector) : null;
311
+ const lexNorm = (r.lexicalScore - minLex) / denomLex; // 0..1
312
+ const semNorm = sem === null ? 0 : (sem + 1) / 2; // -1..1 => 0..1
313
+ const score = (1 - w) * lexNorm + w * semNorm;
314
+ out.push({ ...r, semanticScore: sem, score });
315
+ }
316
+ out.sort((a, b) => b.score - a.score);
317
+ return out.slice(0, topK);
318
+ }
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@akashabot/openclaw-memory-offline-core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "files": ["dist"],
8
+ "dependencies": {
9
+ "better-sqlite3": "^11.8.0",
10
+ "node-fetch": "^3.3.2"
11
+ },
12
+ "devDependencies": {
13
+ "@types/better-sqlite3": "^7.6.11"
14
+ }
15
+ }