@andespindola/brainlink 1.0.7 → 1.2.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 (40) hide show
  1. package/CHANGELOG.md +16 -1
  2. package/README.md +63 -2
  3. package/dist/application/analyze-vault.js +1 -1
  4. package/dist/application/build-context.js +14 -7
  5. package/dist/application/get-graph-node.js +2 -2
  6. package/dist/application/get-graph-summary.js +2 -2
  7. package/dist/application/get-graph.js +2 -2
  8. package/dist/application/index-vault-phases.js +1 -0
  9. package/dist/application/index-vault.js +16 -2
  10. package/dist/application/list-agents.js +2 -2
  11. package/dist/application/list-links.js +3 -3
  12. package/dist/application/ports/knowledge-store.js +1 -0
  13. package/dist/application/provision-vault.js +74 -0
  14. package/dist/application/ranking/run-search.js +212 -0
  15. package/dist/application/search-graph-node-ids.js +3 -2
  16. package/dist/application/search-knowledge.js +9 -17
  17. package/dist/application/server/routes.js +6 -1
  18. package/dist/application/vault-encryption.js +64 -0
  19. package/dist/application/vault-git-core.js +168 -0
  20. package/dist/application/vault-git.js +75 -0
  21. package/dist/application/vault-snapshot.js +87 -0
  22. package/dist/cli/commands/read-commands.js +9 -5
  23. package/dist/cli/commands/vault-sync-commands.js +137 -0
  24. package/dist/cli/commands/write/vault-lifecycle-commands.js +1 -1
  25. package/dist/cli/main.js +2 -0
  26. package/dist/infrastructure/config.js +16 -1
  27. package/dist/infrastructure/context-packs.js +57 -18
  28. package/dist/infrastructure/file-index.js +22 -409
  29. package/dist/infrastructure/index-signature.js +27 -0
  30. package/dist/infrastructure/index-state.js +6 -0
  31. package/dist/infrastructure/knowledge-store/binary-store.js +333 -0
  32. package/dist/infrastructure/knowledge-store/index.js +37 -0
  33. package/dist/infrastructure/knowledge-store/stored-index.js +216 -0
  34. package/dist/infrastructure/vault-crypto.js +78 -0
  35. package/dist/mcp/server.js +46 -1
  36. package/dist/mcp/tools/maintenance-tools.js +2 -2
  37. package/dist/mcp/tools/read-tools.js +8 -4
  38. package/dist/mcp/tools/vault-tools.js +163 -0
  39. package/dist/mcp/tools.js +1 -0
  40. package/package.json +3 -2
package/CHANGELOG.md CHANGED
@@ -1,6 +1,21 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 1.2.0
4
+
5
+ - **Reciprocal Rank Fusion for hybrid search.** Hybrid mode now fuses the lexical (BM25) and semantic rankings by rank position (`Σ 1/(k+rank)`, k=60) instead of a fixed `0.6/0.4` min-max blend, which is robust to the two components' very different score scales. Gated by a new `hybridFusion` config (`rrf` default | `linear`); `fts`/`semantic` modes and stored data are unchanged.
6
+ - **Per-document CAG invalidation.** Persisted context packs now record the documents they were assembled from and are fresh only while each cited document is unchanged, so editing one note refreshes only the packs that cite it instead of the whole cache. A new optional `cagPackTtlMs` bounds pack lifetime as a safety net for newly added notes. Freshness is evaluated with a few `stat`s and no index load.
7
+ - **int8 ANN with partial exact-vector reads (binary backend).** Search loads a compact int8 scan index and reads the exact Float32 vectors by offset only for the shortlist it reranks (embedding-bucket coarse prune → int8 scan → exact Float32 rerank), so the exact vectors never become fully resident on a large vault. Small vaults rerank every candidate exactly, staying bit-identical to a full scan; `fts` reads no vectors.
8
+ - **The binary storage backend is now the default** (`storageBackend: "binary"`). Existing `json` vaults auto-migrate on first use (no re-embedding), and switching backends forces a one-time full reindex so the index is rewritten in the active format. The in-memory search/context caches key on a backend-agnostic commit signature (fixing a stale-read window on the binary backend). The `json` backend remains available.
9
+ - **Vault versioning over MCP.** `brainlink_vault_status` / `_init` / `_commit` / `_history` / `_pull` / `_restore` / `_export` / `_import` expose the git and `.blvault` versioning to agents (previously CLI-only); pull/restore/import reindex automatically.
10
+ - **Self-managed versioning repository.** `brainlink_vault_provision` / `blink vault-provision` create a private GitHub repository for the vault with the authenticated `gh` CLI, wire it as origin, push the first snapshot, and enable `autoVersion` — after which a successful index that changed Markdown commits and pushes automatically (best-effort; a git/network failure never breaks indexing).
11
+ - **Encrypted vault versioning.** With `vaultEncryption` (enabled by `vault-provision` by default), the git repository holds AES-256-GCM ciphertext (`<note>.md.enc`) and git-ignores the plaintext Markdown, so the repository is opaque to anyone without the per-vault key (in `$BRAINLINK_HOME/keys`, never committed, or via `BRAINLINK_VAULT_KEY`). Encryption is deterministic per note (no git churn); pull/restore/clone decrypt back to Markdown before reindexing.
12
+
13
+ ## 1.1.0
14
+
15
+ - 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.
16
+ - 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.
17
+ - 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.
18
+ - Pinned the transitive `fast-uri` dependency to `>=4.1.0` to clear a high-severity Snyk advisory affecting the entire 3.x line.
4
19
 
