@andespindola/brainlink 0.1.0-beta.14 → 0.1.0-beta.140

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.
Files changed (55) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +26 -2
  3. package/CONTRIBUTING.md +2 -2
  4. package/COPYRIGHT.md +5 -0
  5. package/README.md +144 -22
  6. package/SECURITY.md +1 -1
  7. package/dist/application/analyze-vault.js +1 -15
  8. package/dist/application/build-context.js +64 -3
  9. package/dist/application/dedupe-notes.js +226 -0
  10. package/dist/application/frontend/client-css.js +110 -45
  11. package/dist/application/frontend/client-html.js +35 -26
  12. package/dist/application/frontend/client-js.js +2987 -153
  13. package/dist/application/frontend/client-worker-js.js +66 -0
  14. package/dist/application/get-graph-layout.js +39 -6
  15. package/dist/application/get-graph-node.js +3 -3
  16. package/dist/application/get-graph-summary.js +3 -3
  17. package/dist/application/get-graph-view.js +243 -0
  18. package/dist/application/get-graph.js +3 -3
  19. package/dist/application/import-legacy-sqlite.js +296 -0
  20. package/dist/application/index-vault.js +253 -25
  21. package/dist/application/list-agents.js +3 -3
  22. package/dist/application/list-links.js +5 -5
  23. package/dist/application/offline-pack-backup.js +44 -0
  24. package/dist/application/search-graph-node-ids.js +3 -3
  25. package/dist/application/search-knowledge.js +4 -5
  26. package/dist/application/server/routes.js +156 -5
  27. package/dist/application/start-server.js +75 -4
  28. package/dist/application/watch-vault.js +23 -2
  29. package/dist/benchmarks/large-vault.js +1 -1
  30. package/dist/cli/commands/agent-commands.js +7 -0
  31. package/dist/cli/commands/write-commands.js +842 -8
  32. package/dist/domain/context.js +54 -11
  33. package/dist/domain/graph-layout.js +181 -3
  34. package/dist/domain/markdown.js +29 -9
  35. package/dist/domain/middle-out.js +18 -0
  36. package/dist/infrastructure/config.js +38 -0
  37. package/dist/infrastructure/file-index.js +358 -0
  38. package/dist/infrastructure/file-system-vault.js +15 -0
  39. package/dist/infrastructure/index-state.js +58 -0
  40. package/dist/infrastructure/private-pack-codec.js +71 -10
  41. package/dist/infrastructure/search-packs.js +276 -87
  42. package/dist/infrastructure/volatile-memory.js +100 -0
  43. package/dist/mcp/server.js +21 -1
  44. package/dist/mcp/tools.js +96 -0
  45. package/docs/AGENT_USAGE.md +101 -19
  46. package/docs/ARCHITECTURE.md +23 -28
  47. package/docs/QUICKSTART.md +7 -0
  48. package/package.json +6 -4
  49. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  50. package/dist/infrastructure/sqlite/graph-reader.js +0 -267
  51. package/dist/infrastructure/sqlite/recovery.js +0 -163
  52. package/dist/infrastructure/sqlite/schema.js +0 -114
  53. package/dist/infrastructure/sqlite/search-reader.js +0 -188
  54. package/dist/infrastructure/sqlite/types.js +0 -1
  55. package/dist/infrastructure/sqlite-index.js +0 -38
