@andespindola/brainlink 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +73 -0
  3. package/dist/application/find-similar-notes.js +75 -0
  4. package/dist/application/get-graph-node.js +2 -2
  5. package/dist/application/get-graph-summary.js +2 -2
  6. package/dist/application/get-graph.js +2 -2
  7. package/dist/application/index-vault-phases.js +5 -4
  8. package/dist/application/index-vault.js +4 -4
  9. package/dist/application/list-agents.js +2 -2
  10. package/dist/application/list-links.js +3 -3
  11. package/dist/application/memory-suggestions.js +4 -1
  12. package/dist/application/ports/knowledge-store.js +1 -0
  13. package/dist/application/ranking/run-search.js +177 -0
  14. package/dist/application/search-graph-node-ids.js +3 -2
  15. package/dist/application/search-knowledge.js +22 -5
  16. package/dist/application/vault-git.js +180 -0
  17. package/dist/application/vault-snapshot.js +87 -0
  18. package/dist/cli/commands/vault-sync-commands.js +115 -0
  19. package/dist/cli/main.js +2 -0
  20. package/dist/domain/context.js +27 -14
  21. package/dist/domain/diversity.js +64 -0
  22. package/dist/domain/embeddings.js +117 -1
  23. package/dist/domain/markdown.js +10 -1
  24. package/dist/domain/scoring.js +42 -0
  25. package/dist/domain/tokens.js +17 -1
  26. package/dist/infrastructure/config.js +31 -1
  27. package/dist/infrastructure/file-index.js +99 -328
  28. package/dist/infrastructure/knowledge-store/binary-store.js +163 -0
  29. package/dist/infrastructure/knowledge-store/index.js +36 -0
  30. package/dist/infrastructure/knowledge-store/stored-index.js +216 -0
  31. package/dist/mcp/server.js +16 -1
  32. package/dist/mcp/tools/read-tools.js +33 -0
  33. package/dist/mcp/tools/write-tools.js +106 -0
  34. package/dist/mcp/tools.js +2 -2
  35. package/docs/AGENT_USAGE.md +4 -1
  36. package/docs/ARCHITECTURE.md +4 -3
  37. package/package.json +3 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Added an optional **binary storage backend** (`storageBackend: "binary"`) that stores chunk embeddings as dense little-endian Float32 (`vectors.f32`) and document/chunk/link metadata as Brotli-compressed JSON, cutting the on-disk index ~75% on a typical vault with much cheaper loads than parsing decimal-text vectors. The graph/search/link reads now run through a shared query core so the JSON and binary backends are behavior-identical for the same candidates. Switching to `binary` converts an existing `index.json` in place on first use (no re-embedding) and falls back to it for reads until the binary store is materialized. The canonical Markdown is unaffected and the index stays fully regenerable.
6
+ - Introduced a `KnowledgeStore` port and a shared, backend-agnostic ranker, so the index storage layout can evolve without touching the application layer and every backend produces an identical ranking.
7
+ - Added **vault versioning** for the canonical Markdown. Git: `vault-init-git`, `vault-commit`, `vault-status`, `vault-history`, `vault-push`, `vault-pull`, `vault-restore`, `vault-clone` (driven through the system `git` binary; the derived `.brainlink/` is git-ignored). Portable snapshots: `vault-export`/`vault-import` produce and restore a self-contained gzip `.blvault` with per-file SHA-256. Every operation that changes Markdown reindexes automatically so the vault is immediately usable, merge conflicts abort cleanly instead of leaving markers in Markdown, conflicting local edits are preserved as `*.conflict-<ts>.md`, and remote URLs with inline credentials are rejected.
8
+ - Pinned the transitive `fast-uri` dependency to `>=4.1.0` to clear a high-severity Snyk advisory affecting the entire 3.x line.
9
+
10
+ - Added `brainlink_similar_notes` MCP tool: find the notes most similar to a given note (selected by title or path), using the note's own content as the retrieval query and excluding the note itself.
11
+ - Added batch write tools `brainlink_add_notes` and `brainlink_delete_notes` that write or delete up to 100 notes in a single call and reindex once for the whole batch; batch deletion reports per-note failures without aborting the batch.
12
+
13
+ - Made chunking heading-aware: a Markdown heading starts a new chunk once the current chunk already holds substantial content, so large notes split on topical (heading) boundaries instead of mid-section, while small notes and trailing metadata sections (e.g. `## Context Links`) stay in a single chunk. The new boundaries take effect on the next full reindex (`index --full`); existing chunks are reused until then.
14
+ - Added maximal-marginal-relevance ordering to context assembly so the token budget is not spent on several near-duplicate notes: each selected document balances its own relevance against how similar it is to documents already chosen. The strongest document still leads.
15
+
16
+ - Hardened index reliability: a corrupt `index.json` is now detected, logged and quarantined to `index.json.corrupt` (so the next `index --full` rebuilds it) instead of being silently read as an empty index. Search treats corruption as a signal to fall back to the independent compressed search packs rather than returning no results.
17
+
18
+ - Added pluggable external embedding providers (`ollama`, `openai`) alongside the default deterministic `local` provider, configured through a new optional `embedding` block (`url`, `model`, `apiKeyEnv`, `timeoutMs`, `batchSize`). External vectors are L2-normalized to match the local cosine/bucket contract, and the API key is referenced by environment variable name, never stored in config.
19
+ - Switching the embedding provider or external model now rebuilds vectors automatically (the embedding model is folded into the index reindex signature). If an external provider is unreachable, query-time search degrades to lexical-only while index-time surfaces a clear error instead of mixing embedding spaces.
20
+
21
+ - Replaced the naive additive lexical score with field-weighted BM25 ranking (IDF, term-frequency saturation and chunk-length normalization) so rare query terms outweigh common ones and long chunks no longer dominate on raw hit counts.
22
+ - Reworked hybrid search to combine lexical and semantic components on a shared min-max-normalized [0,1] scale instead of the fixed `text + semantic * 8` blend, keeping neither the unbounded lexical magnitude nor the bounded cosine value able to swamp the other. Relevance filtering now runs on raw component scores before normalization so a legitimate weakest-in-mode match is never scaled to zero and dropped.
23
+ - Context token budgeting now reuses the per-chunk token count cached at index time and shares a single, more accurate token estimator (`max(words * 1.3, chars / 4)`), removing the duplicated character-ratio heuristic while staying conservative so rendered packages remain within budget.
24
+
3
25
  ## 1.0.0