5
20
  - 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.
6
21
  - 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.
package/README.md CHANGED
@@ -71,7 +71,7 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
71
71
  - Full-text, semantic and hybrid retrieval on a local file index.
72
72
  - Middle-out context assembly around the strongest chunk per document.
73
73
  - In-process index and context caching with automatic invalidation on index updates.
74
- - Optional CAG context packs at `.brainlink/context-packs/*.json`, derived from the current index signature and reusable across repeated context calls.
74
+ - Optional CAG context packs at `.brainlink/context-packs/*.json`, invalidated per cited document (editing one note only refreshes the packs that cite it) and reusable across repeated context calls.
75
75
  - HTTP graph server caches generated frontend assets and graph-layout JSON payloads by signature, and skips layout serialization when ETag returns `304`.
76
76
  - Compressed-space prefiltering for `.blpk` packs before decryption and scan.
77
77
  - Incremental indexing that reprocesses only changed markdown files and reuses existing chunks/embeddings for unchanged notes.
@@ -560,6 +560,9 @@ Available tools:
560
560
  - `brainlink_doctor_actions`: return vault checks plus prioritized executable next actions.
561
561
  - `brainlink_validate`: validate broken links and orphan notes.
562
562
  - `brainlink_sync`: run index, stats, validation, broken-link and orphan checks in one call.
563
+ - `brainlink_vault_provision`: create a private GitHub repository for the vault with the authenticated `gh` CLI, wire it as `origin`, push the first snapshot, and enable `autoVersion` so later indexed changes are committed and pushed automatically. By default the repository is **encrypted** (`vaultEncryption`): it holds AES-256-GCM ciphertext, decryptable only by a Brainlink instance with the local key.
564
+ - `brainlink_vault_status` / `brainlink_vault_init` / `brainlink_vault_commit` / `brainlink_vault_history` / `brainlink_vault_pull` / `brainlink_vault_restore`: version the vault's canonical Markdown with git (the derived index is git-ignored). Pull and restore reindex changed files automatically so the vault stays immediately searchable.
565
+ - `brainlink_vault_export` / `brainlink_vault_import`: write and read a portable `.blvault` snapshot (gzip envelope of the Markdown with per-file SHA-256). Import verifies checksums, preserves divergent local files as `*.conflict-<ts>.md`, and reindexes.
563
566
  - `brainlink_graph`: read indexed graph nodes and weighted links.
564
567
  - `brainlink_graph_contexts`: list the visual graph contexts used by the local server.
565
568
  - `brainlink_broken_links`: list unresolved wiki links.
@@ -759,6 +762,30 @@ When the configured default vault is changed manually in config files, Brainlink
759
762
  Use `--global` to write to `$BRAINLINK_HOME/brainlink.config.json`, `--no-migrate` to skip migration, and `--no-index` to skip post-migration indexing.
760
763
  `config doctor` is dry-run by default; use `--fix` to apply safe config normalization and allowlist fixes.
761
764
 
765
+ #### Storage backend
766
+
767
+ `storageBackend` selects how the derived index is stored (the canonical Markdown is unaffected):
768
+
769
+ - `binary` (default) — 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 changing the backend forces a one-time full reindex so the index is rewritten in the active format. On large vaults, semantic/hybrid search uses an approximate-nearest-neighbour path — a compact int8 vector scan (with embedding-bucket coarse pruning) shortlists candidates, and only that shortlist's exact Float32 vectors are read from disk and reranked, so the exact vectors never become fully resident. Small vaults rerank every candidate exactly, staying bit-identical to a full scan.
770
+ - `json` — the original single `index.json`. Larger and loaded whole, but a simple, human-readable artifact.
771
+
772
+ ```json
773
+ { "storageBackend": "json" }
774
+ ```
775
+
776
+ #### Hybrid fusion
777
+
778
+ `hybridFusion` selects how the lexical (BM25) and semantic (cosine) rankings are combined in `hybrid` search mode:
779
+
780
+ - `rrf` (default) — Reciprocal Rank Fusion. Combines the two rankings by rank position (`Σ 1/(k + rank)`), so a document both retrievers agree on ranks highly regardless of the very different score scales of BM25 and cosine. No weight tuning required.
781
+ - `linear` — the legacy min-max normalized weighted blend (`0.6·lexical + 0.4·semantic`).
782
+
783
+ `fts` and `semantic` modes are unaffected. The change is additive: existing vaults need no reindex.
784
+
785
+ ```json
786
+ { "hybridFusion": "linear" }
787
+ ```
788
+
762
789
  ### `vaults`
