@awareness-sdk/local 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.
- package/bin/awareness-local.mjs +489 -0
- package/package.json +31 -0
- package/src/api.mjs +122 -0
- package/src/core/cloud-sync.mjs +970 -0
- package/src/core/config.mjs +303 -0
- package/src/core/embedder.mjs +239 -0
- package/src/core/index.mjs +34 -0
- package/src/core/indexer.mjs +726 -0
- package/src/core/knowledge-extractor.mjs +629 -0
- package/src/core/memory-store.mjs +665 -0
- package/src/core/search.mjs +633 -0
- package/src/daemon.mjs +1720 -0
- package/src/mcp-server.mjs +335 -0
- package/src/spec/awareness-spec.json +393 -0
- package/src/web/index.html +1015 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Indexer — SQLite FTS5 full-text search index for Awareness Local.
|
|
3
|
+
*
|
|
4
|
+
* Uses better-sqlite3 in WAL mode for concurrent read access.
|
|
5
|
+
* Manages 7 tables + 2 FTS5 virtual tables + 1 embeddings table.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import Database from 'better-sqlite3';
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Schema DDL
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
const SCHEMA_SQL = `
|
|
16
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
17
|
+
id TEXT PRIMARY KEY,
|
|
18
|
+
filepath TEXT NOT NULL UNIQUE,
|
|
19
|
+
type TEXT NOT NULL,
|
|
20
|
+
title TEXT,
|
|
21
|
+
session_id TEXT,
|
|
22
|
+
agent_role TEXT DEFAULT 'builder_agent',
|
|
23
|
+
source TEXT,
|
|
24
|
+
status TEXT DEFAULT 'active',
|
|
25
|
+
tags TEXT,
|
|
26
|
+
created_at TEXT NOT NULL,
|
|
27
|
+
updated_at TEXT NOT NULL,
|
|
28
|
+
content_hash TEXT,
|
|
29
|
+
synced_to_cloud INTEGER DEFAULT 0
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
|
33
|
+
id UNINDEXED, title, content, tags,
|
|
34
|
+
tokenize='unicode61 remove_diacritics 2'
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS knowledge_cards (
|
|
38
|
+
id TEXT PRIMARY KEY,
|
|
39
|
+
category TEXT NOT NULL,
|
|
40
|
+
title TEXT NOT NULL,
|
|
41
|
+
summary TEXT,
|
|
42
|
+
source_memories TEXT,
|
|
43
|
+
confidence REAL DEFAULT 0.8,
|
|
44
|
+
status TEXT DEFAULT 'active',
|
|
45
|
+
tags TEXT,
|
|
46
|
+
created_at TEXT NOT NULL,
|
|
47
|
+
filepath TEXT NOT NULL UNIQUE
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_fts USING fts5(
|
|
51
|
+
id UNINDEXED, title, summary, content, tags,
|
|
52
|
+
tokenize='unicode61 remove_diacritics 2'
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
56
|
+
id TEXT PRIMARY KEY,
|
|
57
|
+
title TEXT NOT NULL,
|
|
58
|
+
description TEXT,
|
|
59
|
+
status TEXT DEFAULT 'open',
|
|
60
|
+
priority TEXT DEFAULT 'medium',
|
|
61
|
+
agent_role TEXT,
|
|
62
|
+
created_at TEXT NOT NULL,
|
|
63
|
+
updated_at TEXT NOT NULL,
|
|
64
|
+
filepath TEXT NOT NULL UNIQUE
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
68
|
+
id TEXT PRIMARY KEY,
|
|
69
|
+
source TEXT,
|
|
70
|
+
agent_role TEXT,
|
|
71
|
+
started_at TEXT NOT NULL,
|
|
72
|
+
ended_at TEXT,
|
|
73
|
+
memory_count INTEGER DEFAULT 0,
|
|
74
|
+
summary TEXT
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
CREATE TABLE IF NOT EXISTS embeddings (
|
|
78
|
+
memory_id TEXT PRIMARY KEY,
|
|
79
|
+
vector BLOB NOT NULL,
|
|
80
|
+
model_id TEXT NOT NULL,
|
|
81
|
+
created_at TEXT NOT NULL,
|
|
82
|
+
FOREIGN KEY (memory_id) REFERENCES memories(id)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
CREATE TABLE IF NOT EXISTS sync_state (
|
|
86
|
+
key TEXT PRIMARY KEY,
|
|
87
|
+
value TEXT NOT NULL,
|
|
88
|
+
updated_at TEXT NOT NULL
|
|
89
|
+
);
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Helpers
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Compute SHA-256 hex digest of a string.
|
|
98
|
+
*/
|
|
99
|
+
function sha256(text) {
|
|
100
|
+
return createHash('sha256').update(text, 'utf8').digest('hex');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Sanitise a query string for FTS5 MATCH — escape double-quotes and wrap
|
|
105
|
+
* each token in double-quotes so special characters don't break the query.
|
|
106
|
+
* Falls back to a simple prefix search when the input is a single token.
|
|
107
|
+
*/
|
|
108
|
+
/** FTS5 boolean operators — pass through without quoting. */
|
|
109
|
+
const FTS5_OPS = new Set(['OR', 'AND', 'NOT', 'NEAR']);
|
|
110
|
+
|
|
111
|
+
function sanitiseFtsQuery(raw) {
|
|
112
|
+
if (!raw || typeof raw !== 'string') return null;
|
|
113
|
+
const trimmed = raw.trim();
|
|
114
|
+
if (trimmed.length === 0) return null;
|
|
115
|
+
|
|
116
|
+
// If the query already contains FTS5 operators or quoted phrases, pass it
|
|
117
|
+
// through with minimal sanitisation (just remove dangerous chars).
|
|
118
|
+
if (/\bOR\b|\bAND\b|\bNOT\b|\bNEAR\b/.test(trimmed) || trimmed.includes('"')) {
|
|
119
|
+
// Already structured — strip only chars that would break FTS5 syntax
|
|
120
|
+
return trimmed.replace(/[;\\]/g, '');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Plain text query — quote each token to prevent FTS5 syntax errors.
|
|
124
|
+
const tokens = trimmed.split(/\s+/).map((t) => {
|
|
125
|
+
const escaped = t.replace(/"/g, '""');
|
|
126
|
+
return `"${escaped}"`;
|
|
127
|
+
});
|
|
128
|
+
return tokens.join(' ');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Current ISO-8601 timestamp.
|
|
133
|
+
*/
|
|
134
|
+
function nowISO() {
|
|
135
|
+
return new Date().toISOString();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Indexer class
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
export class Indexer {
|
|
143
|
+
/**
|
|
144
|
+
* @param {string} dbPath — path to the SQLite database file.
|
|
145
|
+
*/
|
|
146
|
+
constructor(dbPath) {
|
|
147
|
+
this.db = new Database(dbPath);
|
|
148
|
+
|
|
149
|
+
// WAL mode for concurrent reads from the daemon & file-watchers.
|
|
150
|
+
this.db.pragma('journal_mode = WAL');
|
|
151
|
+
// Reasonable busy timeout so concurrent writers wait instead of failing.
|
|
152
|
+
this.db.pragma('busy_timeout = 5000');
|
|
153
|
+
|
|
154
|
+
this.initSchema();
|
|
155
|
+
this._prepareStatements();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// -----------------------------------------------------------------------
|
|
159
|
+
// Schema initialisation
|
|
160
|
+
// -----------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Execute all CREATE TABLE / CREATE VIRTUAL TABLE statements.
|
|
164
|
+
* Safe to call repeatedly — every statement uses IF NOT EXISTS.
|
|
165
|
+
*/
|
|
166
|
+
initSchema() {
|
|
167
|
+
this.db.exec(SCHEMA_SQL);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// -----------------------------------------------------------------------
|
|
171
|
+
// Prepared-statement cache (lazy, created once)
|
|
172
|
+
// -----------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/** @private */
|
|
175
|
+
_prepareStatements() {
|
|
176
|
+
// -- memories upsert --------------------------------------------------
|
|
177
|
+
this._stmtUpsertMemory = this.db.prepare(`
|
|
178
|
+
INSERT INTO memories (id, filepath, type, title, session_id, agent_role,
|
|
179
|
+
source, status, tags, created_at, updated_at, content_hash, synced_to_cloud)
|
|
180
|
+
VALUES (@id, @filepath, @type, @title, @session_id, @agent_role,
|
|
181
|
+
@source, @status, @tags, @created_at, @updated_at, @content_hash, @synced_to_cloud)
|
|
182
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
183
|
+
filepath = excluded.filepath,
|
|
184
|
+
type = excluded.type,
|
|
185
|
+
title = excluded.title,
|
|
186
|
+
session_id = excluded.session_id,
|
|
187
|
+
agent_role = excluded.agent_role,
|
|
188
|
+
source = excluded.source,
|
|
189
|
+
status = excluded.status,
|
|
190
|
+
tags = excluded.tags,
|
|
191
|
+
updated_at = excluded.updated_at,
|
|
192
|
+
content_hash = excluded.content_hash,
|
|
193
|
+
synced_to_cloud = excluded.synced_to_cloud
|
|
194
|
+
`);
|
|
195
|
+
|
|
196
|
+
this._stmtGetMemoryHash = this.db.prepare(
|
|
197
|
+
`SELECT content_hash FROM memories WHERE id = ?`
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// -- memories_fts upsert (delete + insert, FTS5 has no ON CONFLICT) ---
|
|
201
|
+
this._stmtDeleteFts = this.db.prepare(
|
|
202
|
+
`DELETE FROM memories_fts WHERE id = ?`
|
|
203
|
+
);
|
|
204
|
+
this._stmtInsertFts = this.db.prepare(`
|
|
205
|
+
INSERT INTO memories_fts (id, title, content, tags)
|
|
206
|
+
VALUES (@id, @title, @content, @tags)
|
|
207
|
+
`);
|
|
208
|
+
|
|
209
|
+
// -- knowledge_cards --------------------------------------------------
|
|
210
|
+
this._stmtUpsertKnowledge = this.db.prepare(`
|
|
211
|
+
INSERT INTO knowledge_cards (id, category, title, summary, source_memories,
|
|
212
|
+
confidence, status, tags, created_at, filepath)
|
|
213
|
+
VALUES (@id, @category, @title, @summary, @source_memories,
|
|
214
|
+
@confidence, @status, @tags, @created_at, @filepath)
|
|
215
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
216
|
+
category = excluded.category,
|
|
217
|
+
title = excluded.title,
|
|
218
|
+
summary = excluded.summary,
|
|
219
|
+
source_memories = excluded.source_memories,
|
|
220
|
+
confidence = excluded.confidence,
|
|
221
|
+
status = excluded.status,
|
|
222
|
+
tags = excluded.tags,
|
|
223
|
+
filepath = excluded.filepath
|
|
224
|
+
`);
|
|
225
|
+
|
|
226
|
+
this._stmtDeleteKnowledgeFts = this.db.prepare(
|
|
227
|
+
`DELETE FROM knowledge_fts WHERE id = ?`
|
|
228
|
+
);
|
|
229
|
+
this._stmtInsertKnowledgeFts = this.db.prepare(`
|
|
230
|
+
INSERT INTO knowledge_fts (id, title, summary, content, tags)
|
|
231
|
+
VALUES (@id, @title, @summary, @content, @tags)
|
|
232
|
+
`);
|
|
233
|
+
|
|
234
|
+
// -- tasks ------------------------------------------------------------
|
|
235
|
+
this._stmtUpsertTask = this.db.prepare(`
|
|
236
|
+
INSERT INTO tasks (id, title, description, status, priority, agent_role,
|
|
237
|
+
created_at, updated_at, filepath)
|
|
238
|
+
VALUES (@id, @title, @description, @status, @priority, @agent_role,
|
|
239
|
+
@created_at, @updated_at, @filepath)
|
|
240
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
241
|
+
title = excluded.title,
|
|
242
|
+
description = excluded.description,
|
|
243
|
+
status = excluded.status,
|
|
244
|
+
priority = excluded.priority,
|
|
245
|
+
agent_role = excluded.agent_role,
|
|
246
|
+
updated_at = excluded.updated_at,
|
|
247
|
+
filepath = excluded.filepath
|
|
248
|
+
`);
|
|
249
|
+
|
|
250
|
+
// -- sessions ---------------------------------------------------------
|
|
251
|
+
this._stmtInsertSession = this.db.prepare(`
|
|
252
|
+
INSERT INTO sessions (id, source, agent_role, started_at)
|
|
253
|
+
VALUES (@id, @source, @agent_role, @started_at)
|
|
254
|
+
`);
|
|
255
|
+
|
|
256
|
+
this._stmtUpdateSession = this.db.prepare(`
|
|
257
|
+
UPDATE sessions
|
|
258
|
+
SET ended_at = COALESCE(@ended_at, ended_at),
|
|
259
|
+
memory_count = COALESCE(@memory_count, memory_count),
|
|
260
|
+
summary = COALESCE(@summary, summary)
|
|
261
|
+
WHERE id = @id
|
|
262
|
+
`);
|
|
263
|
+
|
|
264
|
+
// -- embeddings -------------------------------------------------------
|
|
265
|
+
this._stmtUpsertEmbedding = this.db.prepare(`
|
|
266
|
+
INSERT OR REPLACE INTO embeddings (memory_id, vector, model_id, created_at)
|
|
267
|
+
VALUES (@memory_id, @vector, @model_id, @created_at)
|
|
268
|
+
`);
|
|
269
|
+
|
|
270
|
+
this._stmtGetEmbedding = this.db.prepare(
|
|
271
|
+
`SELECT vector FROM embeddings WHERE memory_id = ?`
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
this._stmtGetAllEmbeddings = this.db.prepare(
|
|
275
|
+
`SELECT memory_id, vector FROM embeddings`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// -----------------------------------------------------------------------
|
|
280
|
+
// Memory indexing
|
|
281
|
+
// -----------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Upsert a memory record and its FTS5 entry.
|
|
285
|
+
*
|
|
286
|
+
* If the content_hash is unchanged, the write is skipped entirely (no-op).
|
|
287
|
+
*
|
|
288
|
+
* @param {string} id
|
|
289
|
+
* @param {Object} metadata — must include at least { filepath, type, created_at, updated_at }.
|
|
290
|
+
* @param {string} content — the full Markdown body (used for FTS indexing).
|
|
291
|
+
* @returns {{ indexed: boolean }} — true if the record was written.
|
|
292
|
+
*/
|
|
293
|
+
indexMemory(id, metadata, content) {
|
|
294
|
+
const contentHash = sha256(content);
|
|
295
|
+
|
|
296
|
+
// Fast path: skip if unchanged.
|
|
297
|
+
const existing = this._stmtGetMemoryHash.get(id);
|
|
298
|
+
if (existing && existing.content_hash === contentHash) {
|
|
299
|
+
return { indexed: false };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const now = nowISO();
|
|
303
|
+
const tags =
|
|
304
|
+
typeof metadata.tags === 'string'
|
|
305
|
+
? metadata.tags
|
|
306
|
+
: Array.isArray(metadata.tags)
|
|
307
|
+
? JSON.stringify(metadata.tags)
|
|
308
|
+
: null;
|
|
309
|
+
|
|
310
|
+
const row = {
|
|
311
|
+
id,
|
|
312
|
+
filepath: metadata.filepath,
|
|
313
|
+
type: metadata.type || 'turn_summary',
|
|
314
|
+
title: metadata.title || null,
|
|
315
|
+
session_id: metadata.session_id || null,
|
|
316
|
+
agent_role: metadata.agent_role || 'builder_agent',
|
|
317
|
+
source: metadata.source || null,
|
|
318
|
+
status: metadata.status || 'active',
|
|
319
|
+
tags,
|
|
320
|
+
created_at: metadata.created_at || now,
|
|
321
|
+
updated_at: metadata.updated_at || now,
|
|
322
|
+
content_hash: contentHash,
|
|
323
|
+
synced_to_cloud: metadata.synced_to_cloud ? 1 : 0,
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// Wrap in a transaction so the metadata + FTS rows are atomic.
|
|
327
|
+
const upsert = this.db.transaction(() => {
|
|
328
|
+
this._stmtUpsertMemory.run(row);
|
|
329
|
+
this._stmtDeleteFts.run(id);
|
|
330
|
+
this._stmtInsertFts.run({
|
|
331
|
+
id,
|
|
332
|
+
title: row.title || '',
|
|
333
|
+
content,
|
|
334
|
+
tags: tags || '',
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
upsert();
|
|
338
|
+
|
|
339
|
+
return { indexed: true };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// -----------------------------------------------------------------------
|
|
343
|
+
// Knowledge card indexing
|
|
344
|
+
// -----------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Insert or update a knowledge card and its FTS5 entry.
|
|
348
|
+
*
|
|
349
|
+
* @param {Object} card — { id, category, title, summary, source_memories,
|
|
350
|
+
* confidence, status, tags, created_at, filepath, content }
|
|
351
|
+
* `content` is used only for FTS indexing and is NOT stored in the
|
|
352
|
+
* knowledge_cards table (the full body lives in the Markdown file).
|
|
353
|
+
*/
|
|
354
|
+
indexKnowledgeCard(card) {
|
|
355
|
+
const tags =
|
|
356
|
+
typeof card.tags === 'string'
|
|
357
|
+
? card.tags
|
|
358
|
+
: Array.isArray(card.tags)
|
|
359
|
+
? JSON.stringify(card.tags)
|
|
360
|
+
: null;
|
|
361
|
+
|
|
362
|
+
const now = nowISO();
|
|
363
|
+
const row = {
|
|
364
|
+
id: card.id,
|
|
365
|
+
category: card.category,
|
|
366
|
+
title: card.title,
|
|
367
|
+
summary: card.summary || null,
|
|
368
|
+
source_memories:
|
|
369
|
+
typeof card.source_memories === 'string'
|
|
370
|
+
? card.source_memories
|
|
371
|
+
: Array.isArray(card.source_memories)
|
|
372
|
+
? JSON.stringify(card.source_memories)
|
|
373
|
+
: null,
|
|
374
|
+
confidence: card.confidence ?? 0.8,
|
|
375
|
+
status: card.status || 'active',
|
|
376
|
+
tags,
|
|
377
|
+
created_at: card.created_at || now,
|
|
378
|
+
filepath: card.filepath,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const upsert = this.db.transaction(() => {
|
|
382
|
+
this._stmtUpsertKnowledge.run(row);
|
|
383
|
+
this._stmtDeleteKnowledgeFts.run(card.id);
|
|
384
|
+
this._stmtInsertKnowledgeFts.run({
|
|
385
|
+
id: card.id,
|
|
386
|
+
title: card.title || '',
|
|
387
|
+
summary: card.summary || '',
|
|
388
|
+
content: card.content || '',
|
|
389
|
+
tags: tags || '',
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
upsert();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// -----------------------------------------------------------------------
|
|
396
|
+
// Task indexing
|
|
397
|
+
// -----------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Insert or update a task.
|
|
401
|
+
*
|
|
402
|
+
* @param {Object} task — { id, title, description, status, priority,
|
|
403
|
+
* agent_role, created_at, updated_at, filepath }
|
|
404
|
+
*/
|
|
405
|
+
indexTask(task) {
|
|
406
|
+
const now = nowISO();
|
|
407
|
+
this._stmtUpsertTask.run({
|
|
408
|
+
id: task.id,
|
|
409
|
+
title: task.title,
|
|
410
|
+
description: task.description || null,
|
|
411
|
+
status: task.status || 'open',
|
|
412
|
+
priority: task.priority || 'medium',
|
|
413
|
+
agent_role: task.agent_role || null,
|
|
414
|
+
created_at: task.created_at || now,
|
|
415
|
+
updated_at: task.updated_at || now,
|
|
416
|
+
filepath: task.filepath,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// -----------------------------------------------------------------------
|
|
421
|
+
// Sessions
|
|
422
|
+
// -----------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Create a new session.
|
|
426
|
+
*
|
|
427
|
+
* @param {string} source — e.g. 'claude-code', 'openclaw'
|
|
428
|
+
* @param {string} [agentRole='builder_agent']
|
|
429
|
+
* @returns {{ id: string, source: string, agent_role: string, started_at: string }}
|
|
430
|
+
*/
|
|
431
|
+
createSession(source, agentRole = 'builder_agent') {
|
|
432
|
+
const now = nowISO();
|
|
433
|
+
const id = `ses_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
434
|
+
const row = { id, source: source || null, agent_role: agentRole, started_at: now };
|
|
435
|
+
this._stmtInsertSession.run(row);
|
|
436
|
+
return { ...row };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Update an existing session (e.g. set ended_at, memory_count, summary).
|
|
441
|
+
*
|
|
442
|
+
* @param {string} id
|
|
443
|
+
* @param {Object} updates — any subset of { ended_at, memory_count, summary }.
|
|
444
|
+
*/
|
|
445
|
+
updateSession(id, updates = {}) {
|
|
446
|
+
this._stmtUpdateSession.run({
|
|
447
|
+
id,
|
|
448
|
+
ended_at: updates.ended_at ?? null,
|
|
449
|
+
memory_count: updates.memory_count ?? null,
|
|
450
|
+
summary: updates.summary ?? null,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// -----------------------------------------------------------------------
|
|
455
|
+
// FTS5 search
|
|
456
|
+
// -----------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Full-text search over indexed memories.
|
|
460
|
+
*
|
|
461
|
+
* @param {string} query — natural language or keyword query.
|
|
462
|
+
* @param {Object} [options]
|
|
463
|
+
* @param {string[]} [options.types] — filter by memory type.
|
|
464
|
+
* @param {string[]} [options.tags] — filter by tag (JSON array substring match).
|
|
465
|
+
* @param {number} [options.limit=10]
|
|
466
|
+
* @param {number} [options.offset=0]
|
|
467
|
+
* @returns {Array<Object>} — memory rows with an additional `rank` field (lower = more relevant).
|
|
468
|
+
*/
|
|
469
|
+
search(query, options = {}) {
|
|
470
|
+
const ftsQuery = sanitiseFtsQuery(query);
|
|
471
|
+
if (!ftsQuery) return [];
|
|
472
|
+
|
|
473
|
+
const limit = options.limit ?? 10;
|
|
474
|
+
const offset = options.offset ?? 0;
|
|
475
|
+
|
|
476
|
+
// Build dynamic WHERE clauses for optional filters.
|
|
477
|
+
const conditions = [`memories_fts MATCH ?`, `m.status = 'active'`];
|
|
478
|
+
const params = [ftsQuery];
|
|
479
|
+
|
|
480
|
+
if (Array.isArray(options.types) && options.types.length > 0) {
|
|
481
|
+
const placeholders = options.types.map(() => '?').join(', ');
|
|
482
|
+
conditions.push(`m.type IN (${placeholders})`);
|
|
483
|
+
params.push(...options.types);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (Array.isArray(options.tags) && options.tags.length > 0) {
|
|
487
|
+
// tags is stored as a JSON array string — use LIKE for substring match.
|
|
488
|
+
for (const tag of options.tags) {
|
|
489
|
+
conditions.push(`m.tags LIKE ?`);
|
|
490
|
+
params.push(`%${tag}%`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
params.push(limit, offset);
|
|
495
|
+
|
|
496
|
+
const sql = `
|
|
497
|
+
SELECT m.*, memories_fts.content AS fts_content, bm25(memories_fts) AS rank
|
|
498
|
+
FROM memories_fts
|
|
499
|
+
JOIN memories m ON m.id = memories_fts.id
|
|
500
|
+
WHERE ${conditions.join(' AND ')}
|
|
501
|
+
ORDER BY rank
|
|
502
|
+
LIMIT ? OFFSET ?
|
|
503
|
+
`;
|
|
504
|
+
|
|
505
|
+
return this.db.prepare(sql).all(...params);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Full-text search over knowledge cards.
|
|
510
|
+
*
|
|
511
|
+
* @param {string} query
|
|
512
|
+
* @param {Object} [options]
|
|
513
|
+
* @param {string[]} [options.categories]
|
|
514
|
+
* @param {number} [options.limit=10]
|
|
515
|
+
* @param {number} [options.offset=0]
|
|
516
|
+
* @returns {Array<Object>}
|
|
517
|
+
*/
|
|
518
|
+
searchKnowledge(query, options = {}) {
|
|
519
|
+
const ftsQuery = sanitiseFtsQuery(query);
|
|
520
|
+
if (!ftsQuery) return [];
|
|
521
|
+
|
|
522
|
+
const limit = options.limit ?? 10;
|
|
523
|
+
const offset = options.offset ?? 0;
|
|
524
|
+
|
|
525
|
+
const conditions = [`knowledge_fts MATCH ?`, `k.status = 'active'`];
|
|
526
|
+
const params = [ftsQuery];
|
|
527
|
+
|
|
528
|
+
if (Array.isArray(options.categories) && options.categories.length > 0) {
|
|
529
|
+
const placeholders = options.categories.map(() => '?').join(', ');
|
|
530
|
+
conditions.push(`k.category IN (${placeholders})`);
|
|
531
|
+
params.push(...options.categories);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
params.push(limit, offset);
|
|
535
|
+
|
|
536
|
+
const sql = `
|
|
537
|
+
SELECT k.*, bm25(knowledge_fts) AS rank
|
|
538
|
+
FROM knowledge_fts
|
|
539
|
+
JOIN knowledge_cards k ON k.id = knowledge_fts.id
|
|
540
|
+
WHERE ${conditions.join(' AND ')}
|
|
541
|
+
ORDER BY rank
|
|
542
|
+
LIMIT ? OFFSET ?
|
|
543
|
+
`;
|
|
544
|
+
|
|
545
|
+
return this.db.prepare(sql).all(...params);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// -----------------------------------------------------------------------
|
|
549
|
+
// Incremental indexing
|
|
550
|
+
// -----------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Scan the memory store for new or changed files and index them.
|
|
554
|
+
*
|
|
555
|
+
* @param {Object} memoryStore — a MemoryStore instance with list() and read() methods.
|
|
556
|
+
* @returns {Promise<{ indexed: number, skipped: number }>}
|
|
557
|
+
*/
|
|
558
|
+
async incrementalIndex(memoryStore) {
|
|
559
|
+
const files = await memoryStore.list();
|
|
560
|
+
let indexed = 0;
|
|
561
|
+
let skipped = 0;
|
|
562
|
+
|
|
563
|
+
for (const file of files) {
|
|
564
|
+
try {
|
|
565
|
+
const id = file.metadata?.id;
|
|
566
|
+
if (!id) {
|
|
567
|
+
skipped++;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
// Ensure filepath is in metadata (list() returns it at top level)
|
|
571
|
+
const meta = { ...file.metadata, filepath: file.filepath || file.metadata.filepath };
|
|
572
|
+
// Derive title from first sentence of content if not in metadata
|
|
573
|
+
if (!meta.title && file.content) {
|
|
574
|
+
const firstSentence = file.content.split(/[.\n!?。!?]/)[0].trim();
|
|
575
|
+
meta.title = firstSentence.length > 80
|
|
576
|
+
? firstSentence.substring(0, 77) + '...'
|
|
577
|
+
: firstSentence || null;
|
|
578
|
+
}
|
|
579
|
+
const result = this.indexMemory(id, meta, file.content);
|
|
580
|
+
if (result.indexed) {
|
|
581
|
+
indexed++;
|
|
582
|
+
} else {
|
|
583
|
+
skipped++;
|
|
584
|
+
}
|
|
585
|
+
} catch (err) {
|
|
586
|
+
console.error(`[indexer] failed to index ${file.metadata?.id}:`, err.message);
|
|
587
|
+
skipped++;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return { indexed, skipped };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// -----------------------------------------------------------------------
|
|
595
|
+
// Stats & convenience queries
|
|
596
|
+
// -----------------------------------------------------------------------
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Get aggregate counts for dashboard / healthz.
|
|
600
|
+
*/
|
|
601
|
+
getStats() {
|
|
602
|
+
return {
|
|
603
|
+
totalMemories: this.db
|
|
604
|
+
.prepare(`SELECT COUNT(*) AS c FROM memories WHERE status = ?`)
|
|
605
|
+
.get('active').c,
|
|
606
|
+
totalKnowledge: this.db
|
|
607
|
+
.prepare(`SELECT COUNT(*) AS c FROM knowledge_cards WHERE status = ?`)
|
|
608
|
+
.get('active').c,
|
|
609
|
+
totalTasks: this.db
|
|
610
|
+
.prepare(`SELECT COUNT(*) AS c FROM tasks WHERE status = ?`)
|
|
611
|
+
.get('open').c,
|
|
612
|
+
totalSessions: this.db
|
|
613
|
+
.prepare(`SELECT COUNT(*) AS c FROM sessions`)
|
|
614
|
+
.get().c,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Return the most recently created knowledge cards.
|
|
620
|
+
*
|
|
621
|
+
* @param {number} [limit=5]
|
|
622
|
+
*/
|
|
623
|
+
getRecentKnowledge(limit = 5) {
|
|
624
|
+
return this.db
|
|
625
|
+
.prepare(
|
|
626
|
+
`SELECT * FROM knowledge_cards WHERE status = 'active'
|
|
627
|
+
ORDER BY created_at DESC LIMIT ?`
|
|
628
|
+
)
|
|
629
|
+
.all(limit);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Return open (un-completed) tasks.
|
|
634
|
+
*
|
|
635
|
+
* @param {number} [limit=5]
|
|
636
|
+
*/
|
|
637
|
+
getOpenTasks(limit = 5) {
|
|
638
|
+
return this.db
|
|
639
|
+
.prepare(
|
|
640
|
+
`SELECT * FROM tasks WHERE status = 'open'
|
|
641
|
+
ORDER BY created_at DESC LIMIT ?`
|
|
642
|
+
)
|
|
643
|
+
.all(limit);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Return sessions started within the last N days.
|
|
648
|
+
*
|
|
649
|
+
* @param {number} [days=7]
|
|
650
|
+
*/
|
|
651
|
+
getRecentSessions(days = 7) {
|
|
652
|
+
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
|
|
653
|
+
return this.db
|
|
654
|
+
.prepare(`SELECT * FROM sessions WHERE started_at > ? ORDER BY started_at DESC`)
|
|
655
|
+
.all(cutoff);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// -----------------------------------------------------------------------
|
|
659
|
+
// Embedding storage (SQLite BLOB)
|
|
660
|
+
// -----------------------------------------------------------------------
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Store (or replace) an embedding vector for a memory.
|
|
664
|
+
*
|
|
665
|
+
* @param {string} memoryId
|
|
666
|
+
* @param {Float32Array} vector — 384-dimensional embedding.
|
|
667
|
+
* @param {string} modelId — e.g. 'all-MiniLM-L6-v2' or 'multilingual-e5-small'.
|
|
668
|
+
*/
|
|
669
|
+
storeEmbedding(memoryId, vector, modelId) {
|
|
670
|
+
const buf = Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength);
|
|
671
|
+
this._stmtUpsertEmbedding.run({
|
|
672
|
+
memory_id: memoryId,
|
|
673
|
+
vector: buf,
|
|
674
|
+
model_id: modelId,
|
|
675
|
+
created_at: nowISO(),
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Retrieve the embedding vector for a single memory.
|
|
681
|
+
*
|
|
682
|
+
* @param {string} memoryId
|
|
683
|
+
* @returns {Float32Array|null}
|
|
684
|
+
*/
|
|
685
|
+
getEmbedding(memoryId) {
|
|
686
|
+
const row = this._stmtGetEmbedding.get(memoryId);
|
|
687
|
+
if (!row) return null;
|
|
688
|
+
return new Float32Array(
|
|
689
|
+
row.vector.buffer,
|
|
690
|
+
row.vector.byteOffset,
|
|
691
|
+
row.vector.byteLength / Float32Array.BYTES_PER_ELEMENT
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Retrieve all embeddings (for brute-force cosine search).
|
|
697
|
+
*
|
|
698
|
+
* @returns {Array<{ memory_id: string, vector: Float32Array }>}
|
|
699
|
+
*/
|
|
700
|
+
getAllEmbeddings() {
|
|
701
|
+
const rows = this._stmtGetAllEmbeddings.all();
|
|
702
|
+
return rows.map((row) => ({
|
|
703
|
+
memory_id: row.memory_id,
|
|
704
|
+
vector: new Float32Array(
|
|
705
|
+
row.vector.buffer,
|
|
706
|
+
row.vector.byteOffset,
|
|
707
|
+
row.vector.byteLength / Float32Array.BYTES_PER_ELEMENT
|
|
708
|
+
),
|
|
709
|
+
}));
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// -----------------------------------------------------------------------
|
|
713
|
+
// Lifecycle
|
|
714
|
+
// -----------------------------------------------------------------------
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Close the database connection. Safe to call multiple times.
|
|
718
|
+
*/
|
|
719
|
+
close() {
|
|
720
|
+
if (this.db && this.db.open) {
|
|
721
|
+
this.db.close();
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
export default Indexer;
|