4
26
 
5
27
  - Promoted Brainlink to the first stable functional release.
package/README.md CHANGED
@@ -540,14 +540,17 @@ Available tools:
540
540
  - `brainlink_context_packs`: list or clear persisted CAG context packs.
541
541
  - `brainlink_search`: search indexed notes.
542
542
  - `brainlink_explain`: explain why indexed notes matched a query.
543
+ - `brainlink_similar_notes`: find the notes most similar to a given note (by title or path), excluding the note itself.
543
544
  - `brainlink_dedupe`: detect duplicate candidates using exact hash + semantic similarity scores.
544
545
  - `brainlink_resolve_duplicate`: resolve duplicate pairs (`merge`, `link`, `ignore`) with connectivity-safe fallback edges.
545
546
  - `brainlink_add_note`: write durable Markdown memory and reindex.
547
+ - `brainlink_add_notes`: write several notes in one batch, reindexing once.
546
548
  - `brainlink_remember`: capture durable memory with inferred title, tags and Context Links; supports dry-run.
547
549
  - `brainlink_inbox_add`: capture a quick untriaged memory item.
548
550
  - `brainlink_inbox_list`: list untriaged inbox memory items.
549
551
  - `brainlink_inbox_process`: suggest titles, tags and links for inbox items.
550
552
  - `brainlink_delete_note`: delete a durable Markdown note by title or path after explicit confirmation and reindex.
553
+ - `brainlink_delete_notes`: delete several notes in one confirmed batch, reindexing once; per-note failures are reported.
551
554
  - `brainlink_add_file`: ingest a local file as a note and reindex.
552
555
  - `brainlink_canonicalize_context_links`: ensure existing notes link to inferred context hubs.
553
556
  - `brainlink_volatile_add`: write temporary agent-decided memory with TTL; volatile sections are included in context and never create durable graph edges.
@@ -756,6 +759,17 @@ When the configured default vault is changed manually in config files, Brainlink
756
759
  Use `--global` to write to `$BRAINLINK_HOME/brainlink.config.json`, `--no-migrate` to skip migration, and `--no-index` to skip post-migration indexing.
757
760
  `config doctor` is dry-run by default; use `--fix` to apply safe config normalization and allowlist fixes.
758
761
 
762
+ #### Storage backend
763
+
764
+ `storageBackend` selects how the derived index is stored (the canonical Markdown is unaffected):
765
+
766
+ - `json` (default) — the original single `index.json`.
767
+ - `binary` — Float32 vectors plus Brotli-compressed metadata; a smaller, faster-to-load index (~75% smaller on a typical vault). Switching is seamless: an existing `index.json` is converted in place on first use (no re-embedding) and reads fall back to it until then.
768
+
769
+ ```json
770
+ { "storageBackend": "binary" }
771
+ ```
772
+
759
773
  ### `vaults`
760
774
 
