@andespindola/brainlink 0.1.0-beta.16 → 0.1.0-beta.161

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 (50) hide show
  1. package/AGENTS.md +9 -6
  2. package/CHANGELOG.md +27 -0
  3. package/COPYRIGHT.md +5 -0
  4. package/README.md +177 -20
  5. package/dist/application/add-note.js +13 -44
  6. package/dist/application/auto-migrate-configured-vault.js +37 -0
  7. package/dist/application/build-context.js +64 -3
  8. package/dist/application/canonical-context-links.js +209 -0
  9. package/dist/application/dedupe-notes.js +226 -0
  10. package/dist/application/frontend/client-css.js +258 -51
  11. package/dist/application/frontend/client-html.js +50 -27
  12. package/dist/application/frontend/client-js.js +1369 -605
  13. package/dist/application/frontend/client-render-worker-js.js +645 -0
  14. package/dist/application/frontend/client-worker-js.js +66 -0
  15. package/dist/application/get-graph-contexts.js +33 -0
  16. package/dist/application/get-graph-layout.js +62 -8
  17. package/dist/application/get-graph-stream-chunk.js +326 -0
  18. package/dist/application/get-graph-view.js +246 -0
  19. package/dist/application/graph-view-state.js +66 -0
  20. package/dist/application/import-legacy-sqlite.js +266 -0
  21. package/dist/application/index-vault.js +262 -23
  22. package/dist/application/migrate-context-links.js +79 -0
  23. package/dist/application/offline-pack-backup.js +44 -0
  24. package/dist/application/search-graph-node-ids.js +63 -3
  25. package/dist/application/server/routes.js +247 -7
  26. package/dist/application/start-server.js +75 -4
  27. package/dist/application/watch-vault.js +23 -2
  28. package/dist/cli/commands/agent-commands.js +7 -0
  29. package/dist/cli/commands/write-commands.js +924 -14
  30. package/dist/cli/runtime.js +10 -2
  31. package/dist/domain/context.js +54 -11
  32. package/dist/domain/graph-contexts.js +180 -0
  33. package/dist/domain/graph-layout.js +389 -18
  34. package/dist/domain/markdown.js +53 -9
  35. package/dist/domain/middle-out.js +18 -0
  36. package/dist/infrastructure/config.js +121 -4
  37. package/dist/infrastructure/file-index.js +76 -6
  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 +286 -15
  42. package/dist/infrastructure/vault-migration-state.js +69 -0
  43. package/dist/infrastructure/volatile-memory.js +100 -0
  44. package/dist/mcp/runtime.js +20 -0
  45. package/dist/mcp/server.js +39 -11
  46. package/dist/mcp/tools.js +183 -7
  47. package/docs/AGENT_USAGE.md +96 -5
  48. package/docs/ARCHITECTURE.md +8 -0
  49. package/docs/QUICKSTART.md +7 -0
  50. package/package.json +7 -2
@@ -1,9 +1,11 @@
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';
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';
7
9
  import { openFileIndex } from '../infrastructure/file-index.js';
8
10
  const toTitleKey = (title) => title.toLowerCase();
9
11
  const appendTitleEntry = (map, document) => {
@@ -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,268 @@ const embedIndexedDocuments = async (documents, providerName) => {
47
52
  }))
48
53
  }));
49
54
  };
50
- export const indexVault = async (vaultPath) => {
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
+ };
88
+ export const indexVault = async (vaultPath, options = {}) => {
89
+ return indexVaultWithOptions(vaultPath, options);
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);
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 fullReindex = options.full === true;
63
117
  const index = openFileIndex(absoluteVaultPath);