763
790
 
764
791
  ```bash
@@ -912,6 +939,36 @@ blink pack-backup --vault ./vault --json
912
939
  Creates an offline backup artifact of encrypted search packs with a second compression pass.
913
940
  This is intentionally outside the online retrieval path (`index`, `search`, `context`).
914
941
 
942
+ ### Vault Versioning
943
+
944
+ Version the canonical Markdown with git, or move a vault as a single portable file.
945
+ The derived `.brainlink/` directory is never versioned; any operation that changes
946
+ Markdown reindexes automatically so the vault stays immediately usable.
947
+
948
+ ```bash
949
+ # One-shot: create an ENCRYPTED private GitHub repo (via authenticated gh),
950
+ # push, and enable autoVersion so later indexed changes commit and push
951
+ # automatically. The repo holds AES-256-GCM ciphertext; only a Brainlink
952
+ # instance with the local key can decrypt. Use --no-encrypt for plaintext.
953
+ blink vault-provision --name brainlink-vault
954
+
955
+ # Git: history, remotes, restore
956
+ blink vault-init-git --remote git@github.com:you/your-vault.git
957
+ blink vault-commit -m "snapshot notes"
958
+ blink vault-push
959
+ blink vault-pull # pulls and reindexes changed notes
960
+ blink vault-history --limit 20
961
+ blink vault-restore <commit> # restore Markdown from a ref and reindex
962
+ blink vault-clone git@github.com:you/your-vault.git ./vault # clone + build index
963
+ blink vault-status
964
+
965
+ # Portable snapshot (self-contained, index excluded; import rebuilds it)
966
+ blink vault-export -o vault.blvault
967
+ blink vault-import vault.blvault # conflicting local edits preserved as *.conflict-<ts>.md
968
+ ```
969
+
970
+ Inline credentials in a remote URL are rejected — use SSH or a git credential helper.
971
+
915
972
  ### `agents`
916
973
 
917
974
  ```bash
@@ -955,7 +1012,9 @@ blink context-packs --vault ./vault --stale --clear
955
1012
 
956
1013
  Builds a compact context package for an agent.
957
1014
  Repeated calls with the same vault, agent, query, mode and token/limit settings are served from a short in-memory cache while the index is unchanged.
958
- The default strategy is configured by `defaultContextStrategy` and starts as `auto`, which uses CAG when a fresh pack exists and RAG otherwise, refreshing a pack for future calls. `--strategy cag` enables cache-augmented context generation by reading or refreshing a persisted context pack under `.brainlink/context-packs`; `--strategy rag` forces fresh retrieval assembly from the current index. Context responses include `cache`, `metrics`, `requestedStrategy` and `recommendedStrategy` metadata. Packs are derived artifacts and become stale when the index or volatile memory signature changes.
1015
+ The default strategy is configured by `defaultContextStrategy` and starts as `auto`, which uses CAG when a fresh pack exists and RAG otherwise, refreshing a pack for future calls. `--strategy cag` enables cache-augmented context generation by reading or refreshing a persisted context pack under `.brainlink/context-packs`; `--strategy rag` forces fresh retrieval assembly from the current index. Context responses include `cache`, `metrics`, `requestedStrategy` and `recommendedStrategy` metadata.
1016
+
1017
+ Packs are derived artifacts invalidated **per cited document**: a pack becomes stale only when one of the documents it was assembled from (or volatile memory) changes, so editing a single note no longer invalidates every pack. `cagPackTtlMs` (default `0`, disabled) sets an optional upper bound in milliseconds on how long a pack survives even when all its cited documents are unchanged — a safety net for a newly added note that would now rank into the result but is not among the pack's existing dependencies.
959
1018
 
960
1019
  ### `links`
961
1020
 
@@ -1129,6 +1188,8 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
1129
1188
  `agentProfiles` is optional. When present, CLI and MCP resolve `mode`, `limit`, `tokens` and context `strategy` per agent automatically, then fallback to global defaults.
1130
1189
 
1131
1190
  `autoIndexOnWrite` is optional and defaults to `true`. Set it to `false` to defer indexing after writes.
