@andespindola/brainlink 1.0.5 → 1.0.7

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 (61) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +46 -0
  3. package/dist/application/add-note.js +2 -2
  4. package/dist/application/build-context.js +16 -10
  5. package/dist/application/canonical-context-links.js +44 -5
  6. package/dist/application/check-package-update.js +105 -0
  7. package/dist/application/find-similar-notes.js +75 -0
  8. package/dist/application/frontend/client/chunk-fetch.js +236 -0
  9. package/dist/application/frontend/client/controls.js +178 -0
  10. package/dist/application/frontend/client/elements.js +122 -0
  11. package/dist/application/frontend/client/input.js +202 -0
  12. package/dist/application/frontend/client/node-details.js +191 -0
  13. package/dist/application/frontend/client/rendering.js +296 -0
  14. package/dist/application/frontend/client/scope-theme.js +114 -0
  15. package/dist/application/frontend/client/spatial.js +98 -0
  16. package/dist/application/frontend/client/storage.js +215 -0
  17. package/dist/application/frontend/client/upload.js +90 -0
  18. package/dist/application/frontend/client/worker-bootstrap.js +147 -0
  19. package/dist/application/frontend/client-js.js +24 -1837
  20. package/dist/application/frontend/client-render-worker-js.js +1 -1
  21. package/dist/application/index-vault-phases.js +190 -0
  22. package/dist/application/index-vault.js +46 -167
  23. package/dist/application/memory-suggestions.js +4 -1
  24. package/dist/application/search-knowledge.js +19 -3
  25. package/dist/cli/commands/write/dedupe-commands.js +59 -0
  26. package/dist/cli/commands/write/index-commands.js +205 -0
  27. package/dist/cli/commands/write/link-commands.js +68 -0
  28. package/dist/cli/commands/write/note-commands.js +146 -0
  29. package/dist/cli/commands/write/server-commands.js +553 -0
  30. package/dist/cli/commands/write/shared.js +35 -0
  31. package/dist/cli/commands/write/vault-lifecycle-commands.js +270 -0
  32. package/dist/cli/commands/write-commands.js +12 -1303
  33. package/dist/cli/main.js +39 -3
  34. package/dist/domain/context.js +60 -11
  35. package/dist/domain/diversity.js +64 -0
  36. package/dist/domain/embeddings.js +148 -6
  37. package/dist/domain/graph-contexts.js +62 -57
  38. package/dist/domain/graph-layout/cauliflower-layout.js +116 -0
  39. package/dist/domain/graph-layout/collisions.js +100 -0
  40. package/dist/domain/graph-layout/hierarchy.js +135 -0
  41. package/dist/domain/graph-layout/metrics.js +111 -0
  42. package/dist/domain/graph-layout/segments.js +76 -0
  43. package/dist/domain/graph-layout/star-layout.js +110 -0
  44. package/dist/domain/graph-layout.js +4 -625
  45. package/dist/domain/markdown.js +10 -1
  46. package/dist/domain/scoring.js +42 -0
  47. package/dist/domain/tokens.js +17 -1
  48. package/dist/infrastructure/config.js +33 -1
  49. package/dist/infrastructure/file-index.js +227 -60
  50. package/dist/infrastructure/semantic-prefilter.js +24 -0
  51. package/dist/mcp/server.js +23 -1
  52. package/dist/mcp/tool-guard.js +29 -0
  53. package/dist/mcp/tools/maintenance-tools.js +409 -0
  54. package/dist/mcp/tools/read-tools.js +537 -0
  55. package/dist/mcp/tools/shared.js +216 -0
  56. package/dist/mcp/tools/write-tools.js +353 -0
  57. package/dist/mcp/tools.js +3 -1357
  58. package/docs/AGENT_USAGE.md +4 -1
  59. package/docs/ARCHITECTURE.md +4 -3
  60. package/docs/QUICKSTART.md +4 -0
  61. package/package.json +2 -2
@@ -58,7 +58,7 @@ const defaultTheme = {
58
58
  ],
59
59
  edge: [0.23, 0.31, 0.42, 0.18],
60
60
  edgeHeavy: [0.23, 0.31, 0.42, 0.34],
61
- clear: [0.96, 0.97, 0.98, 1]
61
+ clear: [0.031, 0.075, 0.114, 1]
62
62
  }
63
63
 
64
64
  const theme = { ...defaultTheme }