64
118
  try {
65
- await index.reset();
119
+ const existingIndexedDocuments = await index.getIndexedDocuments();
120
+ const existingByPath = new Map(existingIndexedDocuments.map((document) => [document.document.path, document]));
121
+ const currentSnapshot = toSnapshot(summaries);
122
+ const currentSnapshotMap = createSnapshotMap(currentSnapshot);
123
+ const previousSnapshotMap = createSnapshotMap(previousState?.files ?? []);
124
+ const graphLinkModelChanged = previousState != null &&
125
+ previousState.graphLinkModelVersion !== graphLinkModelVersion;
126
+ const fullSourceReindex = fullReindex || graphLinkModelChanged;
127
+ const settingsChanged = previousState == null ||
128
+ previousState.chunkSize !== config.chunkSize ||
129
+ previousState.embeddingProvider !== config.embeddingProvider ||
130
+ graphLinkModelChanged;
131
+ const packSettingsChanged = previousState == null ||
132
+ previousState.searchPackRowChunkSize !== config.searchPack.rowChunkSize ||
133
+ previousState.searchPackCompressionLevel !== config.searchPack.compressionLevel ||
134
+ previousState.searchPackUseDictionary !== config.searchPack.useDictionary;
135
+ const changedPaths = new Set();
136
+ for (let index = 0; index < summaries.length; index += 1) {
137
+ const summary = summaries[index];
138
+ const previous = previousSnapshotMap.get(summary.relativePath);
139
+ const changed = fullSourceReindex ||
140
+ settingsChanged ||
141
+ previous == null ||
142
+ previous.mtimeMs !== summary.updatedAt.getTime() ||
143
+ previous.size !== summary.size ||
144
+ !existingByPath.has(summary.relativePath);
145
+ if (changed) {
146
+ changedPaths.add(summary.relativePath);
147
+ }
148
+ }
149
+ const hasDeletes = previousState
150
+ ? previousState.files.some((entry) => !currentSnapshotMap.has(entry.path))
151
+ : false;
152
+ const manifestRecovery = await ensureSearchPackManifest(absoluteVaultPath);
153
+ if (changedPaths.size === 0 &&
154
+ !hasDeletes &&
155
+ existingIndexedDocuments.length === summaries.length &&
156
+ previousState != null &&
157
+ !fullReindex) {
158
+ const result = {
159
+ ...toIndexResult(existingIndexedDocuments),
160
+ elapsedMs: elapsedMs(),
161
+ changedDocumentCount: 0,
162
+ packs: {
163
+ rebuilt: false,
164
+ reason: manifestRecovery.repaired ? 'No changes detected; pack manifest repaired' : 'No changes detected'
165
+ }
166
+ };
167
+ emit('complete', 'skip', 'Index skipped: no changes detected', {
168
+ elapsedMs: result.elapsedMs,
169
+ manifestRepaired: manifestRecovery.repaired,
170
+ manifestRecoverySource: manifestRecovery.source
171
+ });
172
+ return result;
173
+ }
174
+ const changedSummaries = summaries.filter((summary) => changedPaths.has(summary.relativePath));
175
+ emit('parse', 'start', 'Parsing changed markdown files', {
176
+ changedFiles: changedSummaries.length
177
+ });
178
+ const changedDocumentsByPath = await readChangedDocuments(absoluteVaultPath, changedSummaries);
179
+ emit('parse', 'finish', 'Parse complete', {
180
+ changedDocuments: changedDocumentsByPath.size
181
+ });
182
+ const documents = summaries.flatMap((summary) => {
183
+ const changed = changedDocumentsByPath.get(summary.relativePath);
184
+ if (changed) {
185
+ return [changed];
186
+ }
187
+ const existing = existingByPath.get(summary.relativePath);
188
+ return existing ? [existing.document] : [];
189
+ });
190
+ const titleMaps = createTitleMaps(documents);
191
+ emit('embed', 'start', 'Embedding changed chunks', {
192
+ changedDocuments: changedDocumentsByPath.size
193
+ });
194
+ const changedIndexedDocuments = changedDocumentsByPath.size > 0
195
+ ? await embedIndexedDocuments(Array.from(changedDocumentsByPath.values()).map((document) => createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize)), config.embeddingProvider)
196
+ : [];
197
+ emit('embed', changedDocumentsByPath.size > 0 ? 'finish' : 'skip', changedDocumentsByPath.size > 0 ? 'Embedding complete' : 'Embedding skipped', {
198
+ changedIndexedDocuments: changedIndexedDocuments.length
199
+ });
200
+ const changedIndexedByPath = new Map(changedIndexedDocuments.map((document) => [document.document.path, document]));
201
+ const needsRelink = settingsChanged || hasDeletes || changedPaths.size > 0;
202
+ const indexedDocuments = documents.map((document) => {
203
+ const changed = changedIndexedByPath.get(document.path);
204
+ if (changed) {
205
+ return changed;
206
+ }
207
+ const existing = existingByPath.get(document.path);
208
+ if (!existing) {
209
+ return createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize);
210
+ }
211
+ return needsRelink ? relinkIndexedDocument(existing, titleMaps) : existing;
212
+ });
213
+ emit('persist', 'start', 'Persisting index');
66
214
  await index.saveDocuments(indexedDocuments);