761
775
  ```bash
@@ -909,6 +923,30 @@ blink pack-backup --vault ./vault --json
909
923
  Creates an offline backup artifact of encrypted search packs with a second compression pass.
910
924
  This is intentionally outside the online retrieval path (`index`, `search`, `context`).
911
925
 
926
+ ### Vault Versioning
927
+
928
+ Version the canonical Markdown with git, or move a vault as a single portable file.
929
+ The derived `.brainlink/` directory is never versioned; any operation that changes
930
+ Markdown reindexes automatically so the vault stays immediately usable.
931
+
932
+ ```bash
933
+ # Git: history, remotes, restore
934
+ blink vault-init-git --remote git@github.com:you/your-vault.git
935
+ blink vault-commit -m "snapshot notes"
936
+ blink vault-push
937
+ blink vault-pull # pulls and reindexes changed notes
938
+ blink vault-history --limit 20
939
+ blink vault-restore <commit> # restore Markdown from a ref and reindex
940
+ blink vault-clone git@github.com:you/your-vault.git ./vault # clone + build index
941
+ blink vault-status
942
+
943
+ # Portable snapshot (self-contained, index excluded; import rebuilds it)
944
+ blink vault-export -o vault.blvault
945
+ blink vault-import vault.blvault # conflicting local edits preserved as *.conflict-<ts>.md
946
+ ```
947
+
948
+ Inline credentials in a remote URL are rejected — use SSH or a git credential helper.
949
+
912
950
  ### `agents`
913
951
 
914
952
  ```bash
@@ -1214,6 +1252,41 @@ spec:
1214
1252
 
1215
1253
  Use `"embeddingProvider": "none"` when you want FTS-only indexing.
1216
1254
 