@@ -0,0 +1,190 @@
1
+ import { createIndexedDocument, graphLinkModelVersion } from '../domain/markdown.js';
2
+ import { sharedAgentId } from '../domain/agents.js';
3
+ import { createEmbeddingBuckets, createEmbeddingProvider } from '../domain/embeddings.js';
4
+ import { embeddingSignature } from '../infrastructure/config.js';
5
+ import { buildSearchPacks, toSearchPackBuildOptions } from '../infrastructure/search-packs.js';
6
+ const toTitleKey = (title) => title.toLowerCase();
7
+ const appendTitleEntry = (map, document) => {
8
+ const key = toTitleKey(document.title);
9
+ if (!map.has(key)) {
10
+ map.set(key, {
11
+ id: document.id,
12
+ path: document.path
13
+ });
14
+ }
15
+ return map;
16
+ };
17
+ export const createTitleMaps = (documents) => [...documents]
18
+ .sort((left, right) => left.path.localeCompare(right.path))
19
+ .reduce((state, document) => {
20
+ const agentMap = state.byAgent.get(document.agentId) ?? new Map();
21
+ appendTitleEntry(agentMap, document);
22
+ state.byAgent.set(document.agentId, agentMap);
23
+ if (document.agentId === sharedAgentId) {
24
+ appendTitleEntry(state.shared, document);
25
+ }
26
+ return state;
27
+ }, {
28
+ shared: new Map(),
29
+ byAgent: new Map()
30
+ });
31
+ const createScopedTitleResolver = (document, titleMaps) => ({
32
+ get: (title) => titleMaps.byAgent.get(document.agentId)?.get(title)?.id ?? titleMaps.shared.get(title)?.id
33
+ });
34
+ const embedIndexedDocuments = async (documents, providerName, embeddingConfig) => {
35
+ if (documents.length === 0) {
36
+ return documents;
37
+ }
38
+ const provider = createEmbeddingProvider(providerName, embeddingConfig);
39
+ const chunks = documents.flatMap((document) => document.chunks);
40
+ const embeddings = await provider.embed(chunks.map((chunk) => chunk.content));
41
+ const embeddingByChunkId = new Map(chunks.map((chunk, index) => [chunk.id, embeddings[index] ?? []]));
42
+ return documents.map((indexedDocument) => ({
43
+ ...indexedDocument,
44
+ chunks: indexedDocument.chunks.map((chunk) => {
45
+ const embedding = embeddingByChunkId.get(chunk.id) ?? [];
46
+ return {
47
+ ...chunk,
48
+ embeddingProvider: provider.name,
49
+ embedding,
50
+ buckets: embedding.length > 0 ? createEmbeddingBuckets(embedding) : []
51
+ };
52
+ })
53
+ }));
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
+ export const detectChanges = (params) => {
68
+ const { summaries, previousState, config, fullReindex, existingByPath, previousSnapshotMap, currentSnapshotMap } = params;
69
+ const graphLinkModelChanged = previousState != null &&
70
+ previousState.graphLinkModelVersion !== graphLinkModelVersion;
71
+ const fullSourceReindex = fullReindex || graphLinkModelChanged;
72
+ const settingsChanged = previousState == null ||
73
+ previousState.chunkSize !== config.chunkSize ||
74
+ previousState.embeddingProvider !== embeddingSignature(config) ||
75
+ graphLinkModelChanged;
76
+ const packSettingsChanged = previousState == null ||
77
+ previousState.searchPackRowChunkSize !== config.searchPack.rowChunkSize ||
78
+ previousState.searchPackCompressionLevel !== config.searchPack.compressionLevel ||
79
+ previousState.searchPackUseDictionary !== config.searchPack.useDictionary;
80
+ const changedPaths = new Set();
81
+ for (let index = 0; index < summaries.length; index += 1) {
82
+ const summary = summaries[index];
83
+ const previous = previousSnapshotMap.get(summary.relativePath);
84
+ const changed = fullSourceReindex ||
85
+ settingsChanged ||
86
+ previous == null ||
87
+ previous.mtimeMs !== summary.updatedAt.getTime() ||
88
+ previous.size !== summary.size ||
89
+ !existingByPath.has(summary.relativePath);
90
+ if (changed) {
91
+ changedPaths.add(summary.relativePath);
92
+ }
93
+ }
94
+ const hasDeletes = previousState
95
+ ? previousState.files.some((entry) => !currentSnapshotMap.has(entry.path))
96
+ : false;
97
+ return { graphLinkModelChanged, settingsChanged, packSettingsChanged, changedPaths, hasDeletes };
98
+ };
99
+ export const decidePackRebuild = (params) => {
100
+ const { existingPackManifest, fullReindex, graphLinkModelChanged, manifestRepaired, settingsChanged, packSettingsChanged, hasDeletes, changedCount, changeRatio, pendingPackChanges } = params;
101
+ const shouldRebuild = !existingPackManifest ||
102
+ fullReindex ||
103
+ graphLinkModelChanged ||
104
+ settingsChanged ||
105
+ packSettingsChanged ||
106
+ hasDeletes ||
107
+ changedCount >= 400 ||
108
+ changeRatio >= 0.04 ||
109
+ pendingPackChanges >= 1200;
110
+ const reason = !existingPackManifest
111
+ ? 'Missing pack manifest'
112
+ : fullReindex
113
+ ? 'Full reindex requested'
114
+ : graphLinkModelChanged
115
+ ? 'Graph link model changed'
116
+ : manifestRepaired
117
+ ? 'Pack manifest repaired from existing packs'
118
+ : settingsChanged
119
+ ? 'Index settings changed'
120
+ : packSettingsChanged
121
+ ? 'Search pack settings changed'
122
+ : hasDeletes
123
+ ? 'Document deletions detected'
124
+ : changedCount >= 400
125
+ ? 'Changed file count threshold reached'
126
+ : changeRatio >= 0.04
127
+ ? 'Change ratio threshold reached'
128
+ : pendingPackChanges >= 1200
129
+ ? 'Pending pack changes threshold reached'
130
+ : 'Pack rebuild skipped';
131
+ return { shouldRebuild, reason };
132
+ };
133
+ export const embedChangedDocuments = async (params) => {
134
+ const { changedDocumentsByPath, titleMaps, config, emit } = params;
135
+ emit('embed', 'start', 'Embedding changed chunks', {
136
+ changedDocuments: changedDocumentsByPath.size
137
+ });
138
+ const changedIndexedDocuments = changedDocumentsByPath.size > 0
139
+ ? await embedIndexedDocuments(Array.from(changedDocumentsByPath.values()).map((document) => createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize)), config.embeddingProvider, config.embedding)
140
+ : [];
141
+ emit('embed', changedDocumentsByPath.size > 0 ? 'finish' : 'skip', changedDocumentsByPath.size > 0 ? 'Embedding complete' : 'Embedding skipped', {
142
+ changedIndexedDocuments: changedIndexedDocuments.length
143
+ });
144
+ return changedIndexedDocuments;
145
+ };
146
+ export const assembleIndexedDocuments = (params) => {
147
+ const { documents, changedIndexedDocuments, existingByPath, titleMaps, config, needsRelink } = params;
148
+ const changedIndexedByPath = new Map(changedIndexedDocuments.map((document) => [document.document.path, document]));
149
+ return documents.map((document) => {
150
+ const changed = changedIndexedByPath.get(document.path);
151
+ if (changed) {
152
+ return changed;
153
+ }
154
+ const existing = existingByPath.get(document.path);
155
+ if (!existing) {
156
+ return createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize);
157
+ }
158
+ return needsRelink ? relinkIndexedDocument(existing, titleMaps) : existing;
159
+ });
160
+ };
161
+ export const rebuildSearchPacksIfNeeded = async (params) => {
162
+ const { shouldRebuild, reason, absoluteVaultPath, indexedDocuments, config, emit } = params;
163
+ if (!shouldRebuild) {
164
+ emit('packs', 'skip', 'Pack rebuild not required', {
165
+ reason
166
+ });
167
+ return undefined;
168
+ }
169
+ emit('packs', 'start', 'Rebuilding compressed search packs', {
170
+ reason
171
+ });
172
+ try {
173
+ const packResult = await buildSearchPacks(absoluteVaultPath, indexedDocuments, toSearchPackBuildOptions(config));
174
+ emit('packs', 'finish', 'Compressed packs rebuilt', {
175
+ reason,
176
+ packCount: packResult.packCount,
177
+ recordCount: packResult.recordCount,
178
+ durationMs: packResult.durationMs,
179
+ compressionRatio: packResult.compression.ratio
180
+ });
181
+ return packResult;
182
+ }
183
+ catch {
184
+ // Pack generation is best-effort. The JSON index remains the primary path.
185
+ emit('packs', 'skip', 'Pack rebuild failed; continuing with JSON index', {
186
+ reason
187
+ });
188
+ return undefined;
189
+ }
190
+ };
@@ -1,69 +1,11 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { createIndexedDocument, graphLinkModelVersion, parseMarkdownDocument } from '../domain/markdown.js';
3
- import { sharedAgentId } from '../domain/agents.js';
4
- import { createEmbeddingProvider } from '../domain/embeddings.js';
5
- import { loadBrainlinkConfig } from '../infrastructure/config.js';
2
+ import { graphLinkModelVersion, parseMarkdownDocument } from '../domain/markdown.js';
3
+ import { embeddingSignature, loadBrainlinkConfig } from '../infrastructure/config.js';
6
4
  import { ensureVault, readMarkdownFileSummaries } from '../infrastructure/file-system-vault.js';