67
- try {
68
- await buildSearchPacks(absoluteVaultPath, indexedDocuments);
215
+ emit('persist', 'finish', 'Index persisted', {
216
+ indexedDocuments: indexedDocuments.length
217
+ });
218
+ const existingPackManifest = manifestRecovery.repaired || manifestRecovery.source === 'not-needed';
219
+ const changedCount = changedPaths.size;
220
+ const documentCount = Math.max(indexedDocuments.length, 1);
221
+ const changeRatio = changedCount / documentCount;
222
+ const previousPendingPackChanges = previousState?.pendingPackChanges ?? 0;
223
+ const pendingPackChanges = previousPendingPackChanges + changedCount;
224
+ const shouldRebuildPacks = !existingPackManifest ||
225
+ fullReindex ||
226
+ graphLinkModelChanged ||
227
+ settingsChanged ||
228
+ packSettingsChanged ||
229
+ hasDeletes ||
230
+ changedCount >= 400 ||
231
+ changeRatio >= 0.04 ||
232
+ pendingPackChanges >= 1200;
233
+ let packResult;
234
+ const packReason = !existingPackManifest
235
+ ? 'Missing pack manifest'
236
+ : fullReindex
237
+ ? 'Full reindex requested'
238
+ : graphLinkModelChanged
239
+ ? 'Graph link model changed'
240
+ : manifestRecovery.repaired
241
+ ? 'Pack manifest repaired from existing packs'
242
+ : settingsChanged
243
+ ? 'Index settings changed'
244
+ : packSettingsChanged
245
+ ? 'Search pack settings changed'
246
+ : hasDeletes
247
+ ? 'Document deletions detected'
248
+ : changedCount >= 400
249
+ ? 'Changed file count threshold reached'
250
+ : changeRatio >= 0.04
251
+ ? 'Change ratio threshold reached'
252
+ : pendingPackChanges >= 1200
253
+ ? 'Pending pack changes threshold reached'
254
+ : 'Pack rebuild skipped';
255
+ if (shouldRebuildPacks) {
256
+ emit('packs', 'start', 'Rebuilding compressed search packs', {
257
+ reason: packReason
258
+ });
259
+ try {
260
+ packResult = await buildSearchPacks(absoluteVaultPath, indexedDocuments, toSearchPackBuildOptions(config));
261
+ emit('packs', 'finish', 'Compressed packs rebuilt', {
262
+ reason: packReason,
263
+ packCount: packResult.packCount,
264
+ recordCount: packResult.recordCount,
265
+ durationMs: packResult.durationMs,
266
+ compressionRatio: packResult.compression.ratio
267
+ });
268
+ }
269
+ catch {
270
+ // Pack generation is best-effort. The JSON index remains the primary path.
271
+ emit('packs', 'skip', 'Pack rebuild failed; continuing with JSON index', {
272
+ reason: packReason
273
+ });
274
+ }
69
275
  }
70
- catch {
71
- // Pack generation is best-effort. The JSON index remains the primary path.
276
+ else {
277
+ emit('packs', 'skip', 'Pack rebuild not required', {
278
+ reason: packReason
279
+ });
72
280
  }
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)
281
+ const packsRebuilt = packResult != null;
282
+ const packResultReason = shouldRebuildPacks && !packsRebuilt ? `${packReason} (failed)` : packReason;
283
+ await writeIndexState(absoluteVaultPath, {
284
+ chunkSize: config.chunkSize,
285
+ embeddingProvider: config.embeddingProvider,
286
+ graphLinkModelVersion,
287
+ searchPackRowChunkSize: config.searchPack.rowChunkSize,
288
+ searchPackCompressionLevel: config.searchPack.compressionLevel,
289
+ searchPackUseDictionary: config.searchPack.useDictionary,
290
+ files: currentSnapshot,
291
+ pendingPackChanges: packsRebuilt ? 0 : pendingPackChanges
292
+ });
293
+ const result = {
294
+ ...toIndexResult(indexedDocuments),
295
+ elapsedMs: elapsedMs(),
296
+ changedDocumentCount: changedDocumentsByPath.size,
297
+ packs: {
298
+ rebuilt: packsRebuilt,
299
+ reason: packResultReason,
300
+ ...(packResult
301
+ ? {
302
+ packCount: packResult.packCount,
303
+ recordCount: packResult.recordCount,
304
+ durationMs: packResult.durationMs,
305
+ compression: packResult.compression
306
+ }
307
+ : {})
308
+ }
77
309
  };
310
+ emit('complete', 'finish', 'Indexing complete', {
311
+ documentCount: result.documentCount,
312
+ chunkCount: result.chunkCount,
313
+ linkCount: result.linkCount,
314
+ elapsedMs: result.elapsedMs
315
+ });
316
+ return result;
78
317
  }