1255
+ ### Embedding providers
1256
+
1257
+ `embeddingProvider` selects how chunk and query embeddings are produced:
1258
+
1259
+ - `local` (default): deterministic, dependency-free local vectors. No network.
1260
+ - `none`: FTS-only; no semantic vectors.
1261
+ - `ollama`: embeddings from a local [Ollama](https://ollama.com) server.
1262
+ - `openai`: embeddings from the OpenAI embeddings API.
1263
+
1264
+ External providers are configured through the optional `embedding` block. The API key is referenced by environment variable name and never stored in the config file:
1265
+
1266
+ ```json
1267
+ {
1268
+ "embeddingProvider": "ollama",
1269
+ "embedding": {
1270
+ "url": "http://127.0.0.1:11434",
1271
+ "model": "nomic-embed-text",
1272
+ "timeoutMs": 10000,
1273
+ "batchSize": 64
1274
+ }
1275
+ }
1276
+ ```
1277
+
1278
+ ```json
1279
+ {
1280
+ "embeddingProvider": "openai",
1281
+ "embedding": {
1282
+ "model": "text-embedding-3-small",
1283
+ "apiKeyEnv": "OPENAI_API_KEY"
1284
+ }
1285
+ }
1286
+ ```
1287
+
1288
+ Switching the provider — or the external `model` — changes the embedding space, so the next index run rebuilds vectors automatically. If an external provider is unreachable at query time, search degrades to lexical-only instead of failing; at index time it surfaces a clear error rather than mixing embedding spaces.
1289
+
1217
1290
  For local security checks, set your Snyk token in the environment:
1218
1291
 
1219
1292
  ```bash
@@ -0,0 +1,75 @@
1
+ import { resolve } from 'node:path';
2
+ import { parseMarkdownDocument } from '../domain/markdown.js';
3
+ import { ensureVault, readMarkdownFiles } from '../infrastructure/file-system-vault.js';
4
+ import { searchKnowledge } from './search-knowledge.js';
5
+ const defaultLimit = 5;
6
+ // Pull extra candidates before deduping by document and dropping the note
7
+ // itself, so a note with many chunks does not crowd out distinct results.
8
+ const candidateOverscan = 8;
9
+ const maxQueryCharacters = 2000;
10
+ const normalizeTitleKey = (title) => title.trim().replace(/\.md$/i, '').toLowerCase();
11
+ const resolveTarget = async (vaultPath, input) => {
12
+ const absoluteVaultPath = await ensureVault(vaultPath);
13
+ const files = await readMarkdownFiles(vaultPath);
14
+ const documents = files.map((file) => ({
15
+ file,
16
+ document: parseMarkdownDocument({
17
+ absolutePath: file.absolutePath,
18
+ vaultPath: absoluteVaultPath,
19
+ content: file.content,
20
+ createdAt: file.createdAt,
21
+ updatedAt: file.updatedAt
22
+ })
23
+ }));
24
+ if (input.path != null && input.path.trim().length > 0) {
25
+ const expected = resolve(absoluteVaultPath, input.path);
26
+ const match = documents.find((entry) => resolve(entry.file.absolutePath) === expected);
27
+ if (!match) {
28
+ throw new Error(`No note found at path: ${input.path}`);
29
+ }
30
+ return { title: match.document.title, path: match.document.path, content: match.file.content };
31
+ }
32
+ const expectedTitle = normalizeTitleKey(input.title ?? '');
33
+ const matches = documents.filter((entry) => normalizeTitleKey(entry.document.title) === expectedTitle &&
34
+ (input.agentId == null || entry.document.agentId === input.agentId));
35
+ if (matches.length === 0) {
36
+ throw new Error(`No note found with title: ${input.title}`);
37
+ }
38
+ if (matches.length > 1) {
39
+ throw new Error(`Multiple notes match title "${input.title}". Use path instead: ${matches.map((entry) => entry.document.path).join(', ')}`);
40
+ }
41
+ return { title: matches[0].document.title, path: matches[0].document.path, content: matches[0].file.content };
42
+ };
43
+ const buildQuery = (target) => `${target.title}\n${target.content}`.slice(0, maxQueryCharacters);
44
+ // Find notes most similar to a given note by using the note's own title and
45
+ // content as the retrieval query, then excluding the note itself.
46
+ export const findSimilarNotes = async (vaultPath, input) => {
47
+ const hasTitle = input.title != null && input.title.trim().length > 0;
48
+ const hasPath = input.path != null && input.path.trim().length > 0;
49
+ if (hasTitle === hasPath) {
50
+ throw new Error('Use exactly one selector: title or path.');
51
+ }
52
+ const target = await resolveTarget(vaultPath, input);
53
+ const limit = Math.max(1, input.limit ?? defaultLimit);
54
+ const results = await searchKnowledge(vaultPath, buildQuery(target), limit + candidateOverscan, input.agentId, input.mode ?? 'hybrid');
55
+ const bestByPath = new Map();
56
+ for (const result of results) {
57
+ if (result.path === target.path) {
58
+ continue;
59
+ }
60
+ const existing = bestByPath.get(result.path);
61
+ if (!existing || result.score > existing.score) {
62
+ bestByPath.set(result.path, {
63
+ title: result.title,
64
+ path: result.path,
65
+ score: result.score,
66
+ searchMode: result.searchMode,
67
+ tags: result.tags
68
+ });
69
+ }
70
+ }
71
+ const similar = Array.from(bestByPath.values())
72
+ .sort((left, right) => right.score - left.score || left.title.localeCompare(right.title))
73
+ .slice(0, limit);
74
+ return { target: { title: target.title, path: target.path }, similar };
75
+ };
@@ -1,8 +1,8 @@
1
1
  import { ensureVault } from '../infrastructure/file-system-vault.js';
2
- import { openFileIndex } from '../infrastructure/file-index.js';
2
+ import { openKnowledgeStore } from '../infrastructure/knowledge-store/index.js';
3
3
  export const getGraphNode = async (vaultPath, id, agentId) => {
4
4
  const absoluteVaultPath = await ensureVault(vaultPath);
5
- const index = openFileIndex(absoluteVaultPath);
5
+ const index = openKnowledgeStore(absoluteVaultPath);
6
6
  try {
7
7
  return await index.getGraphNode(id, agentId);
8
8
  }
@@ -1,8 +1,8 @@
1
1
  import { ensureVault } from '../infrastructure/file-system-vault.js';
2
- import { openFileIndex } from '../infrastructure/file-index.js';
2
+ import { openKnowledgeStore } from '../infrastructure/knowledge-store/index.js';
3
3
  export const getGraphSummary = async (vaultPath, agentId) => {
4
4
  const absoluteVaultPath = await ensureVault(vaultPath);
5
- const index = openFileIndex(absoluteVaultPath);
5
+ const index = openKnowledgeStore(absoluteVaultPath);
6
6
  try {
7
7
  return await index.getGraphSummary(agentId);
8
8
  }
@@ -1,8 +1,8 @@
1
1
  import { ensureVault } from '../infrastructure/file-system-vault.js';
2
- import { openFileIndex } from '../infrastructure/file-index.js';
2
+ import { openKnowledgeStore } from '../infrastructure/knowledge-store/index.js';
3
3
  export const getGraph = async (vaultPath, agentId) => {
4
4
  const absoluteVaultPath = await ensureVault(vaultPath);
5
- const index = openFileIndex(absoluteVaultPath);
5
+ const index = openKnowledgeStore(absoluteVaultPath);
6
6
  try {
7
7
  return await index.getGraph(agentId);
8
8
  }
@@ -1,6 +1,7 @@
1
1
  import { createIndexedDocument, graphLinkModelVersion } from '../domain/markdown.js';
2
2
  import { sharedAgentId } from '../domain/agents.js';
3
3
  import { createEmbeddingBuckets, createEmbeddingProvider } from '../domain/embeddings.js';
4
+ import { embeddingSignature } from '../infrastructure/config.js';
4
5
  import { buildSearchPacks, toSearchPackBuildOptions } from '../infrastructure/search-packs.js';
5
6
  const toTitleKey = (title) => title.toLowerCase();
6
7
  const appendTitleEntry = (map, document) => {
@@ -30,11 +31,11 @@ export const createTitleMaps = (documents) => [...documents]
30
31
  const createScopedTitleResolver = (document, titleMaps) => ({
31
32
  get: (title) => titleMaps.byAgent.get(document.agentId)?.get(title)?.id ?? titleMaps.shared.get(title)?.id
32
33
  });
33
- const embedIndexedDocuments = async (documents, providerName) => {
34
+ const embedIndexedDocuments = async (documents, providerName, embeddingConfig) => {
34
35
  if (documents.length === 0) {
35
36
  return documents;
36
37
  }
37
- const provider = createEmbeddingProvider(providerName);
38
+ const provider = createEmbeddingProvider(providerName, embeddingConfig);
38
39
  const chunks = documents.flatMap((document) => document.chunks);
39
40
  const embeddings = await provider.embed(chunks.map((chunk) => chunk.content));
40
41
  const embeddingByChunkId = new Map(chunks.map((chunk, index) => [chunk.id, embeddings[index] ?? []]));
@@ -70,7 +71,7 @@ export const detectChanges = (params) => {
70
71
  const fullSourceReindex = fullReindex || graphLinkModelChanged;
71
72
  const settingsChanged = previousState == null ||
72
73
  previousState.chunkSize !== config.chunkSize ||
73
- previousState.embeddingProvider !== config.embeddingProvider ||
74
+ previousState.embeddingProvider !== embeddingSignature(config) ||
74
75
  graphLinkModelChanged;
75
76
  const packSettingsChanged = previousState == null ||
76
77
  previousState.searchPackRowChunkSize !== config.searchPack.rowChunkSize ||
@@ -135,7 +136,7 @@ export const embedChangedDocuments = async (params) => {
135
136
  changedDocuments: changedDocumentsByPath.size
136
137
  });
137
138
  const changedIndexedDocuments = changedDocumentsByPath.size > 0
138
- ? await embedIndexedDocuments(Array.from(changedDocumentsByPath.values()).map((document) => createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize)), config.embeddingProvider)
139
+ ? await embedIndexedDocuments(Array.from(changedDocumentsByPath.values()).map((document) => createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize)), config.embeddingProvider, config.embedding)
139
140
  : [];
140
141
  emit('embed', changedDocumentsByPath.size > 0 ? 'finish' : 'skip', changedDocumentsByPath.size > 0 ? 'Embedding complete' : 'Embedding skipped', {
141
142
  changedIndexedDocuments: changedIndexedDocuments.length
@@ -1,10 +1,10 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { graphLinkModelVersion, parseMarkdownDocument } from '../domain/markdown.js';
3
- import { loadBrainlinkConfig } from '../infrastructure/config.js';
3
+ import { embeddingSignature, loadBrainlinkConfig } from '../infrastructure/config.js';
4
4
  import { ensureVault, readMarkdownFileSummaries } from '../infrastructure/file-system-vault.js';
5
5
  import { readIndexState, writeIndexState } from '../infrastructure/index-state.js';
6
6
  import { ensureSearchPackManifest } from '../infrastructure/search-packs.js';
7
- import { openFileIndex } from '../infrastructure/file-index.js';
7
+ import { openKnowledgeStore } from '../infrastructure/knowledge-store/index.js';
8
8
  import { assembleIndexedDocuments, createTitleMaps, decidePackRebuild, detectChanges, embedChangedDocuments, rebuildSearchPacksIfNeeded } from './index-vault-phases.js';
9
9
  const toIndexResult = (documents) => ({
10
10
  documentCount: documents.length,
@@ -56,7 +56,7 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
56
56
  hasPreviousState: previousState != null
57
57
  });
58
58
  const fullReindex = options.full === true;
59
- const index = openFileIndex(absoluteVaultPath);
59
+ const index = openKnowledgeStore(absoluteVaultPath);
60
60
  try {
61
61
  const existingIndexedDocuments = await index.getIndexedDocuments();
62
62
  const existingByPath = new Map(existingIndexedDocuments.map((document) => [document.document.path, document]));
@@ -161,7 +161,7 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
161
161
  const packResultReason = shouldRebuildPacks && !packsRebuilt ? `${packReason} (failed)` : packReason;
162
162
  await writeIndexState(absoluteVaultPath, {
163
163
  chunkSize: config.chunkSize,
164
- embeddingProvider: config.embeddingProvider,
164
+ embeddingProvider: embeddingSignature(config),
165
165
  graphLinkModelVersion,
166
166
  searchPackRowChunkSize: config.searchPack.rowChunkSize,
167
167
  searchPackCompressionLevel: config.searchPack.compressionLevel,
@@ -1,8 +1,8 @@
1
1
  import { ensureVault } from '../infrastructure/file-system-vault.js';
2
- import { openFileIndex } from '../infrastructure/file-index.js';
2
+ import { openKnowledgeStore } from '../infrastructure/knowledge-store/index.js';
3
3
  export const listAgents = async (vaultPath) => {
4
4
  const absoluteVaultPath = await ensureVault(vaultPath);
5
- const index = openFileIndex(absoluteVaultPath);
5
+ const index = openKnowledgeStore(absoluteVaultPath);
6
6
  try {
7
7
  return await index.listAgents();
8
8
  }
@@ -1,8 +1,8 @@
1
1
  import { ensureVault } from '../infrastructure/file-system-vault.js';
2
- import { openFileIndex } from '../infrastructure/file-index.js';
2
+ import { openKnowledgeStore } from '../infrastructure/knowledge-store/index.js';
3
3
  export const listLinks = async (vaultPath, agentId) => {
4
4
  const absoluteVaultPath = await ensureVault(vaultPath);
5
- const index = openFileIndex(absoluteVaultPath);
5
+ const index = openKnowledgeStore(absoluteVaultPath);
6
6
  try {
7
7
  return await index.listLinks(agentId);
8
8
  }
@@ -12,7 +12,7 @@ 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 = openFileIndex(absoluteVaultPath);
15
+ const index = openKnowledgeStore(absoluteVaultPath);
16
16
  try {
17
17
  return await index.listBacklinks(title, agentId);
18
18
  }
@@ -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,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,177 @@
1
+ // Backend-agnostic ranking. Given a candidate set (chunks already selected by a
2
+ // storage backend), this module owns the deterministic scoring math: field
3
+ // weighting, BM25 lexical relevance, cosine semantic relevance, hybrid fusion,
4
+ // and stable ordering. Keeping it here means every KnowledgeStore backend
5
+ // (JSON, binary, LMDB) produces an identical ranking for the same candidates.
6
+ import { dotProduct } from '../../domain/embeddings.js';
7
+ import { bm25TermScore, minMaxNormalize } from '../../domain/scoring.js';
8
+ const queryTokenPattern = /[\p{L}\p{N}_-]+/gu;
9
+ export const normalizeToken = (value) => value
10
+ .normalize('NFKD')
11
+ .replace(/\p{Diacritic}/gu, '')
12
+ .toLowerCase();
13
+ export const tokenize = (query) => query
14
+ .match(queryTokenPattern)
15
+ ?.map(normalizeToken)
16
+ .filter((token) => token.length > 1) ?? [];
17
+ const countOccurrences = (text, token) => {
18
+ let hits = 0;
19
+ let cursor = 0;
20
+ while (cursor < text.length) {
21
+ const index = text.indexOf(token, cursor);
22
+ if (index < 0) {
23
+ break;
24
+ }
25
+ hits += 1;
26
+ cursor = index + token.length;
27
+ }
28
+ return hits;
29
+ };
30
+ const titleFieldWeight = 5;
31
+ const tagFieldWeight = 4;
32
+ const pathFieldWeight = 2;
33
+ const contentHitCap = 6;
34
+ const hybridTextWeight = 0.6;
35
+ const hybridSemanticWeight = 0.4;
36
+ const normalizeFields = (row) => ({
37
+ title: normalizeToken(row.title),
38
+ path: normalizeToken(row.path),
39
+ content: normalizeToken(row.content),
40
+ tags: normalizeToken(row.tags.join(' '))
41
+ });
42
+ // Field-weighted term frequency: title/tag/path matches count more than body
43
+ // matches, and body hits saturate at a cap so a term repeated many times in one
44
+ // chunk cannot dominate. This weighted frequency feeds BM25 below.
45
+ const weightedTermFrequency = (fields, token) => {
46
+ const titleHits = countOccurrences(fields.title, token);
47
+ const tagHits = countOccurrences(fields.tags, token);
48
+ const pathHits = countOccurrences(fields.path, token);
49
+ const contentHits = countOccurrences(fields.content, token);
50
+ return (titleHits * titleFieldWeight +
51
+ tagHits * tagFieldWeight +
52
+ pathHits * pathFieldWeight +
53
+ Math.min(contentHits, contentHitCap));
54
+ };
55
+ // Naive additive field score retained for graph-node title/path lookup, where
56
+ // the candidate set is documents (not chunks) and corpus-wide BM25 statistics
57
+ // add no signal over a direct weighted hit count.
58
+ export const lexicalFieldScore = (row, tokens) => {
59
+ if (tokens.length === 0) {
60
+ return 0;
61
+ }
62
+ const fields = normalizeFields(row);
63
+ return tokens.reduce((score, token) => score + weightedTermFrequency(fields, token), 0);
64
+ };
65
+ // A single chunk with a non-finite cached tokenCount (legacy index, partial
66
+ // write, manual edit) would otherwise poison the shared averageLength and zero
67
+ // out lexical scoring for the whole corpus, so coerce to a safe positive length.
68
+ const safeDocumentLength = (tokenCount) => Number.isFinite(tokenCount) && tokenCount > 0 ? tokenCount : 1;
69
+ const buildCorpusStatistics = (prepared, tokens) => {
70
+ const documentCount = prepared.length;
71
+ const totalLength = prepared.reduce((sum, entry) => sum + safeDocumentLength(entry.row.tokenCount), 0);
72
+ const documentFrequencyByTerm = new Map();
73
+ for (const term of tokens) {
74
+ let documentFrequency = 0;
75
+ for (const entry of prepared) {
76
+ if ((entry.weightedTermFrequencies.get(term) ?? 0) > 0) {
77
+ documentFrequency += 1;
78
+ }
79
+ }
80
+ documentFrequencyByTerm.set(term, documentFrequency);
81
+ }
82
+ return {
83
+ documentCount,
84
+ averageLength: documentCount > 0 ? totalLength / documentCount : 0,
85
+ documentFrequencyByTerm
86
+ };
87
+ };
88
+ const bm25TextScore = (entry, tokens, stats) => tokens.reduce((score, term) => score +
89
+ bm25TermScore({
90
+ termFrequency: entry.weightedTermFrequencies.get(term) ?? 0,
91
+ documentLength: safeDocumentLength(entry.row.tokenCount),
92
+ documentCount: stats.documentCount,
93
+ documentFrequency: stats.documentFrequencyByTerm.get(term) ?? 0,
94
+ averageLength: stats.averageLength
95
+ }), 0);
96
+ const semanticScore = (row, queryEmbedding) => queryEmbedding.length > 0 && row.embedding.length > 0 ? dotProduct(queryEmbedding, row.embedding) : 0;
97
+ const rangeOf = (values) => values.reduce((range, value) => ({ min: Math.min(range.min, value), max: Math.max(range.max, value) }), { min: Infinity, max: -Infinity });
98
+ const toResult = (row, mode, score, text, semantic) => ({
99
+ documentId: row.documentId,
100
+ agentId: row.agentId,
101
+ title: row.title,
102
+ path: row.path,
103
+ chunkId: row.chunkId,
104
+ chunkOrdinal: row.chunkOrdinal,
105
+ content: row.content,
106
+ score,
107
+ textScore: text,
108
+ semanticScore: semantic,
109
+ tokenCount: row.tokenCount,
110
+ searchMode: mode,
111
+ tags: row.tags
112
+ });
113
+ // Rank an already-selected candidate set. Backends own candidate selection
114
+ // (full scan, bucket prefilter, ANN); this function owns scoring and ordering so
115
+ // the result is identical regardless of how the candidates were gathered.
116
+ export const runSearch = (candidates, query, limit, mode, queryEmbedding = []) => {
117
+ const tokens = tokenize(query);
118
+ // Pass 1: field-weighted term frequencies per candidate, then corpus-wide
119
+ // statistics (document frequency, average length) needed for BM25 IDF and
120
+ // length normalization.
121
+ const prepared = candidates.map((row) => {
122
+ const fields = normalizeFields(row);
123
+ const weightedTermFrequencies = new Map();
124
+ for (const term of tokens) {
125
+ const frequency = weightedTermFrequency(fields, term);
126
+ if (frequency > 0) {
127
+ weightedTermFrequencies.set(term, frequency);
128
+ }
129
+ }
130
+ return { row, weightedTermFrequencies };
131
+ });
132
+ const stats = buildCorpusStatistics(prepared, tokens);
133
+ // Pass 2: raw lexical (BM25) and semantic (cosine) component scores.
134
+ const componentScored = prepared.map((entry) => ({
135
+ row: entry.row,
136
+ text: tokens.length > 0 ? bm25TextScore(entry, tokens, stats) : 0,
137
+ semantic: semanticScore(entry.row, queryEmbedding)
138
+ }));
139
+ // Filter on raw relevance before normalization so a legitimate match that
140
+ // happens to be the weakest in its mode is never min-max scaled to zero
141
+ // and dropped. fts stays lexical-only and semantic stays vector-only.
142
+ const relevant = componentScored.filter((entry) => {
143
+ if (tokens.length === 0) {
144
+ return true;
145
+ }
146
+ if (mode === 'fts') {
147
+ return entry.text > 0;
148
+ }
149
+ if (mode === 'semantic') {
150
+ return entry.semantic > 0;
151
+ }
152
+ return entry.text > 0 || entry.semantic > 0;
153
+ });
154
+ if (relevant.length === 0) {
155
+ return [];
156
+ }
157
+ const textRange = rangeOf(relevant.map((entry) => entry.text));
158
+ const semanticRange = rangeOf(relevant.map((entry) => entry.semantic));
159
+ const finalScore = (entry) => {
160
+ if (mode === 'fts') {
161
+ return entry.text;
162
+ }
163
+ if (mode === 'semantic') {
164
+ return entry.semantic;
165
+ }
166
+ // Hybrid mixes the two components on a shared [0, 1] scale so neither the
167
+ // unbounded BM25 magnitude nor the bounded cosine value can swamp the
168
+ // other, replacing the previous fixed `text + semantic * 8` blend.
169
+ const textNorm = minMaxNormalize(entry.text, textRange.min, textRange.max);
170
+ const semanticNorm = minMaxNormalize(entry.semantic, semanticRange.min, semanticRange.max);
171
+ return textNorm * hybridTextWeight + semanticNorm * hybridSemanticWeight;
172
+ };
173
+ return relevant
174
+ .map((entry) => toResult(entry.row, mode, finalScore(entry), entry.text, entry.semantic))
175
+ .sort((left, right) => right.score - left.score || left.title.localeCompare(right.title))
176
+ .slice(0, Math.max(0, limit));
177
+ };
@@ -1,6 +1,7 @@
1
1
  import { stat } from 'node:fs/promises';
2
2
  import { ensureVault } from '../infrastructure/file-system-vault.js';
3
- import { indexStoragePath, openFileIndex } from '../infrastructure/file-index.js';
3
+ import { indexStoragePath } from '../infrastructure/file-index.js';
4
+ import { openKnowledgeStore } from '../infrastructure/knowledge-store/index.js';
4
5
  import { getGraphLayout } from './get-graph-layout.js';
5
6
  const graphSearchCacheTtlMs = 20_000;
6
7
  const graphSearchCacheMaxEntries = 120;
@@ -52,7 +53,7 @@ export const searchGraphNodeIds = async (vaultPath, query, limit, agentId, conte
52
53
  const contextNodeIds = context
53
54
  ? new Set((await getGraphLayout(absoluteVaultPath, { agentId, context })).layout.nodes.map((node) => node.id))
54
55
  : new Set();
55
- const index = openFileIndex(absoluteVaultPath);
56
+ const index = openKnowledgeStore(absoluteVaultPath);
56
57
  try {
57
58
  const searchLimit = context ? Math.max(limit, 5000) : limit;
58
59
  const foundNodeIds = await index.searchGraphNodeIds(query, searchLimit, agentId);
@@ -1,9 +1,20 @@
1
1
  import { stat } from 'node:fs/promises';
2
2
  import { ensureVault } from '../infrastructure/file-system-vault.js';
3
3
  import { ensurePrivatePacksFromLegacyIndex, searchInPacks } from '../infrastructure/search-packs.js';
4
- import { indexStoragePath, openFileIndex } from '../infrastructure/file-index.js';
4
+ import { indexStoragePath } from '../infrastructure/file-index.js';
5
+ import { openKnowledgeStore } from '../infrastructure/knowledge-store/index.js';
5
6
  import { createEmbeddingProvider } from '../domain/embeddings.js';
6
7
  import { loadBrainlinkConfig, sanitizeSearchMode } from '../infrastructure/config.js';
8
+ const embedQuerySafely = async (provider, query) => {
9
+ try {
10
+ return (await provider.embed([query]))[0] ?? [];
11
+ }
12
+ catch (error) {
13
+ const reason = error instanceof Error ? error.message : String(error);
14
+ process.stderr.write(`brainlink: query embedding via ${provider.name} failed, using lexical search (${reason})\n`);
15
+ return [];
16
+ }
17
+ };
7
18
  const hybridCacheTtlMs = 30_000;
8
19
  const hybridCacheMaxEntries = 200;
9
20
  const hybridSearchCache = new Map();
@@ -53,13 +64,19 @@ export const searchKnowledge = async (vaultPath, query, limit, agentId, mode) =>
53
64
  if (cached) {
54
65
  return cached;
55
66
  }
56
- const provider = createEmbeddingProvider(config.embeddingProvider);
67
+ const provider = createEmbeddingProvider(config.embeddingProvider, config.embedding);
57
68
  const shouldEmbedQuery = searchMode !== 'fts' && provider.name !== 'none';
58
- const queryEmbedding = shouldEmbedQuery ? (await provider.embed([query]))[0] ?? [] : [];
69
+ // An external provider can be unreachable at query time. Degrade to
70
+ // lexical-only retrieval rather than substituting a local-space vector that
71
+ // would not match the stored embeddings, keeping search available.
72
+ const queryEmbedding = shouldEmbedQuery ? await embedQuerySafely(provider, query) : [];
73
+ // Pure semantic mode with no usable embedding would return nothing; fall back
74
+ // to lexical (fts) so a transient provider outage still yields results.
75
+ const effectiveMode = searchMode === 'semantic' && shouldEmbedQuery && queryEmbedding.length === 0 ? 'fts' : searchMode;
59
76
  try {
60
- const index = openFileIndex(absoluteVaultPath);
77
+ const index = openKnowledgeStore(absoluteVaultPath);
61
78
  try {
62
- const results = await index.search(query, limit, agentId, searchMode, queryEmbedding);
79
+ const results = await index.search(query, limit, agentId, effectiveMode, queryEmbedding);
63
80
  if (cacheKey) {
64
81
  cacheSet({
65
82
  key: cacheKey,