1191
+ `autoVersion` is optional and defaults to `false`. When enabled (e.g. by `blink vault-provision` / `brainlink_vault_provision`), a successful index that changed Markdown commits and pushes the vault to its git remote automatically, best-effort — a git or network failure is logged and never breaks indexing. It is a no-op unless the vault is a git repo with an `origin` remote.
1192
+ `vaultEncryption` is optional and defaults to `false`. When enabled, vault versioning commits AES-256-GCM ciphertext (`<note>.md.enc`) and git-ignores the plaintext Markdown, so the repository is opaque to anyone without the key — only a Brainlink instance holding the per-vault key can decrypt it. The key lives at `$BRAINLINK_HOME/keys/vault-versioning-<hash>.key` (outside the vault, never committed) or can be supplied via `BRAINLINK_VAULT_KEY` to restore an encrypted vault on another machine. Pull, restore and clone decrypt back to Markdown before reindexing; encryption is deterministic per note, so unchanged notes add no git churn. `blink vault-provision` enables this by default.
1132
1193
  `autoCanonicalContextLinks` is optional and defaults to `true`. When enabled, `blink add`, `brainlink_add_note` and `brainlink_add_file` add a canonical `## Context Links` entry to the inferred context hub, creating that hub when needed.
1133
1194
  `autoUpdateCheck` is optional and defaults to `true`. Brainlink checks the npm registry no more often than `updateCheckIntervalMs` and prints an update notice to `stderr`; it never installs updates automatically.
1134
1195
 