79
318
  finally {
80
319
  index.close();
@@ -0,0 +1,79 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { ensureVault, readMarkdownFileSummaries, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
3
+ import { extractContextLinkWeights, extractWikiLinkWeights, hasContextLinksSection, parseMarkdownDocument } from '../domain/markdown.js';
4
+ const defaultContextLinkLimit = 5;
5
+ const normalizeTitle = (title) => title.trim().replace(/\.md$/i, '').toLowerCase();
6
+ const formatPriority = (priority) => priority === 'normal' ? '' : ` priority: ${priority}`;
7
+ const formatContextLinksSection = (links) => [
8
+ '## Context Links',
9
+ '',
10
+ ...links.map((link) => `- [[${link.title}]]${formatPriority(link.priority)}`)
11
+ ].join('\n');
12
+ const appendContextLinksSection = (content, links) => `${content.trimEnd()}\n\n${formatContextLinksSection(links)}\n`;
13
+ const selectContextLinkCandidates = (content, title, limit) => extractWikiLinkWeights(content)
14
+ .filter((link) => normalizeTitle(link.title) !== normalizeTitle(title))
15
+ .slice(0, limit)
16
+ .map((link) => ({
17
+ title: link.title,
18
+ priority: link.priority,
19
+ weight: link.weight
20
+ }));
21
+ export const migrateContextLinks = async (vaultPath, options = {}) => {
22
+ const absoluteVaultPath = await ensureVault(vaultPath);
23
+ const limit = Math.max(1, Math.floor(options.limit ?? defaultContextLinkLimit));
24
+ const summaries = await readMarkdownFileSummaries(absoluteVaultPath);
25
+ const entries = [];
26
+ for (const summary of summaries) {
27
+ const content = await readFile(summary.absolutePath, 'utf8');
28
+ const document = parseMarkdownDocument({
29
+ absolutePath: summary.absolutePath,
30
+ vaultPath: absoluteVaultPath,
31
+ content,
32
+ createdAt: summary.createdAt,
33
+ updatedAt: summary.updatedAt
34
+ });
35
+ if (options.agentId && document.agentId !== options.agentId) {
36
+ continue;
37
+ }
38
+ if (hasContextLinksSection(content)) {
39
+ entries.push({
40
+ path: summary.relativePath,
41
+ title: document.title,
42
+ changed: false,
43
+ reason: 'already-has-context-links',
44
+ links: extractContextLinkWeights(content)
45
+ });
46
+ continue;
47
+ }
48
+ const links = selectContextLinkCandidates(content, document.title, limit);
49
+ if (links.length === 0) {
50
+ entries.push({
51
+ path: summary.relativePath,
52
+ title: document.title,
53
+ changed: false,
54
+ reason: 'no-link-candidates',
55
+ links
56
+ });
57
+ continue;
58
+ }
59
+ if (!options.dryRun) {
60
+ await writeMarkdownFile(vaultPath, summary.relativePath, appendContextLinksSection(content, links));
61
+ }
62
+ entries.push({
63
+ path: summary.relativePath,
64
+ title: document.title,
65
+ changed: true,
66
+ reason: 'added-context-links',
67
+ links
68
+ });
69
+ }
70
+ const changed = entries.filter((entry) => entry.changed).length;
71
+ return {
72
+ dryRun: options.dryRun === true,
73
+ scanned: entries.length,
74
+ changed,
75
+ skipped: entries.length - changed,
76
+ limit,
77
+ entries
78
+ };
79
+ };
@@ -0,0 +1,44 @@
1
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { gzipSync } from 'node:zlib';
4
+ import { ensureVault } from '../infrastructure/file-system-vault.js';
5
+ const packsDirectory = (vaultPath) => join(vaultPath, '.brainlink', 'search-packs');
6
+ const toSortedBackupFiles = async (vaultPath) => {
7
+ const directory = packsDirectory(vaultPath);
8
+ const names = await readdir(directory);
9
+ return names
10
+ .filter((name) => name.endsWith('.blpk') || name === 'manifest.json')
11
+ .sort((left, right) => left.localeCompare(right));
12
+ };
13
+ export const createOfflinePackBackup = async (input) => {
14
+ const vaultPath = await ensureVault(input.vaultPath);
15
+ const fileNames = await toSortedBackupFiles(vaultPath);
16
+ const files = [];
17
+ let inputBytes = 0;
18
+ for (const name of fileNames) {
19
+ const content = await readFile(join(packsDirectory(vaultPath), name));
20
+ inputBytes += content.byteLength;
21
+ files.push({
22
+ name,
23
+ contentB64: content.toString('base64')
24
+ });
25
+ }
26
+ const envelope = {
27
+ version: 1,
28
+ createdAt: new Date().toISOString(),
29
+ files
30
+ };
31
+ const serialized = Buffer.from(JSON.stringify(envelope), 'utf8');
32
+ const compressed = gzipSync(serialized, { level: 9 });
33
+ await mkdir(dirname(input.outputPath), { recursive: true });
34
+ await writeFile(input.outputPath, compressed);
35
+ const safeInput = Math.max(inputBytes, 1);
36
+ return {
37
+ outputPath: input.outputPath,
38
+ fileCount: files.length,
39
+ inputBytes,
40
+ outputBytes: compressed.byteLength,
41
+ ratio: compressed.byteLength / safeInput,
42
+ savedBytes: Math.max(inputBytes - compressed.byteLength, 0)
43
+ };
44
+ };
@@ -1,10 +1,70 @@
1
+ import { stat } from 'node:fs/promises';
1
2
  import { ensureVault } from '../infrastructure/file-system-vault.js';
2
- import { openFileIndex } from '../infrastructure/file-index.js';
3
- export const searchGraphNodeIds = async (vaultPath, query, limit, agentId) => {
3
+ import { indexStoragePath, openFileIndex } from '../infrastructure/file-index.js';
4
+ import { getGraphLayout } from './get-graph-layout.js';
5
+ const graphSearchCacheTtlMs = 20_000;
6
+ const graphSearchCacheMaxEntries = 120;
7
+ const graphSearchCache = new Map();
8
+ const readIndexSignature = async (vaultPath) => {
9
+ try {
10
+ const info = await stat(indexStoragePath(vaultPath));
11
+ return `${Math.floor(info.mtimeMs)}:${info.size}`;
12
+ }
13
+ catch {
14
+ return '0:0';
15
+ }
16
+ };
17
+ const cacheKey = (vaultPath, query, limit, agentId, context) => JSON.stringify({
18
+ vaultPath,
19
+ query: query.trim().toLowerCase(),
20
+ limit,
21
+ agentId: agentId?.trim().toLowerCase() ?? '*',
22
+ context: context?.trim().toLowerCase() ?? '*'
23
+ });
24
+ const readCached = (key, indexSignature) => {
25
+ const entry = graphSearchCache.get(key);
26
+ if (!entry) {
27
+ return undefined;
28
+ }
29
+ const fresh = Date.now() - entry.createdAt <= graphSearchCacheTtlMs && entry.indexSignature === indexSignature;
30
+ if (!fresh) {
31
+ graphSearchCache.delete(key);
32
+ return undefined;
33
+ }
34
+ return entry.nodeIds;
35
+ };
36
+ const writeCached = (key, entry) => {
37
+ graphSearchCache.set(key, entry);
38
+ if (graphSearchCache.size <= graphSearchCacheMaxEntries) {
39
+ return;
40
+ }
41
+ const overflow = graphSearchCache.size - graphSearchCacheMaxEntries;
42
+ Array.from(graphSearchCache.keys()).slice(0, overflow).forEach((cacheKey) => graphSearchCache.delete(cacheKey));
43
+ };
44
+ export const searchGraphNodeIds = async (vaultPath, query, limit, agentId, context) => {
4
45
  const absoluteVaultPath = await ensureVault(vaultPath);
46
+ const indexSignature = await readIndexSignature(absoluteVaultPath);
47
+ const key = cacheKey(absoluteVaultPath, query, limit, agentId, context);
48
+ const cached = readCached(key, indexSignature);
49
+ if (cached) {
50
+ return cached;
51
+ }
52
+ const contextNodeIds = context
53
+ ? new Set((await getGraphLayout(absoluteVaultPath, { agentId, context })).layout.nodes.map((node) => node.id))
54
+ : new Set();
5
55
  const index = openFileIndex(absoluteVaultPath);
6
56
  try {
7
- return await index.searchGraphNodeIds(query, limit, agentId);
57
+ const searchLimit = context ? Math.max(limit, 5000) : limit;
58
+ const foundNodeIds = await index.searchGraphNodeIds(query, searchLimit, agentId);
59
+ const nodeIds = context
60
+ ? foundNodeIds.filter((nodeId) => contextNodeIds.has(nodeId)).slice(0, limit)
61
+ : foundNodeIds;
62
+ writeCached(key, {
63
+ createdAt: Date.now(),
64
+ indexSignature,
65
+ nodeIds
66
+ });
67
+ return nodeIds;
8
68
  }
9
69
  finally {
10
70
  index.close();