7
5
  import { readIndexState, writeIndexState } from '../infrastructure/index-state.js';
8
- import { buildSearchPacks, ensureSearchPackManifest, toSearchPackBuildOptions } from '../infrastructure/search-packs.js';
6
+ import { ensureSearchPackManifest } from '../infrastructure/search-packs.js';
9
7
  import { openFileIndex } from '../infrastructure/file-index.js';
10
- const toTitleKey = (title) => title.toLowerCase();
11
- const appendTitleEntry = (map, document) => {
12
- const key = toTitleKey(document.title);
13
- if (!map.has(key)) {
14
- map.set(key, {
15
- id: document.id,
16
- path: document.path
17
- });
18
- }
19
- return map;
20
- };
21
- const createTitleMaps = (documents) => [...documents]
22
- .sort((left, right) => left.path.localeCompare(right.path))
23
- .reduce((state, document) => {
24
- const agentMap = state.byAgent.get(document.agentId) ?? new Map();
25
- appendTitleEntry(agentMap, document);
26
- state.byAgent.set(document.agentId, agentMap);
27
- if (document.agentId === sharedAgentId) {
28
- appendTitleEntry(state.shared, document);
29
- }
30
- return state;
31
- }, {
32
- shared: new Map(),
33
- byAgent: new Map()
34
- });
35
- const createScopedTitleResolver = (document, titleMaps) => ({
36
- get: (title) => titleMaps.byAgent.get(document.agentId)?.get(title)?.id ?? titleMaps.shared.get(title)?.id
37
- });
38
- const embedIndexedDocuments = async (documents, providerName) => {
39
- if (documents.length === 0) {
40
- return documents;
41
- }
42
- const provider = createEmbeddingProvider(providerName);
43
- const chunks = documents.flatMap((document) => document.chunks);
44
- const embeddings = await provider.embed(chunks.map((chunk) => chunk.content));
45
- const embeddingByChunkId = new Map(chunks.map((chunk, index) => [chunk.id, embeddings[index] ?? []]));
46
- return documents.map((indexedDocument) => ({
47
- ...indexedDocument,
48
- chunks: indexedDocument.chunks.map((chunk) => ({
49
- ...chunk,
50
- embeddingProvider: provider.name,
51
- embedding: embeddingByChunkId.get(chunk.id) ?? []
52
- }))
53
- }));
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
- };
8
+ import { assembleIndexedDocuments, createTitleMaps, decidePackRebuild, detectChanges, embedChangedDocuments, rebuildSearchPacksIfNeeded } from './index-vault-phases.js';
67
9
  const toIndexResult = (documents) => ({
68
10
  documentCount: documents.length,
69
11
  chunkCount: documents.reduce((total, document) => total + document.chunks.length, 0),
@@ -121,34 +63,15 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
121
63
  const currentSnapshot = toSnapshot(summaries);
122
64
  const currentSnapshotMap = createSnapshotMap(currentSnapshot);
123
65
  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;
66
+ const { graphLinkModelChanged, settingsChanged, packSettingsChanged, changedPaths, hasDeletes } = detectChanges({
67
+ summaries,
68
+ previousState,
69
+ config,
70
+ fullReindex,
71
+ existingByPath,
72
+ previousSnapshotMap,
73
+ currentSnapshotMap
74
+ });
152
75
  const manifestRecovery = await ensureSearchPackManifest(absoluteVaultPath);
153
76
  if (changedPaths.size === 0 &&
154
77
  !hasDeletes &&
@@ -188,27 +111,20 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
188
111
  return existing ? [existing.document] : [];
189
112
  });
190
113
  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
114
+ const changedIndexedDocuments = await embedChangedDocuments({
115
+ changedDocumentsByPath,
116
+ titleMaps,
117
+ config,
118
+ emit
199
119
  });
200
- const changedIndexedByPath = new Map(changedIndexedDocuments.map((document) => [document.document.path, document]));
201
120
  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;
121
+ const indexedDocuments = assembleIndexedDocuments({
122
+ documents,
123
+ changedIndexedDocuments,
124
+ existingByPath,
125
+ titleMaps,
126
+ config,
127
+ needsRelink
212
128
  });
213
129
  emit('persist', 'start', 'Persisting index');
214
130
  await index.saveDocuments(indexedDocuments);
@@ -221,68 +137,31 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
221
137
  const changeRatio = changedCount / documentCount;
222
138
  const previousPendingPackChanges = previousState?.pendingPackChanges ?? 0;
223
139
  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
- }
275
- }
276
- else {
277
- emit('packs', 'skip', 'Pack rebuild not required', {
278
- reason: packReason
279
- });
280
- }
140
+ const { shouldRebuild: shouldRebuildPacks, reason: packReason } = decidePackRebuild({
141
+ existingPackManifest,
142
+ fullReindex,
143
+ graphLinkModelChanged,
144
+ manifestRepaired: manifestRecovery.repaired,
145
+ settingsChanged,
146
+ packSettingsChanged,
147
+ hasDeletes,
148
+ changedCount,
149
+ changeRatio,
150
+ pendingPackChanges
151
+ });
152
+ const packResult = await rebuildSearchPacksIfNeeded({
153
+ shouldRebuild: shouldRebuildPacks,
154
+ reason: packReason,
155
+ absoluteVaultPath,
156
+ indexedDocuments,
157
+ config,
158
+ emit
159
+ });
281
160
  const packsRebuilt = packResult != null;