@@ -50,7 +50,7 @@ export const getExtendedStats = async (vaultPath, agentId) => {
50
50
  await searchKnowledge(absoluteVaultPath, probeQuery, Math.min(defaults.defaultSearchLimit, 8), agentId, 'hybrid');
51
51
  const searchLatency = performance.now() - searchStart;
52
52
  const contextStart = performance.now();
53
- await buildContextPackage(absoluteVaultPath, probeQuery, Math.min(defaults.defaultSearchLimit, 8), defaults.defaultContextTokens, agentId, 'hybrid', undefined, defaults.defaultContextCacheTtlMs);
53
+ await buildContextPackage(absoluteVaultPath, probeQuery, Math.min(defaults.defaultSearchLimit, 8), defaults.defaultContextTokens, agentId, 'hybrid', undefined, defaults.defaultContextCacheTtlMs, defaults.cagPackTtlMs);
54
54
  const contextLatency = performance.now() - contextStart;
55
55
  return {
56
56
  stats,
@@ -2,7 +2,7 @@ import { stat } from 'node:fs/promises';
2
2
  import { performance } from 'node:perf_hooks';
3
3
  import { formatContextPackage, selectContextSections } from '../domain/context.js';
4
4
  import { readContextPack, writeContextPack } from '../infrastructure/context-packs.js';
5
- import { indexStoragePath } from '../infrastructure/file-index.js';
5
+ import { indexCommitSignature } from '../infrastructure/index-signature.js';
6
6
  import { searchVolatileMemory, volatileMemoryStoragePath } from '../infrastructure/volatile-memory.js';
7
7
  import { searchKnowledge } from './search-knowledge.js';
8
8
  const contextCacheMaxEntries = 200;
@@ -16,7 +16,12 @@ const readFileSignature = async (path) => {
16
16
  return '0:0';
17
17
  }
18
18
  };
19
- export const readContextDataSignature = async (vaultPath) => `${await readFileSignature(indexStoragePath(vaultPath))}|${await readFileSignature(volatileMemoryStoragePath(vaultPath))}`;
19
+ export const readContextDataSignature = async (vaultPath) => `${await indexCommitSignature(vaultPath)}|${await readFileSignature(volatileMemoryStoragePath(vaultPath))}`;
20
+ // Signature of just the volatile-memory file. Disk context packs no longer gate
21
+ // on the whole-vault index signature (that invalidated every pack on any edit);
22
+ // they gate per cited document plus this volatile signature, so turn memory
23
+ // changes still refresh a pack while unrelated document edits do not.
24
+ export const readVolatileSignature = (vaultPath) => readFileSignature(volatileMemoryStoragePath(vaultPath));
20
25
  const toCacheKey = (vaultPath, query, limit, maxTokens, agentId, mode, strategy) => JSON.stringify({
21
26
  vaultPath,
22
27
  query: query.trim().toLowerCase(),
@@ -69,7 +74,7 @@ const emptyMetrics = (context, totalMs, overrides = {}) => ({
69
74
  estimatedTokens: estimateSectionTokens(context),
70
75
  ...overrides
71
76
  });
72
- export const buildContextPackage = async (vaultPath, query, limit, maxTokens, agentId, mode, strategy = 'rag', contextCacheTtlMs = 120_000) => {
77
+ export const buildContextPackage = async (vaultPath, query, limit, maxTokens, agentId, mode, strategy = 'rag', contextCacheTtlMs = 120_000, cagPackTtlMs = 0) => {
73
78
  const totalStart = performance.now();
74
79
  const cacheKey = toCacheKey(vaultPath, query, limit, maxTokens, agentId, mode, strategy);
75
80
  const dataSignature = await readContextDataSignature(vaultPath);
@@ -78,10 +83,12 @@ export const buildContextPackage = async (vaultPath, query, limit, maxTokens, ag
78
83
  return cached;
79
84
  }
80
85
  const shouldUseContextPack = strategy === 'cag' || strategy === 'auto';
86
+ const volatileSignature = await readVolatileSignature(vaultPath);
87
+ const packFreshness = { volatileSignature, ttlMs: cagPackTtlMs };
81
88
  let packReadMs = 0;
82
89
  if (shouldUseContextPack) {
83
90
  const packReadStart = performance.now();
84
- const pack = await readContextPack(vaultPath, { query, limit, maxTokens, agentId, mode }, dataSignature);
91
+ const pack = await readContextPack(vaultPath, { query, limit, maxTokens, agentId, mode }, packFreshness);
85
92
  packReadMs = elapsedMs(packReadStart);
86
93
  if (pack.status === 'hit' && pack.context.sections.length > 0) {
87
94
  const contextFromPack = {
@@ -139,7 +146,7 @@ export const buildContextPackage = async (vaultPath, query, limit, maxTokens, ag
139
146
  const hasSections = sections.length > 0;
140
147
  const packWriteStart = performance.now();
141
148
  const packPath = shouldUseContextPack && hasSections
142
- ? await writeContextPack(vaultPath, { query, limit, maxTokens, agentId, mode }, dataSignature, context)
149
+ ? await writeContextPack(vaultPath, { query, limit, maxTokens, agentId, mode }, context, { volatileSignature })
143
150
  : undefined;
144
151
  const packWriteMs = packPath ? elapsedMs(packWriteStart) : 0;
145
152
  const contextWithMetadata = withCacheMetadata(context, {
@@ -169,7 +176,7 @@ export const buildContextPackage = async (vaultPath, query, limit, maxTokens, ag
169
176
  }
170
177
  return contextWithMetrics;
171
178
  };
172
- export const buildContext = async (vaultPath, query, limit, maxTokens, agentId, mode, strategy, contextCacheTtlMs = 120_000) => {
173
- const contextPackage = await buildContextPackage(vaultPath, query, limit, maxTokens, agentId, mode, strategy, contextCacheTtlMs);
179
+ export const buildContext = async (vaultPath, query, limit, maxTokens, agentId, mode, strategy, contextCacheTtlMs = 120_000, cagPackTtlMs = 0) => {
180
+ const contextPackage = await buildContextPackage(vaultPath, query, limit, maxTokens, agentId, mode, strategy, contextCacheTtlMs, cagPackTtlMs);
174
181
  return contextPackage.content;
175
182
  };
@@ -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
  }
@@ -72,6 +72,7 @@ export const detectChanges = (params) => {
72
72
  const settingsChanged = previousState == null ||
73
73
  previousState.chunkSize !== config.chunkSize ||
74
74
  previousState.embeddingProvider !== embeddingSignature(config) ||
75
+ previousState.storageBackend !== config.storageBackend ||
75
76
  graphLinkModelChanged;
76
77
  const packSettingsChanged = previousState == null ||
77
78
  previousState.searchPackRowChunkSize !== config.searchPack.rowChunkSize ||
@@ -4,7 +4,8 @@ import { embeddingSignature, loadBrainlinkConfig } from '../infrastructure/confi
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
+ import { autoVersionVault } from './vault-git-core.js';
8
9
  import { assembleIndexedDocuments, createTitleMaps, decidePackRebuild, detectChanges, embedChangedDocuments, rebuildSearchPacksIfNeeded } from './index-vault-phases.js';
9
10
  const toIndexResult = (documents) => ({
10
11
  documentCount: documents.length,
@@ -56,7 +57,7 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
56
57
  hasPreviousState: previousState != null
57
58
  });
58
59
  const fullReindex = options.full === true;
59
- const index = openFileIndex(absoluteVaultPath);
60
+ const index = openKnowledgeStore(absoluteVaultPath);
60
61
  try {
61
62
  const existingIndexedDocuments = await index.getIndexedDocuments();
62
63
  const existingByPath = new Map(existingIndexedDocuments.map((document) => [document.document.path, document]));
@@ -162,6 +163,7 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
162
163
  await writeIndexState(absoluteVaultPath, {
163
164
  chunkSize: config.chunkSize,
164
165
  embeddingProvider: embeddingSignature(config),
166
+ storageBackend: config.storageBackend,
165
167
  graphLinkModelVersion,
166
168
  searchPackRowChunkSize: config.searchPack.rowChunkSize,
167
169
  searchPackCompressionLevel: config.searchPack.compressionLevel,
@@ -169,6 +171,18 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
169
171
  files: currentSnapshot,
170
172
  pendingPackChanges: packsRebuilt ? 0 : pendingPackChanges
171
173
  });
174
+ // Auto-version the vault when enabled and Markdown actually changed. Runs
175
+ // after the index is committed, is best-effort (a git/network failure never
176
+ // breaks indexing), and is a no-op unless the vault is a git repo with an
177
+ // origin remote — commitVault itself skips when nothing is staged.
178
+ if (config.autoVersion && changedDocumentsByPath.size > 0) {
179
+ try {
180
+ await autoVersionVault(absoluteVaultPath, `chore(vault): update ${new Date().toISOString()}`);
181
+ }
182
+ catch (error) {
183
+ process.stderr.write(`brainlink: auto-version skipped (${error instanceof Error ? error.message : String(error)})\n`);
184
+ }
185
+ }
172
186
  const result = {
173
187
  ...toIndexResult(indexedDocuments),
174
188
  elapsedMs: elapsedMs(),
@@ -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
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ // Provision a private GitHub repository for the vault using the already
2
+ // authenticated `gh` CLI, wire it as origin, push the first snapshot, and enable
3
+ // auto-versioning so later changes are committed and pushed automatically. Only
4
+ // the canonical Markdown is versioned; the derived index stays git-ignored.
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import { loadRawConfig, writeRawConfig } from '../infrastructure/config.js';
8
+ import { commitVault, hasRemote, initVaultGit, pushVault } from './vault-git-core.js';
9
+ const execFileAsync = promisify(execFile);
10
+ export class GhUnavailableError extends Error {
11
+ constructor() {
12
+ super('gh executable not found. Install and authenticate the GitHub CLI (gh auth login) to provision a vault repo.');
13
+ this.name = 'GhUnavailableError';
14
+ }
15
+ }
16
+ export class GhCommandError extends Error {
17
+ constructor(args, stderr) {
18
+ super(`gh ${args.join(' ')} failed: ${stderr.trim() || 'unknown error'}`);
19
+ this.name = 'GhCommandError';
20
+ }
21
+ }
22
+ const isMissingBinary = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
23
+ const runGh = async (cwd, args) => {
24
+ try {
25
+ const { stdout } = await execFileAsync('gh', [...args], { cwd, maxBuffer: 16 * 1024 * 1024 });
26
+ return stdout;
27
+ }
28
+ catch (error) {
29
+ if (isMissingBinary(error)) {
30
+ throw new GhUnavailableError();
31
+ }
32
+ const stderr = error.stderr ?? (error instanceof Error ? error.message : String(error));
33
+ throw new GhCommandError(args, stderr);
34
+ }
35
+ };
36
+ // Fail early with an actionable message if gh is not authenticated, rather than
37
+ // letting `repo create` fail deep in the flow.
38
+ const assertGhAuthenticated = async (cwd) => {
39
+ await runGh(cwd, ['auth', 'status']);
40
+ };
41
+ // Persist config toggles to the global config. Global scope matches the common
42
+ // case of a single default vault; a project-local vault can set these in its own
43
+ // config. Written BEFORE the first commit so the initial snapshot is encrypted
44
+ // and the gitignore is correct from the start.
45
+ const mergeGlobalConfig = async (patch) => {
46
+ const current = await loadRawConfig('global');
47
+ await writeRawConfig('global', { ...current, ...patch });
48
+ };
49
+ export const provisionVaultRepo = async (vaultPath, options = {}) => {
50
+ const name = options.name ?? 'brainlink-vault';
51
+ const enableAutoVersion = options.enableAutoVersion !== false;
52
+ const encrypt = options.encrypt !== false;
53
+ // Toggle config first so initVaultGit/commitVault encrypt from the very first
54
+ // snapshot and git-ignore the plaintext Markdown.
55
+ await mergeGlobalConfig({
56
+ ...(encrypt ? { vaultEncryption: true } : {}),
57
+ ...(enableAutoVersion ? { autoVersion: true } : {})
58
+ });
59
+ const { root } = await initVaultGit(vaultPath);
60
+ const committed = await commitVault(vaultPath, options.message ?? 'chore(vault): initial snapshot');
61
+ // A pre-existing origin means the repo is already provisioned: just push the
62
+ // current state. Only creating a new repository needs gh, so the reuse path
63
+ // works without it.
64
+ const created = !(await hasRemote(root));
65
+ if (created) {
66
+ await assertGhAuthenticated(root);
67
+ const visibility = options.private === false ? '--public' : '--private';
68
+ await runGh(root, ['repo', 'create', name, visibility, '--source', root, '--remote', 'origin', '--push']);
69
+ }
70
+ else {
71
+ await pushVault(vaultPath);
72
+ }
73
+ return { repo: name, created, committed, pushed: true, autoVersionEnabled: enableAutoVersion, encrypted: encrypt };
74
+ };
@@ -0,0 +1,212 @@
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
+ // Rank-fusion damping constant (Cormack et al.). 60 is the field-standard value:
37
+ // large enough that the top ranks are not wildly separated, small enough that a
38
+ // document ranked highly by one retriever still clearly outweighs low ranks.
39
+ const rrfConstant = 60;
40
+ const normalizeFields = (row) => ({
41
+ title: normalizeToken(row.title),
42
+ path: normalizeToken(row.path),
43
+ content: normalizeToken(row.content),
44
+ tags: normalizeToken(row.tags.join(' '))
45
+ });
46
+ // Field-weighted term frequency: title/tag/path matches count more than body
47
+ // matches, and body hits saturate at a cap so a term repeated many times in one
48
+ // chunk cannot dominate. This weighted frequency feeds BM25 below.
49
+ const weightedTermFrequency = (fields, token) => {
50
+ const titleHits = countOccurrences(fields.title, token);
51
+ const tagHits = countOccurrences(fields.tags, token);
52
+ const pathHits = countOccurrences(fields.path, token);
53
+ const contentHits = countOccurrences(fields.content, token);
54
+ return (titleHits * titleFieldWeight +
55
+ tagHits * tagFieldWeight +
56
+ pathHits * pathFieldWeight +
57
+ Math.min(contentHits, contentHitCap));
58
+ };
59
+ // Naive additive field score retained for graph-node title/path lookup, where
60
+ // the candidate set is documents (not chunks) and corpus-wide BM25 statistics
61
+ // add no signal over a direct weighted hit count.
62
+ export const lexicalFieldScore = (row, tokens) => {
63
+ if (tokens.length === 0) {
64
+ return 0;
65
+ }
66
+ const fields = normalizeFields(row);
67
+ return tokens.reduce((score, token) => score + weightedTermFrequency(fields, token), 0);
68
+ };
69
+ // A single chunk with a non-finite cached tokenCount (legacy index, partial
70
+ // write, manual edit) would otherwise poison the shared averageLength and zero
71
+ // out lexical scoring for the whole corpus, so coerce to a safe positive length.
72
+ const safeDocumentLength = (tokenCount) => Number.isFinite(tokenCount) && tokenCount > 0 ? tokenCount : 1;
73
+ const buildCorpusStatistics = (prepared, tokens) => {
74
+ const documentCount = prepared.length;
75
+ const totalLength = prepared.reduce((sum, entry) => sum + safeDocumentLength(entry.row.tokenCount), 0);
76
+ const documentFrequencyByTerm = new Map();
77
+ for (const term of tokens) {
78
+ let documentFrequency = 0;
79
+ for (const entry of prepared) {
80
+ if ((entry.weightedTermFrequencies.get(term) ?? 0) > 0) {
81
+ documentFrequency += 1;
82
+ }
83
+ }
84
+ documentFrequencyByTerm.set(term, documentFrequency);
85
+ }
86
+ return {
87
+ documentCount,
88
+ averageLength: documentCount > 0 ? totalLength / documentCount : 0,
89
+ documentFrequencyByTerm
90
+ };
91
+ };
92
+ const bm25TextScore = (entry, tokens, stats) => tokens.reduce((score, term) => score +
93
+ bm25TermScore({
94
+ termFrequency: entry.weightedTermFrequencies.get(term) ?? 0,
95
+ documentLength: safeDocumentLength(entry.row.tokenCount),
96
+ documentCount: stats.documentCount,
97
+ documentFrequency: stats.documentFrequencyByTerm.get(term) ?? 0,
98
+ averageLength: stats.averageLength
99
+ }), 0);
100
+ const semanticScore = (row, queryEmbedding) => queryEmbedding.length > 0 && row.embedding.length > 0 ? dotProduct(queryEmbedding, row.embedding) : 0;
101
+ const rangeOf = (values) => values.reduce((range, value) => ({ min: Math.min(range.min, value), max: Math.max(range.max, value) }), { min: Infinity, max: -Infinity });
102
+ const toResult = (row, mode, score, text, semantic) => ({
103
+ documentId: row.documentId,
104
+ agentId: row.agentId,
105
+ title: row.title,
106
+ path: row.path,
107
+ chunkId: row.chunkId,
108
+ chunkOrdinal: row.chunkOrdinal,
109
+ content: row.content,
110
+ score,
111
+ textScore: text,
112
+ semanticScore: semantic,
113
+ tokenCount: row.tokenCount,
114
+ searchMode: mode,
115
+ tags: row.tags
116
+ });
117
+ // Reciprocal Rank Fusion: rank the candidates independently by each component
118
+ // (lexical, semantic) and fuse by rank position, `Σ 1/(k + rank)`. Because it
119
+ // consumes ranks rather than raw magnitudes it is immune to the scale mismatch
120
+ // between unbounded BM25 and bounded cosine, so a strong lexical hit and a
121
+ // strong semantic hit contribute comparably without hand-tuned weights. A
122
+ // candidate absent from a component's ranking (zero score there) simply
123
+ // contributes nothing for that component. Ties break by title to stay
124
+ // deterministic, matching the final ordering.
125
+ const reciprocalRankFusion = (entries) => {
126
+ const ranksByField = (component) => {
127
+ const ranked = entries
128
+ .filter((entry) => component(entry) > 0)
129
+ .slice()
130
+ .sort((left, right) => component(right) - component(left) || left.row.title.localeCompare(right.row.title));
131
+ return new Map(ranked.map((entry, index) => [entry, index + 1]));
132
+ };
133
+ const textRanks = ranksByField((entry) => entry.text);
134
+ const semanticRanks = ranksByField((entry) => entry.semantic);
135
+ return new Map(entries.map((entry) => {
136
+ const textRank = textRanks.get(entry);
137
+ const semanticRank = semanticRanks.get(entry);
138
+ const score = (textRank ? 1 / (rrfConstant + textRank) : 0) + (semanticRank ? 1 / (rrfConstant + semanticRank) : 0);
139
+ return [entry, score];
140
+ }));
141
+ };
142
+ // Rank an already-selected candidate set. Backends own candidate selection
143
+ // (full scan, bucket prefilter, ANN); this function owns scoring and ordering so
144
+ // the result is identical regardless of how the candidates were gathered.
145
+ export const runSearch = (candidates, query, limit, mode, queryEmbedding = [], fusion = 'rrf') => {
146
+ const tokens = tokenize(query);
147
+ // Pass 1: field-weighted term frequencies per candidate, then corpus-wide
148
+ // statistics (document frequency, average length) needed for BM25 IDF and
149
+ // length normalization.
150
+ const prepared = candidates.map((row) => {
151
+ const fields = normalizeFields(row);
152
+ const weightedTermFrequencies = new Map();
153
+ for (const term of tokens) {
154
+ const frequency = weightedTermFrequency(fields, term);
155
+ if (frequency > 0) {
156
+ weightedTermFrequencies.set(term, frequency);
157
+ }
158
+ }
159
+ return { row, weightedTermFrequencies };
160
+ });
161
+ const stats = buildCorpusStatistics(prepared, tokens);
162
+ // Pass 2: raw lexical (BM25) and semantic (cosine) component scores.
163
+ const componentScored = prepared.map((entry) => ({
164
+ row: entry.row,
165
+ text: tokens.length > 0 ? bm25TextScore(entry, tokens, stats) : 0,
166
+ semantic: semanticScore(entry.row, queryEmbedding)
167
+ }));
168
+ // Filter on raw relevance before normalization so a legitimate match that
169
+ // happens to be the weakest in its mode is never min-max scaled to zero
170
+ // and dropped. fts stays lexical-only and semantic stays vector-only.
171
+ const relevant = componentScored.filter((entry) => {
172
+ if (tokens.length === 0) {
173
+ return true;
174
+ }
175
+ if (mode === 'fts') {
176
+ return entry.text > 0;
177
+ }
178
+ if (mode === 'semantic') {
179
+ return entry.semantic > 0;
180
+ }
181
+ return entry.text > 0 || entry.semantic > 0;
182
+ });
183
+ if (relevant.length === 0) {
184
+ return [];
185
+ }
186
+ const textRange = rangeOf(relevant.map((entry) => entry.text));
187
+ const semanticRange = rangeOf(relevant.map((entry) => entry.semantic));
188
+ // Rank-fused hybrid scores are precomputed once over the whole candidate set
189
+ // (RRF needs each component's global ranking); other modes score per entry.
190
+ const rrfScores = mode === 'hybrid' && fusion === 'rrf' ? reciprocalRankFusion(relevant) : null;
191
+ const finalScore = (entry) => {
192
+ if (mode === 'fts') {
193
+ return entry.text;
194
+ }
195
+ if (mode === 'semantic') {
196
+ return entry.semantic;
197
+ }
198
+ if (rrfScores) {
199
+ return rrfScores.get(entry) ?? 0;
200
+ }
201
+ // Linear hybrid mixes the two components on a shared [0, 1] scale so neither
202
+ // the unbounded BM25 magnitude nor the bounded cosine value can swamp the
203
+ // other, replacing the previous fixed `text + semantic * 8` blend.
204
+ const textNorm = minMaxNormalize(entry.text, textRange.min, textRange.max);
205
+ const semanticNorm = minMaxNormalize(entry.semantic, semanticRange.min, semanticRange.max);
206
+ return textNorm * hybridTextWeight + semanticNorm * hybridSemanticWeight;
207
+ };
208
+ return relevant
209
+ .map((entry) => toResult(entry.row, mode, finalScore(entry), entry.text, entry.semantic))
210
+ .sort((left, right) => right.score - left.score || left.title.localeCompare(right.title))
211
+ .slice(0, Math.max(0, limit));
212
+ };