@@ -0,0 +1,296 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { access } from 'node:fs/promises';
3
+ import { basename, extname, join, relative, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { promisify } from 'node:util';
6
+ import { extractTags, extractWikiLinks } from '../domain/markdown.js';
7
+ import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
8
+ import { ensureVault, listVaultFiles, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
9
+ import { getBrainlinkHomePath } from '../infrastructure/paths.js';
10
+ const execFileAsync = promisify(execFile);
11
+ const fieldSeparator = '\u001f';
12
+ const rowSeparator = '\u001e';
13
+ const contentColumnCandidates = ['content', 'markdown', 'body', 'text', 'note'];
14
+ const titleColumnCandidates = ['title', 'note_title', 'name', 'headline'];
15
+ const pathColumnCandidates = ['path', 'file_path', 'filepath', 'source_path', 'source'];
16
+ const agentColumnCandidates = ['agent', 'agent_id', 'namespace', 'scope'];
17
+ const tagColumnCandidates = ['tags', 'tag_list', 'keywords'];
18
+ const createdColumnCandidates = ['created_at', 'createdat', 'created', 'ctime'];
19
+ const updatedColumnCandidates = ['updated_at', 'updatedat', 'updated', 'mtime'];
20
+ const systemHubTitle = 'Memory Hub';
21
+ const systemRootTitle = 'Knowledge Root';
22
+ const normalizeTitle = (title) => title.trim().replace(/\.md$/i, '').toLowerCase();
23
+ const slugify = (title) => title
24
+ .normalize('NFKD')
25
+ .replace(/[\u0300-\u036f]/g, '')
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9]+/g, '-')
28
+ .replace(/^-+|-+$/g, '');
29
+ const quoteIdentifier = (value) => `"${value.replaceAll('"', '""')}"`;
30
+ const pickColumn = (columns, candidates) => {
31
+ const byLower = new Map(columns.map((column) => [column.toLowerCase(), column]));
32
+ return candidates.map((candidate) => byLower.get(candidate)).find((column) => Boolean(column)) ?? null;
33
+ };
34
+ const parseDelimitedRows = (rawOutput) => {
35
+ const normalized = rawOutput.trim();
36
+ if (normalized.length === 0) {
37
+ return [];
38
+ }
39
+ return normalized
40
+ .split(rowSeparator)
41
+ .map((row) => row.trim())
42
+ .filter(Boolean)
43
+ .map((row) => row.split(fieldSeparator));
44
+ };
45
+ const runSqliteQuery = async (databasePath, sql) => {
46
+ const baseArgs = ['-noheader', '-separator', fieldSeparator, '-newline', rowSeparator, '-cmd', '.timeout 5000'];
47
+ const runQuery = async (args) => {
48
+ const { stdout } = await execFileAsync('sqlite3', [...args, sql], { maxBuffer: 1024 * 1024 * 64 });
49
+ return parseDelimitedRows(stdout);
50
+ };
51
+ try {
52
+ return await runQuery(['--readonly', ...baseArgs, databasePath]);
53
+ }
54
+ catch (error) {
55
+ const message = error instanceof Error ? error.message : String(error);
56
+ const lower = message.toLowerCase();
57
+ if (lower.includes('enoent') || lower.includes('not found')) {
58
+ throw new Error('sqlite3 CLI was not found. Install sqlite3 to use db-import.');
59
+ }
60
+ if (lower.includes('database is locked') || lower.includes('(5)')) {
61
+ try {
62
+ const uri = pathToFileURL(databasePath);
63
+ uri.search = 'mode=ro&immutable=1';
64
+ return await runQuery(['-uri', ...baseArgs, uri.toString()]);
65
+ }
66
+ catch (fallbackError) {
67
+ const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
68
+ throw new Error(`Unable to read SQLite database (locked). Close writers (server/watch/mcp) or rerun with DB idle. Details: ${fallbackMessage}`);
69
+ }
70
+ }
71
+ throw new Error(`Unable to read SQLite database: ${message}`);
72
+ }
73
+ };
74
+ const detectLegacyDbPath = async (vaultPath, explicitPath) => {
75
+ if (explicitPath) {
76
+ return resolve(explicitPath);
77
+ }
78
+ const vaultRoot = await ensureVault(vaultPath);
79
+ const candidates = [
80
+ join(vaultRoot, '.brainlink', 'brainlink.db'),
81
+ join(vaultRoot, '.brainlink', 'index.db'),
82
+ join(getBrainlinkHomePath(), 'brainlink.db'),
83
+ join(getBrainlinkHomePath(), 'vault', '.brainlink', 'brainlink.db')
84
+ ];
85
+ for (const candidate of candidates) {
86
+ try {
87
+ await access(candidate);
88
+ return candidate;
89
+ }
90
+ catch { }
91
+ }
92
+ throw new Error(`No legacy SQLite database found. Checked: ${candidates.join(', ')}. Use --db <path-to-db> to import explicitly.`);
93
+ };
94
+ const listTables = async (dbPath) => {
95
+ const rows = await runSqliteQuery(dbPath, `SELECT name
96
+ FROM sqlite_master
97
+ WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
98
+ ORDER BY name`);
99
+ return rows.map((columns) => columns[0]).filter(Boolean);
100
+ };
101
+ const listColumns = async (dbPath, table) => {
102
+ const rows = await runSqliteQuery(dbPath, `PRAGMA table_info(${quoteIdentifier(table)})`);
103
+ return rows.map((columns) => columns[1]).filter(Boolean);
104
+ };
105
+ const tableScore = (columns) => {
106
+ const contentColumn = pickColumn(columns, contentColumnCandidates);
107
+ const titleColumn = pickColumn(columns, titleColumnCandidates);
108
+ const pathColumn = pickColumn(columns, pathColumnCandidates);
109
+ const agentColumn = pickColumn(columns, agentColumnCandidates);
110
+ return (contentColumn ? 6 : 0) + (titleColumn ? 4 : 0) + (pathColumn ? 2 : 0) + (agentColumn ? 1 : 0);
111
+ };
112
+ const detectTableMapping = async (dbPath, tableOverride) => {
113
+ const tables = await listTables(dbPath);
114
+ if (tables.length === 0) {
115
+ throw new Error('Legacy SQLite database has no readable tables.');
116
+ }
117
+ const mappings = await Promise.all(tables.map(async (table) => {
118
+ const columns = await listColumns(dbPath, table);
119
+ return {
120
+ table,
121
+ columns,
122
+ titleColumn: pickColumn(columns, titleColumnCandidates),
123
+ contentColumn: pickColumn(columns, contentColumnCandidates),
124
+ pathColumn: pickColumn(columns, pathColumnCandidates),
125
+ agentColumn: pickColumn(columns, agentColumnCandidates),
126
+ tagsColumn: pickColumn(columns, tagColumnCandidates),
127
+ createdColumn: pickColumn(columns, createdColumnCandidates),
128
+ updatedColumn: pickColumn(columns, updatedColumnCandidates),
129
+ score: tableScore(columns)
130
+ };
131
+ }));
132
+ if (tableOverride) {
133
+ const overridden = mappings.find((mapping) => mapping.table === tableOverride);
134
+ if (!overridden) {
135
+ throw new Error(`Table not found in SQLite database: ${tableOverride}`);
136
+ }
137
+ if (!overridden.contentColumn) {
138
+ throw new Error(`Table ${tableOverride} does not expose a readable content column.`);
139
+ }
140
+ return { mapping: overridden, detectedTables: tables };
141
+ }
142
+ const selected = [...mappings]
143
+ .filter((mapping) => mapping.contentColumn)
144
+ .sort((left, right) => right.score - left.score)[0];
145
+ if (!selected) {
146
+ throw new Error('Could not detect a legacy table with content column in SQLite database.');
147
+ }
148
+ return { mapping: selected, detectedTables: tables };
149
+ };
150
+ const hexExpression = (column) => column ? `hex(COALESCE(CAST(${quoteIdentifier(column)} AS BLOB), X''))` : `hex(X'')`;
151
+ const decodeHexUtf8 = (value) => value ? Buffer.from(value, 'hex').toString('utf8') : '';
152
+ const parseLegacyTags = (value) => Array.from(new Set(value
153
+ .split(/[\s,;|]+/)
154
+ .map((item) => item.trim().replace(/^#/, '').toLowerCase())
155
+ .filter((item) => /^[a-z0-9][a-z0-9_-]*$/i.test(item))));
156
+ const titleFromPath = (pathValue) => basename(pathValue).replace(extname(pathValue), '').replace(/[-_]+/g, ' ').trim();
157
+ const appendMissingTags = (content, tags) => {
158
+ if (tags.length === 0) {
159
+ return content;
160
+ }
161
+ const existingTags = new Set(extractTags(content).map((tag) => tag.toLowerCase()));
162
+ const missing = tags.filter((tag) => !existingTags.has(tag.toLowerCase()));
163
+ if (missing.length === 0) {
164
+ return content;
165
+ }
166
+ return `${content.trim()}\n\nTags: ${missing.map((tag) => `#${tag}`).join(' ')}`;
167
+ };
168
+ const buildNote = (title, content, agentId) => [
169
+ '---',
170
+ `title: "${title.replaceAll('"', '\\"')}"`,
171
+ `agent: "${agentId}"`,
172
+ '---',
173
+ '',
174
+ `# ${title}`,
175
+ '',
176
+ content.trim(),
177
+ ''
178
+ ].join('\n');
179
+ const parseLegacyRow = (columns, rowIndex) => {
180
+ const [titleHex, contentHex, pathHex, agentHex, tagsHex] = columns;
181
+ const content = decodeHexUtf8(contentHex).trim();
182
+ const path = decodeHexUtf8(pathHex).trim();
183
+ const titleCandidate = decodeHexUtf8(titleHex).trim();
184
+ const fallbackTitleFromPath = path ? titleFromPath(path) : '';
185
+ const title = titleCandidate || fallbackTitleFromPath || `Imported Memory ${rowIndex + 1}`;
186
+ return {
187
+ title,
188
+ content,
189
+ path,
190
+ agent: decodeHexUtf8(agentHex).trim(),
191
+ tags: parseLegacyTags(decodeHexUtf8(tagsHex))
192
+ };
193
+ };
194
+ const noteRelativePath = (agentId, slug, suffix = 0) => `agents/${agentId}/${suffix > 0 ? `${slug}-${suffix + 1}` : slug || 'untitled'}.md`;
195
+ const reserveUniquePath = (agentId, title, reserved) => {
196
+ const slug = slugify(title);
197
+ for (let suffix = 0; suffix < 10_000; suffix += 1) {
198
+ const relativePath = noteRelativePath(agentId, slug, suffix);
199
+ if (!reserved.has(relativePath)) {
200
+ reserved.add(relativePath);
201
+ return relativePath;
202
+ }
203
+ }
204
+ throw new Error(`Could not allocate unique path for imported note: ${title}`);
205
+ };
206
+ const ensureSystemNote = async (vaultPath, reserved, created, agentId, title, content, dryRun) => {
207
+ const filename = noteRelativePath(agentId, slugify(title));
208
+ if (reserved.has(filename)) {
209
+ return;
210
+ }
211
+ reserved.add(filename);
212
+ created.add(filename);
213
+ if (dryRun) {
214
+ return;
215
+ }
216
+ await writeMarkdownFile(vaultPath, filename, buildNote(title, content, agentId));
217
+ };
218
+ const applyConnectivityRule = async (vaultPath, reserved, created, title, content, agentId, dryRun) => {
219
+ const links = extractWikiLinks(content).filter((link) => normalizeTitle(link) !== normalizeTitle(title));
220
+ if (links.length > 0) {
221
+ return content.trim();
222
+ }
223
+ const normalized = normalizeTitle(title);
224
+ if (normalized === normalizeTitle(systemHubTitle)) {
225
+ await ensureSystemNote(vaultPath, reserved, created, agentId, systemRootTitle, `Entry point for agent memory. [[${systemHubTitle}]] #memory #root`, dryRun);
226
+ return `${content.trim()}\n\nRelated: [[${systemRootTitle}]]`;
227
+ }
228
+ await ensureSystemNote(vaultPath, reserved, created, agentId, systemHubTitle, 'Central memory index for this agent namespace. #memory #hub', dryRun);
229
+ return `${content.trim()}\n\nRelated: [[${systemHubTitle}]]`;
230
+ };
231
+ const importRowsFromMapping = async (vaultPath, dbPath, mapping, options, reserved) => {
232
+ const limit = Number.isFinite(options.limit) && (options.limit ?? 0) > 0 ? Math.floor(options.limit ?? 0) : undefined;
233
+ const sql = [
234
+ 'SELECT',
235
+ `${hexExpression(mapping.titleColumn)} AS title_hex,`,
236
+ `${hexExpression(mapping.contentColumn)} AS content_hex,`,
237
+ `${hexExpression(mapping.pathColumn)} AS path_hex,`,
238
+ `${hexExpression(mapping.agentColumn)} AS agent_hex,`,
239
+ `${hexExpression(mapping.tagsColumn)} AS tags_hex,`,
240
+ `${hexExpression(mapping.createdColumn)} AS created_hex,`,
241
+ `${hexExpression(mapping.updatedColumn)} AS updated_hex`,
242
+ `FROM ${quoteIdentifier(mapping.table)}`,
243
+ ...(limit ? [`LIMIT ${limit}`] : [])
244
+ ].join(' ');
245
+ const rows = await runSqliteQuery(dbPath, sql);
246
+ const createdSystemNotes = new Set();
247
+ const importedFiles = [];
248
+ let imported = 0;
249
+ let skipped = 0;
250
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
251
+ const row = parseLegacyRow(rows[rowIndex], rowIndex);
252
+ if (!row.content) {
253
+ skipped += 1;
254
+ continue;
255
+ }
256
+ const agentId = sanitizeAgentId(options.agentOverride || row.agent || sharedAgentId);
257
+ const filename = reserveUniquePath(agentId, row.title, reserved);
258
+ const mergedContent = appendMissingTags(row.content, row.tags);
259
+ const connectedContent = await applyConnectivityRule(vaultPath, reserved, createdSystemNotes, row.title, mergedContent, agentId, options.dryRun === true);
260
+ const note = buildNote(row.title, connectedContent, agentId);
261
+ if (options.dryRun !== true) {
262
+ await writeMarkdownFile(vaultPath, filename, note);
263
+ }
264
+ importedFiles.push(filename);
265
+ imported += 1;
266
+ }
267
+ return {
268
+ rowsRead: rows.length,
269
+ imported,
270
+ skipped,
271
+ createdSystemNotes: createdSystemNotes.size,
272
+ importedFiles
273
+ };
274
+ };
275
+ export const importLegacySqliteDatabase = async (vaultPath, options = {}) => {
276
+ const vault = await ensureVault(vaultPath);
277
+ const dbPath = await detectLegacyDbPath(vaultPath, options.dbPath);
278
+ const { mapping, detectedTables } = await detectTableMapping(dbPath, options.table);
279
+ const existingFiles = (await listVaultFiles(vaultPath))
280
+ .filter((path) => extname(path).toLowerCase() === '.md')
281
+ .map((path) => relative(vault, path));
282
+ const reserved = new Set(existingFiles);
283
+ const imported = await importRowsFromMapping(vaultPath, dbPath, mapping, options, reserved);
284
+ return {
285
+ vault,
286
+ dbPath,
287
+ table: mapping.table,
288
+ detectedTables,
289
+ rowsRead: imported.rowsRead,
290
+ imported: imported.imported,
291
+ skipped: imported.skipped,
292
+ createdSystemNotes: imported.createdSystemNotes,
293
+ dryRun: options.dryRun === true,
294
+ importedFiles: imported.importedFiles
295
+ };
296
+ };
@@ -1,10 +1,12 @@
1
- import { createIndexedDocument, parseMarkdownDocument } from '../domain/markdown.js';
1
+ import { readFile } from 'node:fs/promises';
2
+ import { createIndexedDocument, graphLinkModelVersion, parseMarkdownDocument } from '../domain/markdown.js';
2
3
  import { sharedAgentId } from '../domain/agents.js';
3
4
  import { createEmbeddingProvider } from '../domain/embeddings.js';
4
5
  import { loadBrainlinkConfig } from '../infrastructure/config.js';
5
- import { ensureVault, readMarkdownFiles } from '../infrastructure/file-system-vault.js';
6
- import { buildSearchPacks } from '../infrastructure/search-packs.js';
7
- import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
6
+ import { ensureVault, readMarkdownFileSummaries } from '../infrastructure/file-system-vault.js';
7
+ import { readIndexState, writeIndexState } from '../infrastructure/index-state.js';
8
+ import { buildSearchPacks, ensureSearchPackManifest, toSearchPackBuildOptions } from '../infrastructure/search-packs.js';
9
+ import { openFileIndex } from '../infrastructure/file-index.js';
8
10
  const toTitleKey = (title) => title.toLowerCase();
9
11
  const appendTitleEntry = (map, document) => {
10
12
  const key = toTitleKey(document.title);
@@ -34,6 +36,9 @@ const createScopedTitleResolver = (document, titleMaps) => ({
34
36
  get: (title) => titleMaps.byAgent.get(document.agentId)?.get(title)?.id ?? titleMaps.shared.get(title)?.id
35
37
  });
36
38
  const embedIndexedDocuments = async (documents, providerName) => {
39
+ if (documents.length === 0) {
40
+ return documents;
41
+ }
37
42
  const provider = createEmbeddingProvider(providerName);
38
43
  const chunks = documents.flatMap((document) => document.chunks);
39
44
  const embeddings = await provider.embed(chunks.map((chunk) => chunk.content));
@@ -47,34 +52,257 @@ const embedIndexedDocuments = async (documents, providerName) => {
47
52
  }))
48
53
  }));