282
161
  const packResultReason = shouldRebuildPacks && !packsRebuilt ? `${packReason} (failed)` : packReason;
283
162
  await writeIndexState(absoluteVaultPath, {
284
163
  chunkSize: config.chunkSize,
285
- embeddingProvider: config.embeddingProvider,
164
+ embeddingProvider: embeddingSignature(config),
286
165
  graphLinkModelVersion,
287
166
  searchPackRowChunkSize: config.searchPack.rowChunkSize,
288
167
  searchPackCompressionLevel: config.searchPack.compressionLevel,
@@ -127,7 +127,10 @@ export const suggestContextLinks = async (vaultPath, content, agentId, limit) =>
127
127
  for (const result of searchResults) {
128
128
  const key = result.title.toLowerCase();
129
129
  const current = byTitle.get(key);
130
- const score = Number(Math.min(1, Math.max(result.score / 20, result.semanticScore)).toFixed(4));
130
+ // Hybrid search already returns a min-max-normalized [0,1] score, so it is
131
+ // compared directly against the cosine semanticScore (also [0,1]) instead of
132
+ // being rescaled from the previous unbounded blend.
133
+ const score = Number(Math.min(1, Math.max(result.score, result.semanticScore)).toFixed(4));
131
134
  if (!current || score > current.score) {
132
135
  byTitle.set(key, {
133
136
  title: result.title,
@@ -4,6 +4,16 @@ import { ensurePrivatePacksFromLegacyIndex, searchInPacks } from '../infrastruct
4
4
  import { indexStoragePath, openFileIndex } from '../infrastructure/file-index.js';
5
5
  import { createEmbeddingProvider } from '../domain/embeddings.js';
6
6
  import { loadBrainlinkConfig, sanitizeSearchMode } from '../infrastructure/config.js';
7
+ const embedQuerySafely = async (provider, query) => {
8
+ try {
9
+ return (await provider.embed([query]))[0] ?? [];
10
+ }
11
+ catch (error) {
12
+ const reason = error instanceof Error ? error.message : String(error);
13
+ process.stderr.write(`brainlink: query embedding via ${provider.name} failed, using lexical search (${reason})\n`);
14
+ return [];
15
+ }
16
+ };
7
17
  const hybridCacheTtlMs = 30_000;
8
18
  const hybridCacheMaxEntries = 200;
9
19
  const hybridSearchCache = new Map();
@@ -53,13 +63,19 @@ export const searchKnowledge = async (vaultPath, query, limit, agentId, mode) =>
53
63
  if (cached) {
54
64
  return cached;
55
65
  }
56
- const provider = createEmbeddingProvider(config.embeddingProvider);
66
+ const provider = createEmbeddingProvider(config.embeddingProvider, config.embedding);
57
67
  const shouldEmbedQuery = searchMode !== 'fts' && provider.name !== 'none';
58
- const queryEmbedding = shouldEmbedQuery ? (await provider.embed([query]))[0] ?? [] : [];
68
+ // An external provider can be unreachable at query time. Degrade to
69
+ // lexical-only retrieval rather than substituting a local-space vector that
70
+ // would not match the stored embeddings, keeping search available.
71
+ const queryEmbedding = shouldEmbedQuery ? await embedQuerySafely(provider, query) : [];
72
+ // Pure semantic mode with no usable embedding would return nothing; fall back
73
+ // to lexical (fts) so a transient provider outage still yields results.
74
+ const effectiveMode = searchMode === 'semantic' && shouldEmbedQuery && queryEmbedding.length === 0 ? 'fts' : searchMode;
59
75
  try {
60
76
  const index = openFileIndex(absoluteVaultPath);
61
77
  try {
62
- const results = await index.search(query, limit, agentId, searchMode, queryEmbedding);
78
+ const results = await index.search(query, limit, agentId, effectiveMode, queryEmbedding);
63
79
  if (cacheKey) {
64
80
  cacheSet({
65
81
  key: cacheKey,
@@ -0,0 +1,59 @@
1
+ import { resolveDuplicateNotes, scanDuplicateNotes } from '../../../application/dedupe-notes.js';
2
+ import { parsePositiveInteger, print, resolveOptions } from '../../runtime.js';
3
+ import { parseScore } from './shared.js';
4
+ export const registerDedupeCommands = (program) => {
5
+ program
6
+ .command('dedupe')
7
+ .option('-v, --vault <vault>', 'vault directory')
8
+ .option('-a, --agent <agent>', 'agent memory namespace')
9
+ .option('-l, --limit <limit>', 'maximum duplicate candidate pairs')
10
+ .option('--min-score <score>', 'minimum semantic similarity score between 0 and 1', '0.92')
11
+ .option('--no-semantic', 'disable semantic duplicate detection and keep exact-content matching only')
12
+ .option('--json', 'print machine-readable JSON')
13
+ .description('detect possible duplicate notes with exact hash and semantic similarity scores')
14
+ .action(async (options) => {
15
+ const resolved = await resolveOptions(options);
16
+ const duplicates = await scanDuplicateNotes(resolved.vault, {
17
+ agentId: resolved.agent,
18
+ limit: parsePositiveInteger(options.limit ?? '25', 25),
19
+ minSemanticScore: parseScore(options.minScore, 0.92),
20
+ includeSemantic: options.semantic !== false
21
+ });
22
+ print(options.json, { vault: resolved.vault, agent: resolved.agent, duplicates }, () => {
23
+ if (duplicates.length === 0) {
24
+ return 'No possible duplicates found.';
25
+ }
26
+ return duplicates
27
+ .map((item, index) => `${index + 1}. [${item.kind}] score=${item.score.toFixed(4)} ${item.left.path} <-> ${item.right.path} (${item.reason})`)
28
+ .join('\n');
29
+ });
30
+ });
31
+ program
32
+ .command('dedupe-resolve')
33
+ .option('-v, --vault <vault>', 'vault directory')
34
+ .option('--left <path>', 'left note relative path from dedupe result')
35
+ .option('--right <path>', 'right note relative path from dedupe result')
36
+ .option('--action <action>', 'resolution action: merge, link or ignore')
37
+ .option('--no-auto-index', 'skip reindex after duplicate resolution')
38
+ .option('--json', 'print machine-readable JSON')
39
+ .description('resolve a duplicate candidate with merge, link or ignore')
40
+ .action(async (options) => {
41
+ const resolved = await resolveOptions(options);
42
+ if (!options.left || !options.right) {
43
+ throw new Error('Use --left <path> and --right <path> to resolve a duplicate pair.');
44
+ }
45
+ if (options.action !== 'merge' && options.action !== 'link' && options.action !== 'ignore') {
46
+ throw new Error('Use --action merge|link|ignore.');
47
+ }
48
+ const result = await resolveDuplicateNotes(resolved.vault, {
49
+ leftPath: options.left,
50
+ rightPath: options.right,
51
+ action: options.action,
52
+ autoIndex: options.autoIndex !== false
53
+ });
54
+ print(options.json, {
55
+ vault: resolved.vault,
56
+ ...result
57
+ }, () => `Resolved duplicate (${result.action}) for ${result.leftPath} <-> ${result.rightPath}`);
58
+ });
59
+ };