ctxman 3.1.1 → 3.2.8
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/.ctxman/llm-profiles.json +6 -6
- package/README.md +16 -10
- package/bin/cli.js +109 -10
- package/bin/cm-ask.js +64 -6
- package/bin/cm-audit.js +76 -0
- package/bin/cm-count.js +45 -6
- package/bin/cm-diagnose.js +199 -0
- package/bin/cm-focus.js +322 -0
- package/bin/cm-map.js +71 -18
- package/bin/cm-pack.js +36 -5
- package/bin/cm-policy.js +107 -2
- package/bin/cm-pr.js +18 -3
- package/bin/cm-search.js +84 -19
- package/bin/cm-task.js +192 -0
- package/bin/cm-who.js +119 -0
- package/bin/cm-why.js +41 -5
- package/index.d.ts +940 -1
- package/index.js +105 -1
- package/lib/analyzers/token-calculator.js +76 -4
- package/lib/api/mcp/server.js +843 -29
- package/lib/cache/CacheManager.js +161 -47
- package/lib/core/Analyzer.js +61 -5
- package/lib/count/index.js +74 -14
- package/lib/diagnose/index.js +712 -0
- package/lib/focus/index.js +790 -0
- package/lib/map/call-graph.js +570 -0
- package/lib/map/graph.js +30 -5
- package/lib/map/index.js +12 -4
- package/lib/map/rank.js +45 -7
- package/lib/map/render.js +89 -28
- package/lib/pack/index.js +20 -1
- package/lib/pack/select.js +8 -1
- package/lib/policy/audit.js +3 -1
- package/lib/policy/index.js +5 -0
- package/lib/policy/workspace.js +254 -0
- package/lib/pr/pr-context.js +292 -31
- package/lib/pr/pr-renderer.js +25 -10
- package/lib/rag/EmbeddingProvider.js +139 -2
- package/lib/rag/VectorStoreFactory.js +10 -4
- package/lib/rag/ask.js +186 -17
- package/lib/rag/stores/SqliteVectorStore.js +595 -0
- package/lib/search/hybrid.js +324 -0
- package/lib/search/symbols.js +75 -11
- package/lib/task/index.js +630 -0
- package/lib/utils/file-utils.js +1 -0
- package/lib/utils/multi-repo-manager.js +4 -1
- package/lib/watch/FileWatcher.js +121 -31
- package/lib/watch/IncrementalAnalyzer.js +964 -16
- package/lib/who/index.js +893 -0
- package/lib/why/analyzer-bridge.js +223 -0
- package/lib/why/discussions.js +314 -0
- package/lib/why/index.js +356 -30
- package/lib/why/render.js +154 -9
- package/lib/workspace/index.js +1274 -0
- package/package.json +1 -1
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { createRequire } from 'module';
|
|
5
|
+
import { BaseVectorStore } from './BaseVectorStore.js';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
let cachedDatabaseSync = null;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Lazily loads `DatabaseSync` from `node:sqlite` while suppressing Node's
|
|
12
|
+
* one-time `ExperimentalWarning: SQLite is an experimental feature` on stderr.
|
|
13
|
+
*/
|
|
14
|
+
function getDatabaseSync() {
|
|
15
|
+
if (cachedDatabaseSync) return cachedDatabaseSync;
|
|
16
|
+
const origEmitWarning = process.emitWarning;
|
|
17
|
+
process.emitWarning = function (warning, ...args) {
|
|
18
|
+
const msg = typeof warning === 'string' ? warning : warning?.message || '';
|
|
19
|
+
if (/SQLite is an experimental feature/i.test(msg)) return;
|
|
20
|
+
return origEmitWarning.call(this, warning, ...args);
|
|
21
|
+
};
|
|
22
|
+
try {
|
|
23
|
+
({ DatabaseSync: cachedDatabaseSync } = require('node:sqlite'));
|
|
24
|
+
return cachedDatabaseSync;
|
|
25
|
+
} finally {
|
|
26
|
+
process.emitWarning = origEmitWarning;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const toPosix = (p) =>
|
|
31
|
+
String(p ?? '')
|
|
32
|
+
.split(path.sep)
|
|
33
|
+
.join('/');
|
|
34
|
+
|
|
35
|
+
/** Byte-order comparison, identical across platforms and locales */
|
|
36
|
+
function comparePaths(a, b) {
|
|
37
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Zero-native-binary vector & knowledge-graph store using Node 22+ built-in node:sqlite (`DatabaseSync`).
|
|
42
|
+
* Stores document chunks, content SHA-256 hashes, metadata, float32 embedding vectors,
|
|
43
|
+
* and incremental repository knowledge-graph nodes & edges in `<storePath>/index.sqlite`.
|
|
44
|
+
* Supports incremental upserts (skipping unchanged content), document removal,
|
|
45
|
+
* graph delta synchronization, and exact cosine similarity vector search.
|
|
46
|
+
*/
|
|
47
|
+
export class SqliteVectorStore extends BaseVectorStore {
|
|
48
|
+
/**
|
|
49
|
+
* @param {import('../EmbeddingProvider').EmbeddingProvider} embeddingProvider
|
|
50
|
+
* @param {string} [storagePath='.ctxman/self-knowledge'] - Directory or .sqlite file path
|
|
51
|
+
*/
|
|
52
|
+
constructor(embeddingProvider, storagePath = '.ctxman/self-knowledge') {
|
|
53
|
+
super(embeddingProvider);
|
|
54
|
+
this.storagePath = storagePath;
|
|
55
|
+
this.dbPath = storagePath.endsWith('.sqlite')
|
|
56
|
+
? storagePath
|
|
57
|
+
: path.join(storagePath, 'index.sqlite');
|
|
58
|
+
this.db = null;
|
|
59
|
+
this.initialized = false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Initialize directory, SQLite database, documents table, and knowledge-graph tables.
|
|
64
|
+
*/
|
|
65
|
+
async init() {
|
|
66
|
+
if (this.initialized) return;
|
|
67
|
+
|
|
68
|
+
const dir = path.dirname(this.dbPath);
|
|
69
|
+
if (!fs.existsSync(dir)) {
|
|
70
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const DatabaseSync = getDatabaseSync();
|
|
74
|
+
this.db = new DatabaseSync(this.dbPath);
|
|
75
|
+
|
|
76
|
+
// Create tables for documents (vectors) and knowledge graph (nodes + edges)
|
|
77
|
+
this.db.exec(`
|
|
78
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
79
|
+
id TEXT PRIMARY KEY,
|
|
80
|
+
file_path TEXT,
|
|
81
|
+
content_hash TEXT NOT NULL,
|
|
82
|
+
text TEXT NOT NULL,
|
|
83
|
+
metadata TEXT,
|
|
84
|
+
vector BLOB,
|
|
85
|
+
updated_at INTEGER
|
|
86
|
+
);
|
|
87
|
+
CREATE INDEX IF NOT EXISTS idx_documents_file_path ON documents(file_path);
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_documents_content_hash ON documents(content_hash);
|
|
89
|
+
|
|
90
|
+
CREATE TABLE IF NOT EXISTS graph_nodes (
|
|
91
|
+
path TEXT PRIMARY KEY,
|
|
92
|
+
language TEXT NOT NULL,
|
|
93
|
+
bytes INTEGER NOT NULL DEFAULT 0,
|
|
94
|
+
content_hash TEXT,
|
|
95
|
+
methods TEXT NOT NULL DEFAULT '[]',
|
|
96
|
+
declarations TEXT NOT NULL DEFAULT '[]',
|
|
97
|
+
imports TEXT NOT NULL DEFAULT '[]',
|
|
98
|
+
symbol_refs REAL NOT NULL DEFAULT 0,
|
|
99
|
+
unresolved_imports INTEGER NOT NULL DEFAULT 0,
|
|
100
|
+
updated_at INTEGER
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
CREATE TABLE IF NOT EXISTS graph_edges (
|
|
104
|
+
from_path TEXT NOT NULL,
|
|
105
|
+
to_path TEXT NOT NULL,
|
|
106
|
+
weight REAL NOT NULL DEFAULT 1,
|
|
107
|
+
updated_at INTEGER,
|
|
108
|
+
PRIMARY KEY (from_path, to_path)
|
|
109
|
+
);
|
|
110
|
+
CREATE INDEX IF NOT EXISTS idx_graph_edges_from ON graph_edges(from_path);
|
|
111
|
+
CREATE INDEX IF NOT EXISTS idx_graph_edges_to ON graph_edges(to_path);
|
|
112
|
+
`);
|
|
113
|
+
|
|
114
|
+
this.stmtGetById = this.db.prepare('SELECT id, content_hash FROM documents WHERE id = ?');
|
|
115
|
+
this.stmtGetByPath = this.db.prepare(
|
|
116
|
+
'SELECT id, content_hash FROM documents WHERE file_path = ?'
|
|
117
|
+
);
|
|
118
|
+
this.stmtUpsert = this.db.prepare(`
|
|
119
|
+
INSERT INTO documents (id, file_path, content_hash, text, metadata, vector, updated_at)
|
|
120
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
121
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
122
|
+
file_path = excluded.file_path,
|
|
123
|
+
content_hash = excluded.content_hash,
|
|
124
|
+
text = excluded.text,
|
|
125
|
+
metadata = excluded.metadata,
|
|
126
|
+
vector = excluded.vector,
|
|
127
|
+
updated_at = excluded.updated_at
|
|
128
|
+
`);
|
|
129
|
+
this.stmtDeleteById = this.db.prepare('DELETE FROM documents WHERE id = ?');
|
|
130
|
+
this.stmtAllMeta = this.db.prepare('SELECT id, file_path, metadata FROM documents');
|
|
131
|
+
this.stmtAllVectors = this.db.prepare(
|
|
132
|
+
'SELECT id, file_path, text, metadata, vector FROM documents'
|
|
133
|
+
);
|
|
134
|
+
this.stmtCount = this.db.prepare('SELECT COUNT(*) AS count FROM documents');
|
|
135
|
+
|
|
136
|
+
// Graph prepared statements
|
|
137
|
+
this.stmtUpsertNode = this.db.prepare(`
|
|
138
|
+
INSERT INTO graph_nodes (
|
|
139
|
+
path, language, bytes, content_hash, methods, declarations, imports,
|
|
140
|
+
symbol_refs, unresolved_imports, updated_at
|
|
141
|
+
)
|
|
142
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
143
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
144
|
+
language = excluded.language,
|
|
145
|
+
bytes = excluded.bytes,
|
|
146
|
+
content_hash = excluded.content_hash,
|
|
147
|
+
methods = excluded.methods,
|
|
148
|
+
declarations = excluded.declarations,
|
|
149
|
+
imports = excluded.imports,
|
|
150
|
+
symbol_refs = excluded.symbol_refs,
|
|
151
|
+
unresolved_imports = excluded.unresolved_imports,
|
|
152
|
+
updated_at = excluded.updated_at
|
|
153
|
+
`);
|
|
154
|
+
this.stmtDeleteNode = this.db.prepare('DELETE FROM graph_nodes WHERE path = ?');
|
|
155
|
+
this.stmtDeleteOutEdges = this.db.prepare('DELETE FROM graph_edges WHERE from_path = ?');
|
|
156
|
+
this.stmtDeleteIncidentEdges = this.db.prepare(
|
|
157
|
+
'DELETE FROM graph_edges WHERE from_path = ? OR to_path = ?'
|
|
158
|
+
);
|
|
159
|
+
this.stmtUpsertEdge = this.db.prepare(`
|
|
160
|
+
INSERT INTO graph_edges (from_path, to_path, weight, updated_at)
|
|
161
|
+
VALUES (?, ?, ?, ?)
|
|
162
|
+
ON CONFLICT(from_path, to_path) DO UPDATE SET
|
|
163
|
+
weight = excluded.weight,
|
|
164
|
+
updated_at = excluded.updated_at
|
|
165
|
+
`);
|
|
166
|
+
this.stmtAllNodes = this.db.prepare(`
|
|
167
|
+
SELECT path, language, bytes, content_hash, methods, declarations, imports,
|
|
168
|
+
symbol_refs, unresolved_imports, updated_at
|
|
169
|
+
FROM graph_nodes
|
|
170
|
+
ORDER BY path ASC
|
|
171
|
+
`);
|
|
172
|
+
this.stmtAllEdges = this.db.prepare(`
|
|
173
|
+
SELECT from_path, to_path, weight
|
|
174
|
+
FROM graph_edges
|
|
175
|
+
ORDER BY from_path ASC, to_path ASC
|
|
176
|
+
`);
|
|
177
|
+
this.stmtNodeCount = this.db.prepare('SELECT COUNT(*) AS count FROM graph_nodes');
|
|
178
|
+
this.stmtEdgeCount = this.db.prepare('SELECT COUNT(*) AS count FROM graph_edges');
|
|
179
|
+
|
|
180
|
+
this.initialized = true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Add a single document to the store.
|
|
185
|
+
* @param {string} text
|
|
186
|
+
* @param {Object} [metadata={}]
|
|
187
|
+
* @returns {Promise<{upserted: number, skipped: number}>}
|
|
188
|
+
*/
|
|
189
|
+
async addDocument(text, metadata = {}) {
|
|
190
|
+
return this.addDocuments([{ text, metadata }]);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Incremental upsert of documents.
|
|
195
|
+
* Skips re-embedding and rewriting if content SHA-256 is unchanged.
|
|
196
|
+
* @param {Array<{text: string, id?: string, metadata?: Object}>} docs
|
|
197
|
+
* @returns {Promise<{upserted: number, skipped: number}>}
|
|
198
|
+
*/
|
|
199
|
+
async addDocuments(docs) {
|
|
200
|
+
if (!docs || docs.length === 0) return { upserted: 0, skipped: 0 };
|
|
201
|
+
if (!this.initialized) await this.init();
|
|
202
|
+
|
|
203
|
+
let upserted = 0;
|
|
204
|
+
let skipped = 0;
|
|
205
|
+
|
|
206
|
+
for (const doc of docs) {
|
|
207
|
+
const text = String(doc.text ?? '');
|
|
208
|
+
const contentHash = crypto.createHash('sha256').update(text).digest('hex');
|
|
209
|
+
|
|
210
|
+
const meta = doc.metadata ?? {};
|
|
211
|
+
const filePath =
|
|
212
|
+
meta.path ?? (typeof doc.id === 'string' && !doc.id.includes(':') ? doc.id : null);
|
|
213
|
+
const id = doc.id ?? meta.sourceId ?? (filePath ? filePath : crypto.randomUUID());
|
|
214
|
+
|
|
215
|
+
// Check if unchanged by id or filePath
|
|
216
|
+
const existing =
|
|
217
|
+
this.stmtGetById.get(id) ?? (filePath ? this.stmtGetByPath.get(filePath) : null);
|
|
218
|
+
if (existing && existing.content_hash === contentHash) {
|
|
219
|
+
// Content hash matches; skip re-embedding
|
|
220
|
+
skipped++;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const targetId = existing ? existing.id : id;
|
|
225
|
+
|
|
226
|
+
// Compute vector embedding
|
|
227
|
+
const vector = await this.embeddingProvider.embed(text);
|
|
228
|
+
const float32 = new Float32Array(vector);
|
|
229
|
+
const blob = Buffer.from(float32.buffer, float32.byteOffset, float32.byteLength);
|
|
230
|
+
|
|
231
|
+
this.stmtUpsert.run(
|
|
232
|
+
targetId,
|
|
233
|
+
filePath,
|
|
234
|
+
contentHash,
|
|
235
|
+
text,
|
|
236
|
+
JSON.stringify(meta),
|
|
237
|
+
blob,
|
|
238
|
+
Date.now()
|
|
239
|
+
);
|
|
240
|
+
upserted++;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { upserted, skipped };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Remove document(s) matching a document ID, relative path, or absolute file path.
|
|
248
|
+
* @param {string} idOrFilePath
|
|
249
|
+
* @returns {Promise<number>} Number of removed documents
|
|
250
|
+
*/
|
|
251
|
+
async removeDocument(idOrFilePath) {
|
|
252
|
+
if (!idOrFilePath) return 0;
|
|
253
|
+
if (!this.initialized) await this.init();
|
|
254
|
+
|
|
255
|
+
const target = String(idOrFilePath);
|
|
256
|
+
const posixTarget = toPosix(target);
|
|
257
|
+
const suffixTarget = posixTarget.startsWith('/') ? posixTarget : `/${posixTarget}`;
|
|
258
|
+
|
|
259
|
+
const rows = this.stmtAllMeta.all();
|
|
260
|
+
const idsToDelete = new Set();
|
|
261
|
+
|
|
262
|
+
for (const row of rows) {
|
|
263
|
+
if (row.id === target || row.id === posixTarget) {
|
|
264
|
+
idsToDelete.add(row.id);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (row.file_path) {
|
|
268
|
+
const posixFilePath = toPosix(row.file_path);
|
|
269
|
+
if (
|
|
270
|
+
row.file_path === target ||
|
|
271
|
+
posixFilePath === posixTarget ||
|
|
272
|
+
posixFilePath.endsWith(suffixTarget)
|
|
273
|
+
) {
|
|
274
|
+
idsToDelete.add(row.id);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (row.metadata) {
|
|
279
|
+
try {
|
|
280
|
+
const meta = JSON.parse(row.metadata);
|
|
281
|
+
const metaPath = meta.path ? toPosix(meta.path) : null;
|
|
282
|
+
const metaRel = meta.relativePath ? toPosix(meta.relativePath) : null;
|
|
283
|
+
if (
|
|
284
|
+
metaPath === posixTarget ||
|
|
285
|
+
(metaPath && metaPath.endsWith(suffixTarget)) ||
|
|
286
|
+
metaRel === posixTarget
|
|
287
|
+
) {
|
|
288
|
+
idsToDelete.add(row.id);
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
// ignore malformed metadata
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let removed = 0;
|
|
297
|
+
for (const id of idsToDelete) {
|
|
298
|
+
const res = this.stmtDeleteById.run(id);
|
|
299
|
+
removed += Number(res?.changes ?? 1);
|
|
300
|
+
}
|
|
301
|
+
return removed;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Upsert a single repository knowledge-graph node.
|
|
306
|
+
* @param {Object} node
|
|
307
|
+
*/
|
|
308
|
+
async upsertGraphNode(node) {
|
|
309
|
+
if (!node || !node.path) return;
|
|
310
|
+
if (!this.initialized) await this.init();
|
|
311
|
+
|
|
312
|
+
const relPath = toPosix(node.path);
|
|
313
|
+
this.stmtUpsertNode.run(
|
|
314
|
+
relPath,
|
|
315
|
+
String(node.language ?? 'unknown'),
|
|
316
|
+
Number(node.bytes ?? 0),
|
|
317
|
+
node.contentHash ?? null,
|
|
318
|
+
JSON.stringify(node.methods ?? []),
|
|
319
|
+
JSON.stringify(node.declarations ?? []),
|
|
320
|
+
JSON.stringify(node.imports ?? []),
|
|
321
|
+
Number(node.symbolRefs ?? 0),
|
|
322
|
+
Number(node.unresolvedImports ?? 0),
|
|
323
|
+
Date.now()
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Replace all outgoing import edges for a single file node.
|
|
329
|
+
* @param {string} fromPath
|
|
330
|
+
* @param {Array<{to: string, weight?: number}>} edges
|
|
331
|
+
*/
|
|
332
|
+
async replaceGraphEdges(fromPath, edges = []) {
|
|
333
|
+
if (!fromPath) return;
|
|
334
|
+
if (!this.initialized) await this.init();
|
|
335
|
+
|
|
336
|
+
const posixFrom = toPosix(fromPath);
|
|
337
|
+
const now = Date.now();
|
|
338
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
339
|
+
try {
|
|
340
|
+
this.stmtDeleteOutEdges.run(posixFrom);
|
|
341
|
+
for (const edge of edges) {
|
|
342
|
+
if (!edge || !edge.to) continue;
|
|
343
|
+
this.stmtUpsertEdge.run(posixFrom, toPosix(edge.to), Number(edge.weight ?? 1), now);
|
|
344
|
+
}
|
|
345
|
+
this.db.exec('COMMIT');
|
|
346
|
+
} catch (err) {
|
|
347
|
+
this.db.exec('ROLLBACK');
|
|
348
|
+
throw err;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Remove a node and all incident (incoming + outgoing) edges from the knowledge graph.
|
|
354
|
+
* @param {string} filePath - Project-relative path
|
|
355
|
+
* @returns {Promise<{removedNode: number, removedEdges: number}>}
|
|
356
|
+
*/
|
|
357
|
+
async removeGraphNode(filePath) {
|
|
358
|
+
if (!filePath) return { removedNode: 0, removedEdges: 0 };
|
|
359
|
+
if (!this.initialized) await this.init();
|
|
360
|
+
|
|
361
|
+
const posixPath = toPosix(filePath);
|
|
362
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
363
|
+
try {
|
|
364
|
+
const edgeRes = this.stmtDeleteIncidentEdges.run(posixPath, posixPath);
|
|
365
|
+
const nodeRes = this.stmtDeleteNode.run(posixPath);
|
|
366
|
+
this.db.exec('COMMIT');
|
|
367
|
+
return {
|
|
368
|
+
removedNode: Number(nodeRes?.changes ?? 0),
|
|
369
|
+
removedEdges: Number(edgeRes?.changes ?? 0),
|
|
370
|
+
};
|
|
371
|
+
} catch (err) {
|
|
372
|
+
this.db.exec('ROLLBACK');
|
|
373
|
+
throw err;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Synchronize a full or incremental set of nodes and edges in a single SQLite transaction.
|
|
379
|
+
* @param {{nodes: Array<Object>, edges: Array<{from: string, to: string, weight?: number}>, replaceAll?: boolean}} graph
|
|
380
|
+
*/
|
|
381
|
+
async syncGraph(graph) {
|
|
382
|
+
if (!graph) return;
|
|
383
|
+
if (!this.initialized) await this.init();
|
|
384
|
+
|
|
385
|
+
const now = Date.now();
|
|
386
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
387
|
+
try {
|
|
388
|
+
if (graph.replaceAll) {
|
|
389
|
+
this.db.exec('DELETE FROM graph_nodes; DELETE FROM graph_edges;');
|
|
390
|
+
}
|
|
391
|
+
for (const node of graph.nodes ?? []) {
|
|
392
|
+
const relPath = toPosix(node.path);
|
|
393
|
+
this.stmtUpsertNode.run(
|
|
394
|
+
relPath,
|
|
395
|
+
String(node.language ?? 'unknown'),
|
|
396
|
+
Number(node.bytes ?? 0),
|
|
397
|
+
node.contentHash ?? null,
|
|
398
|
+
JSON.stringify(node.methods ?? []),
|
|
399
|
+
JSON.stringify(node.declarations ?? []),
|
|
400
|
+
JSON.stringify(node.imports ?? []),
|
|
401
|
+
Number(node.symbolRefs ?? 0),
|
|
402
|
+
Number(node.unresolvedImports ?? 0),
|
|
403
|
+
now
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
if (Array.isArray(graph.edges)) {
|
|
407
|
+
this.db.exec('DELETE FROM graph_edges;');
|
|
408
|
+
for (const edge of graph.edges) {
|
|
409
|
+
if (!edge || !edge.from || !edge.to) continue;
|
|
410
|
+
this.stmtUpsertEdge.run(
|
|
411
|
+
toPosix(edge.from),
|
|
412
|
+
toPosix(edge.to),
|
|
413
|
+
Number(edge.weight ?? 1),
|
|
414
|
+
now
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
this.db.exec('COMMIT');
|
|
419
|
+
} catch (err) {
|
|
420
|
+
this.db.exec('ROLLBACK');
|
|
421
|
+
throw err;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Read the persisted knowledge graph (`nodes` and `edges`) from SQLite.
|
|
427
|
+
* @returns {Promise<{nodes: Array<Object>, edges: Array<{from: string, to: string, weight: number}>}>}
|
|
428
|
+
*/
|
|
429
|
+
async getGraph() {
|
|
430
|
+
if (!this.initialized) await this.init();
|
|
431
|
+
|
|
432
|
+
const rawNodes = this.stmtAllNodes.all();
|
|
433
|
+
const rawEdges = this.stmtAllEdges.all();
|
|
434
|
+
|
|
435
|
+
const parseJson = (str, fallback) => {
|
|
436
|
+
if (!str) return fallback;
|
|
437
|
+
try {
|
|
438
|
+
return JSON.parse(str);
|
|
439
|
+
} catch {
|
|
440
|
+
return fallback;
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const nodes = rawNodes
|
|
445
|
+
.map((row) => ({
|
|
446
|
+
path: row.path,
|
|
447
|
+
language: row.language,
|
|
448
|
+
bytes: Number(row.bytes ?? 0),
|
|
449
|
+
contentHash: row.content_hash ?? null,
|
|
450
|
+
methods: parseJson(row.methods, []),
|
|
451
|
+
declarations: parseJson(row.declarations, []),
|
|
452
|
+
imports: parseJson(row.imports, []),
|
|
453
|
+
symbolRefs: Number(row.symbol_refs ?? 0),
|
|
454
|
+
unresolvedImports: Number(row.unresolved_imports ?? 0),
|
|
455
|
+
updatedAt: row.updated_at ? Number(row.updated_at) : null,
|
|
456
|
+
}))
|
|
457
|
+
.sort((a, b) => comparePaths(a.path, b.path));
|
|
458
|
+
|
|
459
|
+
const edges = rawEdges
|
|
460
|
+
.map((row) => ({
|
|
461
|
+
from: row.from_path,
|
|
462
|
+
to: row.to_path,
|
|
463
|
+
weight: Number(row.weight ?? 1),
|
|
464
|
+
}))
|
|
465
|
+
.sort((a, b) => comparePaths(a.from, b.from) || comparePaths(a.to, b.to));
|
|
466
|
+
|
|
467
|
+
return { nodes, edges };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Return node and edge counts in the persisted knowledge graph.
|
|
472
|
+
* @returns {{nodes: number, edges: number}}
|
|
473
|
+
*/
|
|
474
|
+
graphCounts() {
|
|
475
|
+
if (!this.initialized || !this.db) return { nodes: 0, edges: 0 };
|
|
476
|
+
const nRow = this.stmtNodeCount.get();
|
|
477
|
+
const eRow = this.stmtEdgeCount.get();
|
|
478
|
+
return {
|
|
479
|
+
nodes: nRow ? Number(nRow.count) : 0,
|
|
480
|
+
edges: eRow ? Number(eRow.count) : 0,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Exact cosine similarity search over stored documents.
|
|
486
|
+
* @param {string} queryText
|
|
487
|
+
* @param {number} [limit=5]
|
|
488
|
+
* @returns {Promise<Array<{id?: string, text: string, score: number, metadata: Object}>>}
|
|
489
|
+
*/
|
|
490
|
+
async search(queryText, limit = 5) {
|
|
491
|
+
if (!this.initialized) await this.init();
|
|
492
|
+
|
|
493
|
+
const countRow = this.stmtCount.get();
|
|
494
|
+
if (!countRow || Number(countRow.count) === 0) return [];
|
|
495
|
+
|
|
496
|
+
const queryVector = await this.embeddingProvider.embed(queryText);
|
|
497
|
+
const qLen = queryVector.length;
|
|
498
|
+
|
|
499
|
+
let queryNormSq = 0;
|
|
500
|
+
for (let i = 0; i < qLen; i++) {
|
|
501
|
+
queryNormSq += queryVector[i] * queryVector[i];
|
|
502
|
+
}
|
|
503
|
+
const queryNorm = Math.sqrt(queryNormSq);
|
|
504
|
+
if (queryNorm === 0) return [];
|
|
505
|
+
|
|
506
|
+
const rows = this.stmtAllVectors.all();
|
|
507
|
+
const scored = [];
|
|
508
|
+
|
|
509
|
+
for (const row of rows) {
|
|
510
|
+
if (!row.vector) continue;
|
|
511
|
+
const buf = row.vector;
|
|
512
|
+
const docVector = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
|
|
513
|
+
|
|
514
|
+
let dot = 0;
|
|
515
|
+
let docNormSq = 0;
|
|
516
|
+
const dim = Math.min(qLen, docVector.length);
|
|
517
|
+
|
|
518
|
+
for (let i = 0; i < dim; i++) {
|
|
519
|
+
const qVal = queryVector[i];
|
|
520
|
+
const dVal = docVector[i];
|
|
521
|
+
dot += qVal * dVal;
|
|
522
|
+
docNormSq += dVal * dVal;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const docNorm = Math.sqrt(docNormSq);
|
|
526
|
+
const similarity = docNorm === 0 ? 0 : dot / (queryNorm * docNorm);
|
|
527
|
+
|
|
528
|
+
let parsedMeta = {};
|
|
529
|
+
if (row.metadata) {
|
|
530
|
+
try {
|
|
531
|
+
parsedMeta = JSON.parse(row.metadata);
|
|
532
|
+
} catch {
|
|
533
|
+
parsedMeta = {};
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
scored.push({
|
|
538
|
+
id: row.id,
|
|
539
|
+
text: row.text,
|
|
540
|
+
score: similarity,
|
|
541
|
+
metadata: parsedMeta,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Sort descending by similarity score
|
|
546
|
+
scored.sort((a, b) => b.score - a.score);
|
|
547
|
+
|
|
548
|
+
return scored.slice(0, limit);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Delete documents and graph tables and reinitialize.
|
|
553
|
+
*/
|
|
554
|
+
async deleteTable() {
|
|
555
|
+
if (!this.initialized) await this.init();
|
|
556
|
+
try {
|
|
557
|
+
this.db.exec(`
|
|
558
|
+
DROP TABLE IF EXISTS documents;
|
|
559
|
+
DROP TABLE IF EXISTS graph_nodes;
|
|
560
|
+
DROP TABLE IF EXISTS graph_edges;
|
|
561
|
+
`);
|
|
562
|
+
this.initialized = false;
|
|
563
|
+
await this.init();
|
|
564
|
+
} catch (_e) {
|
|
565
|
+
// Ignore if table does not exist
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Total number of indexed documents.
|
|
571
|
+
* @returns {number}
|
|
572
|
+
*/
|
|
573
|
+
count() {
|
|
574
|
+
if (!this.initialized || !this.db) return 0;
|
|
575
|
+
const row = this.stmtCount.get();
|
|
576
|
+
return row ? Number(row.count) : 0;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Close the database connection.
|
|
581
|
+
*/
|
|
582
|
+
close() {
|
|
583
|
+
if (this.db) {
|
|
584
|
+
try {
|
|
585
|
+
this.db.close();
|
|
586
|
+
} catch (_e) {
|
|
587
|
+
// ignore
|
|
588
|
+
}
|
|
589
|
+
this.db = null;
|
|
590
|
+
this.initialized = false;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export default SqliteVectorStore;
|