49
54
  };
55
+ const relinkIndexedDocument = (indexedDocument, titleMaps) => {
56
+ const resolver = createScopedTitleResolver(indexedDocument.document, titleMaps);
57
+ return {
58
+ ...indexedDocument,
59
+ links: indexedDocument.links
60
+ .map((link) => ({
61
+ ...link,
62
+ toDocumentId: resolver.get(link.toTitle.toLowerCase()) ?? null
63
+ }))
64
+ .filter((link) => link.toDocumentId !== indexedDocument.document.id)
65
+ };
66
+ };
67
+ const toIndexResult = (documents) => ({
68
+ documentCount: documents.length,
69
+ chunkCount: documents.reduce((total, document) => total + document.chunks.length, 0),
70
+ linkCount: documents.reduce((total, document) => total + document.links.length, 0)
71
+ });
72
+ const toSnapshot = (summaries) => summaries.map((summary) => ({
73
+ path: summary.relativePath,
74
+ mtimeMs: summary.updatedAt.getTime(),
75
+ size: summary.size
76
+ }));
77
+ const createSnapshotMap = (snapshot) => new Map(snapshot.map((entry) => [entry.path, entry]));
78
+ const readChangedDocuments = async (absoluteVaultPath, changedSummaries) => {
79
+ const parsed = await Promise.all(changedSummaries.map(async (summary) => parseMarkdownDocument({
80
+ absolutePath: summary.absolutePath,
81
+ vaultPath: absoluteVaultPath,
82
+ content: await readFile(summary.absolutePath, 'utf8'),
83
+ createdAt: summary.createdAt,
84
+ updatedAt: summary.updatedAt
85
+ })));
86
+ return new Map(parsed.map((document) => [document.path, document]));
87
+ };
50
88
  export const indexVault = async (vaultPath) => {
89
+ return indexVaultWithOptions(vaultPath, {});
90
+ };
91
+ export const indexVaultWithOptions = async (vaultPath, options) => {
92
+ const startedAt = process.hrtime.bigint();
93
+ const elapsedMs = () => Number(process.hrtime.bigint() - startedAt) / 1_000_000;
94
+ const emit = (phase, status, message, details) => {
95
+ options.onProgress?.({
96
+ phase,
97
+ status,
98
+ message,
99
+ elapsedMs: elapsedMs(),
100
+ timestamp: new Date().toISOString(),
101
+ details
102
+ });
103
+ };
104
+ emit('start', 'start', 'Indexing started');
51
105
  const absoluteVaultPath = await ensureVault(vaultPath);
52
106
  const config = await loadBrainlinkConfig();
53
- const files = await readMarkdownFiles(absoluteVaultPath);
54
- const documents = files.map((file) => parseMarkdownDocument({
55
- absolutePath: file.absolutePath,
56
- vaultPath: absoluteVaultPath,
57
- content: file.content,
58
- createdAt: file.createdAt,
59
- updatedAt: file.updatedAt
60
- }));
61
- const titleMaps = createTitleMaps(documents);
62
- const indexedDocuments = await embedIndexedDocuments(documents.map((document) => createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize)), config.embeddingProvider);
63
- const index = openSqliteIndex(absoluteVaultPath);
107
+ emit('scan', 'start', 'Scanning markdown files');
108
+ const [summaries, previousState] = await Promise.all([
109
+ readMarkdownFileSummaries(absoluteVaultPath),
110
+ readIndexState(absoluteVaultPath)
111
+ ]);
112
+ emit('scan', 'finish', 'Scan complete', {
113
+ markdownFiles: summaries.length,
114
+ hasPreviousState: previousState != null
115
+ });
116
+ const index = openFileIndex(absoluteVaultPath);
64
117
  try {
65
- index.reset();
66
- index.saveDocuments(indexedDocuments);
67
- try {
68
- await buildSearchPacks(absoluteVaultPath, indexedDocuments);
118
+ const existingIndexedDocuments = await index.getIndexedDocuments();
119
+ const existingByPath = new Map(existingIndexedDocuments.map((document) => [document.document.path, document]));
120
+ const currentSnapshot = toSnapshot(summaries);
121
+ const currentSnapshotMap = createSnapshotMap(currentSnapshot);
122
+ const previousSnapshotMap = createSnapshotMap(previousState?.files ?? []);
123
+ const settingsChanged = previousState == null ||
124
+ previousState.chunkSize !== config.chunkSize ||
125
+ previousState.embeddingProvider !== config.embeddingProvider ||
126
+ previousState.graphLinkModelVersion !== graphLinkModelVersion;
127
+ const packSettingsChanged = previousState == null ||
128
+ previousState.searchPackRowChunkSize !== config.searchPack.rowChunkSize ||
129
+ previousState.searchPackCompressionLevel !== config.searchPack.compressionLevel ||
130
+ previousState.searchPackUseDictionary !== config.searchPack.useDictionary;
131
+ const changedPaths = new Set();
132
+ for (let index = 0; index < summaries.length; index += 1) {
133
+ const summary = summaries[index];
134
+ const previous = previousSnapshotMap.get(summary.relativePath);
135
+ const changed = settingsChanged ||
136
+ previous == null ||
137
+ previous.mtimeMs !== summary.updatedAt.getTime() ||
138
+ previous.size !== summary.size ||
139
+ !existingByPath.has(summary.relativePath);
140
+ if (changed) {
141
+ changedPaths.add(summary.relativePath);
142
+ }
69
143
  }
70
- catch {
71
- // Pack generation is best-effort. SQLite index remains the primary path.
144
+ const hasDeletes = previousState
145
+ ? previousState.files.some((entry) => !currentSnapshotMap.has(entry.path))
146
+ : false;
147
+ const manifestRecovery = await ensureSearchPackManifest(absoluteVaultPath);
148
+ if (changedPaths.size === 0 &&
149
+ !hasDeletes &&
150
+ existingIndexedDocuments.length === summaries.length &&
151
+ previousState != null) {
152
+ const result = {
153
+ ...toIndexResult(existingIndexedDocuments),
154
+ elapsedMs: elapsedMs(),
155
+ changedDocumentCount: 0,
156
+ packs: {
157
+ rebuilt: false,
158
+ reason: manifestRecovery.repaired ? 'No changes detected; pack manifest repaired' : 'No changes detected'
159
+ }
160
+ };
161
+ emit('complete', 'skip', 'Index skipped: no changes detected', {
162
+ elapsedMs: result.elapsedMs,
163
+ manifestRepaired: manifestRecovery.repaired,
164
+ manifestRecoverySource: manifestRecovery.source
165
+ });
166
+ return result;
72
167
  }
73
- return {
74
- documentCount: indexedDocuments.length,
75
- chunkCount: indexedDocuments.reduce((total, document) => total + document.chunks.length, 0),
76
- linkCount: indexedDocuments.reduce((total, document) => total + document.links.length, 0)
168
+ const changedSummaries = summaries.filter((summary) => changedPaths.has(summary.relativePath));
169
+ emit('parse', 'start', 'Parsing changed markdown files', {
170
+ changedFiles: changedSummaries.length
171
+ });
172
+ const changedDocumentsByPath = await readChangedDocuments(absoluteVaultPath, changedSummaries);
173
+ emit('parse', 'finish', 'Parse complete', {
174
+ changedDocuments: changedDocumentsByPath.size
175
+ });
176
+ const documents = summaries.flatMap((summary) => {
177
+ const changed = changedDocumentsByPath.get(summary.relativePath);
178
+ if (changed) {
179
+ return [changed];
180
+ }
181
+ const existing = existingByPath.get(summary.relativePath);
182
+ return existing ? [existing.document] : [];
183
+ });
184
+ const titleMaps = createTitleMaps(documents);
185
+ emit('embed', 'start', 'Embedding changed chunks', {
186
+ changedDocuments: changedDocumentsByPath.size
187
+ });
188
+ const changedIndexedDocuments = changedDocumentsByPath.size > 0
189
+ ? await embedIndexedDocuments(Array.from(changedDocumentsByPath.values()).map((document) => createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize)), config.embeddingProvider)
190
+ : [];
191
+ emit('embed', changedDocumentsByPath.size > 0 ? 'finish' : 'skip', changedDocumentsByPath.size > 0 ? 'Embedding complete' : 'Embedding skipped', {
192
+ changedIndexedDocuments: changedIndexedDocuments.length
193
+ });
194
+ const changedIndexedByPath = new Map(changedIndexedDocuments.map((document) => [document.document.path, document]));
195
+ const needsRelink = settingsChanged || hasDeletes || changedPaths.size > 0;
196
+ const indexedDocuments = documents.map((document) => {
197
+ const changed = changedIndexedByPath.get(document.path);
198
+ if (changed) {
199
+ return changed;
200
+ }
201
+ const existing = existingByPath.get(document.path);
202
+ if (!existing) {
203
+ return createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize);
204
+ }
205
+ return needsRelink ? relinkIndexedDocument(existing, titleMaps) : existing;
206
+ });
207
+ emit('persist', 'start', 'Persisting index');
208
+ await index.reset();
209
+ await index.saveDocuments(indexedDocuments);
210
+ emit('persist', 'finish', 'Index persisted', {
211
+ indexedDocuments: indexedDocuments.length
212
+ });
213
+ const existingPackManifest = manifestRecovery.repaired || manifestRecovery.source === 'not-needed';
214
+ const changedCount = changedPaths.size;
215
+ const documentCount = Math.max(indexedDocuments.length, 1);
216
+ const changeRatio = changedCount / documentCount;
217
+ const previousPendingPackChanges = previousState?.pendingPackChanges ?? 0;
218
+ const pendingPackChanges = previousPendingPackChanges + changedCount;
219
+ const shouldRebuildPacks = !existingPackManifest ||
220
+ settingsChanged ||
221
+ packSettingsChanged ||
222
+ hasDeletes ||
223
+ changedCount >= 400 ||
224
+ changeRatio >= 0.04 ||
225
+ pendingPackChanges >= 1200;
226
+ let packResult;
227
+ const packReason = !existingPackManifest
228
+ ? 'Missing pack manifest'
229
+ : manifestRecovery.repaired
230
+ ? 'Pack manifest repaired from existing packs'
231
+ : settingsChanged
232
+ ? 'Index settings changed'
233
+ : packSettingsChanged
234
+ ? 'Search pack settings changed'
235
+ : hasDeletes
236
+ ? 'Document deletions detected'
237
+ : changedCount >= 400
238
+ ? 'Changed file count threshold reached'
239
+ : changeRatio >= 0.04
240
+ ? 'Change ratio threshold reached'
241
+ : pendingPackChanges >= 1200
242
+ ? 'Pending pack changes threshold reached'
243
+ : 'Pack rebuild skipped';
244
+ if (shouldRebuildPacks) {
245
+ emit('packs', 'start', 'Rebuilding compressed search packs', {
246
+ reason: packReason
247
+ });
248
+ try {
249
+ packResult = await buildSearchPacks(absoluteVaultPath, indexedDocuments, toSearchPackBuildOptions(config));
250
+ emit('packs', 'finish', 'Compressed packs rebuilt', {
251
+ reason: packReason,
252
+ packCount: packResult.packCount,
253
+ recordCount: packResult.recordCount,
254
+ durationMs: packResult.durationMs,
255
+ compressionRatio: packResult.compression.ratio
256
+ });
257
+ }
258
+ catch {
259
+ // Pack generation is best-effort. The JSON index remains the primary path.
260
+ emit('packs', 'skip', 'Pack rebuild failed; continuing with JSON index', {
261
+ reason: packReason
262
+ });
263
+ }
264
+ }
265
+ else {
266
+ emit('packs', 'skip', 'Pack rebuild not required', {
267
+ reason: packReason
268
+ });
269
+ }
270
+ const packsRebuilt = packResult != null;
271
+ const packResultReason = shouldRebuildPacks && !packsRebuilt ? `${packReason} (failed)` : packReason;
272
+ await writeIndexState(absoluteVaultPath, {
273
+ chunkSize: config.chunkSize,
274
+ embeddingProvider: config.embeddingProvider,
275
+ graphLinkModelVersion,
276
+ searchPackRowChunkSize: config.searchPack.rowChunkSize,
277
+ searchPackCompressionLevel: config.searchPack.compressionLevel,
278
+ searchPackUseDictionary: config.searchPack.useDictionary,
279
+ files: currentSnapshot,
280
+ pendingPackChanges: packsRebuilt ? 0 : pendingPackChanges
281
+ });
282
+ const result = {
283
+ ...toIndexResult(indexedDocuments),
284
+ elapsedMs: elapsedMs(),
285
+ changedDocumentCount: changedDocumentsByPath.size,
286
+ packs: {
287
+ rebuilt: packsRebuilt,
288
+ reason: packResultReason,
289
+ ...(packResult
290
+ ? {
291
+ packCount: packResult.packCount,
292
+ recordCount: packResult.recordCount,
293
+ durationMs: packResult.durationMs,
294
+ compression: packResult.compression
295
+ }
296
+ : {})
297
+ }
77
298
  };
299
+ emit('complete', 'finish', 'Indexing complete', {
300
+ documentCount: result.documentCount,
301
+ chunkCount: result.chunkCount,
302
+ linkCount: result.linkCount,
303
+ elapsedMs: result.elapsedMs
304
+ });
305
+ return result;
78
306
  }
79
307
  finally {
80
308
  index.close();
@@ -1,10 +1,10 @@
1
1
  import { ensureVault } from '../infrastructure/file-system-vault.js';
2
- import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
2
+ import { openFileIndex } from '../infrastructure/file-index.js';
3
3
  export const listAgents = async (vaultPath) => {
4
4
  const absoluteVaultPath = await ensureVault(vaultPath);
5
- const index = openSqliteIndex(absoluteVaultPath);
5
+ const index = openFileIndex(absoluteVaultPath);
6
6
  try {
7
- return index.listAgents();
7
+ return await index.listAgents();
8
8
  }
9
9
  finally {
10
10
  index.close();
@@ -1,10 +1,10 @@
1
1
  import { ensureVault } from '../infrastructure/file-system-vault.js';
2
- import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
2
+ import { openFileIndex } from '../infrastructure/file-index.js';
3
3
  export const listLinks = async (vaultPath, agentId) => {
4
4
  const absoluteVaultPath = await ensureVault(vaultPath);
5
- const index = openSqliteIndex(absoluteVaultPath);
5
+ const index = openFileIndex(absoluteVaultPath);
6
6
  try {
7
- return index.listLinks(agentId);
7
+ return await index.listLinks(agentId);
8
8
  }
9
9
  finally {
10
10
  index.close();
@@ -12,9 +12,9 @@ export const listLinks = async (vaultPath, agentId) => {
12
12
  };
13
13
  export const listBacklinks = async (vaultPath, title, agentId) => {
14
14
  const absoluteVaultPath = await ensureVault(vaultPath);
15
- const index = openSqliteIndex(absoluteVaultPath);
15
+ const index = openFileIndex(absoluteVaultPath);
16
16
  try {
17
- return index.listBacklinks(title, agentId);
17
+ return await index.listBacklinks(title, agentId);
18
18
  }
19
19
  finally {
20
20
  index.close();