@andespindola/brainlink 1.1.0 → 1.3.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 (33) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/README.md +31 -5
  3. package/dist/application/analyze-vault.js +1 -1
  4. package/dist/application/build-context.js +14 -7
  5. package/dist/application/index-vault-phases.js +1 -0
  6. package/dist/application/index-vault.js +14 -0
  7. package/dist/application/provision-vault.js +74 -0
  8. package/dist/application/ranking/run-search.js +38 -3
  9. package/dist/application/search-knowledge.js +7 -16
  10. package/dist/application/server/routes.js +6 -1
  11. package/dist/application/vault-encryption.js +64 -0
  12. package/dist/application/vault-git-core.js +168 -0
  13. package/dist/application/vault-git.js +36 -141
  14. package/dist/cli/commands/read-commands.js +9 -5
  15. package/dist/cli/commands/vault-sync-commands.js +22 -0
  16. package/dist/cli/commands/write/vault-lifecycle-commands.js +1 -1
  17. package/dist/infrastructure/config.js +13 -2
  18. package/dist/infrastructure/context-packs.js +57 -18
  19. package/dist/infrastructure/file-index.js +2 -2
  20. package/dist/infrastructure/index-signature.js +27 -0
  21. package/dist/infrastructure/index-state.js +6 -0
  22. package/dist/infrastructure/knowledge-store/binary-store.js +173 -3
  23. package/dist/infrastructure/knowledge-store/index.js +3 -2
  24. package/dist/infrastructure/knowledge-store/stored-index.js +2 -2
  25. package/dist/infrastructure/vault-crypto.js +78 -0
  26. package/dist/mcp/server-instructions.js +22 -0
  27. package/dist/mcp/server.js +48 -2
  28. package/dist/mcp/tools/maintenance-tools.js +2 -2
  29. package/dist/mcp/tools/read-tools.js +8 -4
  30. package/dist/mcp/tools/vault-tools.js +163 -0
  31. package/dist/mcp/tools.js +1 -0
  32. package/docs/AGENT_USAGE.md +4 -0
  33. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,6 +1,20 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 1.3.0
4
+
5
+ - **MCP server instructions on connect.** The server now advertises `instructions` in its initialize response, so clients that surface them (e.g. Claude Code) inject Brainlink's memory discipline into the agent's context the moment it connects — treat Brainlink as the default context source for everything, bootstrap first with a task query, use the full toolkit (both RAG and CAG via `strategy: auto`, context packs, hybrid search, the knowledge graph, explain/recommendations/validate/stats) instead of shallow retrieval, keep ephemeral facts in volatile memory, and persist durable learnings as connected notes. The field is advisory (clients that ignore it are unaffected) and no tool contract changed.
6
+
7
+ ## 1.2.0
8
+
9
+ - **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.
10
+ - **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.
11
+ - **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.
12
+ - **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.
13
+ - **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.
14
+ - **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).
15
+ - **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.
16
+
17
+ ## 1.1.0
4
18
 
5
19
  - 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
20
  - 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.
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.
@@ -763,11 +766,24 @@ Use `--global` to write to `$BRAINLINK_HOME/brainlink.config.json`, `--no-migrat
763
766
 
764
767
  `storageBackend` selects how the derived index is stored (the canonical Markdown is unaffected):
765
768
 
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.
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.
768
771
 
769
772
  ```json
770
- { "storageBackend": "binary" }
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" }
771
787
  ```
772
788
 
773
789
  ### `vaults`
@@ -930,6 +946,12 @@ The derived `.brainlink/` directory is never versioned; any operation that chang
930
946
  Markdown reindexes automatically so the vault stays immediately usable.
931
947
 
932
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
+
933
955
  # Git: history, remotes, restore
934
956
  blink vault-init-git --remote git@github.com:you/your-vault.git
935
957
  blink vault-commit -m "snapshot notes"
@@ -990,7 +1012,9 @@ blink context-packs --vault ./vault --stale --clear
990
1012
 
991
1013
  Builds a compact context package for an agent.
992
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.
993
- 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.
994
1018
 
995
1019
  ### `links`
996
1020
 
@@ -1164,6 +1188,8 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
1164
1188
  `agentProfiles` is optional. When present, CLI and MCP resolve `mode`, `limit`, `tokens` and context `strategy` per agent automatically, then fallback to global defaults.
1165
1189
 
1166
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.
1167
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.
1168
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.
1169
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
  };
@@ -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 ||
@@ -5,6 +5,7 @@ import { ensureVault, readMarkdownFileSummaries } from '../infrastructure/file-s
5
5
  import { readIndexState, writeIndexState } from '../infrastructure/index-state.js';
6
6
  import { ensureSearchPackManifest } from '../infrastructure/search-packs.js';
7
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,
@@ -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(),
@@ -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
+ };
@@ -33,6 +33,10 @@ const pathFieldWeight = 2;
33
33
  const contentHitCap = 6;
34
34
  const hybridTextWeight = 0.6;
35
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;
36
40
  const normalizeFields = (row) => ({
37
41
  title: normalizeToken(row.title),
38
42
  path: normalizeToken(row.path),
@@ -110,10 +114,35 @@ const toResult = (row, mode, score, text, semantic) => ({
110
114
  searchMode: mode,
111
115
  tags: row.tags
112
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
+ };
113
142
  // Rank an already-selected candidate set. Backends own candidate selection
114
143
  // (full scan, bucket prefilter, ANN); this function owns scoring and ordering so
115
144
  // the result is identical regardless of how the candidates were gathered.
116
- export const runSearch = (candidates, query, limit, mode, queryEmbedding = []) => {
145
+ export const runSearch = (candidates, query, limit, mode, queryEmbedding = [], fusion = 'rrf') => {
117
146
  const tokens = tokenize(query);
118
147
  // Pass 1: field-weighted term frequencies per candidate, then corpus-wide
119
148
  // statistics (document frequency, average length) needed for BM25 IDF and
@@ -156,6 +185,9 @@ export const runSearch = (candidates, query, limit, mode, queryEmbedding = []) =
156
185
  }
157
186
  const textRange = rangeOf(relevant.map((entry) => entry.text));
158
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;
159
191
  const finalScore = (entry) => {
160
192
  if (mode === 'fts') {
161
193
  return entry.text;
@@ -163,8 +195,11 @@ export const runSearch = (candidates, query, limit, mode, queryEmbedding = []) =
163
195
  if (mode === 'semantic') {
164
196
  return entry.semantic;
165
197
  }
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
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
168
203
  // other, replacing the previous fixed `text + semantic * 8` blend.
169
204
  const textNorm = minMaxNormalize(entry.text, textRange.min, textRange.max);
170
205
  const semanticNorm = minMaxNormalize(entry.semantic, semanticRange.min, semanticRange.max);
@@ -1,7 +1,6 @@
1
- import { stat } from 'node:fs/promises';
2
1
  import { ensureVault } from '../infrastructure/file-system-vault.js';
3
2
  import { ensurePrivatePacksFromLegacyIndex, searchInPacks } from '../infrastructure/search-packs.js';
4
- import { indexStoragePath } from '../infrastructure/file-index.js';
3
+ import { indexCommitSignature } from '../infrastructure/index-signature.js';
5
4
  import { openKnowledgeStore } from '../infrastructure/knowledge-store/index.js';
6
5
  import { createEmbeddingProvider } from '../domain/embeddings.js';
7
6
  import { loadBrainlinkConfig, sanitizeSearchMode } from '../infrastructure/config.js';
@@ -18,26 +17,18 @@ const embedQuerySafely = async (provider, query) => {
18
17
  const hybridCacheTtlMs = 30_000;
19
18
  const hybridCacheMaxEntries = 200;
20
19
  const hybridSearchCache = new Map();
21
- const readIndexMtimeMs = async (vaultPath) => {
22
- try {
23
- return (await stat(indexStoragePath(vaultPath))).mtimeMs;
24
- }
25
- catch {
26
- return 0;
27
- }
28
- };
29
20
  const toCacheKey = (vaultPath, query, limit, agentId) => JSON.stringify({
30
21
  vaultPath,
31
22
  query: query.trim().toLowerCase(),
32
23
  limit,
33
24
  agentId: agentId?.trim().toLowerCase() ?? '*'
34
25
  });
35
- const cacheGet = (key, indexMtimeMs) => {
26
+ const cacheGet = (key, indexSignature) => {
36
27
  const entry = hybridSearchCache.get(key);
37
28
  if (!entry) {
38
29
  return undefined;
39
30
  }
40
- const fresh = Date.now() - entry.createdAt <= hybridCacheTtlMs && entry.indexMtimeMs === indexMtimeMs;
31
+ const fresh = Date.now() - entry.createdAt <= hybridCacheTtlMs && entry.indexSignature === indexSignature;
41
32
  if (!fresh) {
42
33
  hybridSearchCache.delete(key);
43
34
  return undefined;
@@ -59,8 +50,8 @@ export const searchKnowledge = async (vaultPath, query, limit, agentId, mode) =>
59
50
  const searchMode = sanitizeSearchMode(mode, config.defaultSearchMode);
60
51
  await ensurePrivatePacksFromLegacyIndex(absoluteVaultPath);
61
52
  const cacheKey = searchMode === 'hybrid' ? toCacheKey(absoluteVaultPath, query, limit, agentId) : undefined;
62
- const indexMtimeMs = cacheKey ? await readIndexMtimeMs(absoluteVaultPath) : 0;
63
- const cached = cacheKey ? cacheGet(cacheKey, indexMtimeMs) : undefined;
53
+ const indexSignature = cacheKey ? await indexCommitSignature(absoluteVaultPath) : '';
54
+ const cached = cacheKey ? cacheGet(cacheKey, indexSignature) : undefined;
64
55
  if (cached) {
65
56
  return cached;
66
57
  }
@@ -81,7 +72,7 @@ export const searchKnowledge = async (vaultPath, query, limit, agentId, mode) =>
81
72
  cacheSet({
82
73
  key: cacheKey,
83
74
  createdAt: Date.now(),
84
- indexMtimeMs,
75
+ indexSignature,
85
76
  results
86
77
  });
87
78
  }
@@ -97,7 +88,7 @@ export const searchKnowledge = async (vaultPath, query, limit, agentId, mode) =>
97
88
  cacheSet({
98
89
  key: cacheKey,
99
90
  createdAt: Date.now(),
100
- indexMtimeMs,
91
+ indexSignature,
101
92
  results: fallbackResults
102
93
  });
103
94
  }
@@ -40,6 +40,10 @@ const readContextCacheTtlMs = async (url) => {
40
40
  const defaults = await readRuntimeDefaults(url);
41
41
  return defaults.defaultContextCacheTtlMs;
42
42
  };
43
+ const readCagPackTtlMs = async (url) => {
44
+ const defaults = await readRuntimeDefaults(url);
45
+ return defaults.cagPackTtlMs;
46
+ };
43
47
  const hasInvalidSearchMode = (url) => {
44
48
  const mode = url.searchParams.get('mode');
45
49
  return mode !== null && !['fts', 'semantic', 'hybrid'].includes(mode);
@@ -445,13 +449,14 @@ export const route = async (request, url, vaultPath) => {
445
449
  const mode = await readSearchMode(url);
446
450
  const strategy = await readContextStrategy(url);
447
451
  const contextCacheTtlMs = await readContextCacheTtlMs(url);
452
+ const cagPackTtlMs = await readCagPackTtlMs(url);
448
453
  if (hasInvalidSearchMode(url)) {
449
454
  return createResponse(createJsonResponse({ error: 'Invalid mode. Use fts, semantic or hybrid.' }), 400, contentTypes['.json']);
450
455
  }
451
456
  if (hasInvalidContextStrategy(url)) {
452
457
  return createResponse(createJsonResponse({ error: 'Invalid strategy. Use rag, cag or auto.' }), 400, contentTypes['.json']);
453
458
  }
454
- return createResponse(createJsonResponse(await buildContextPackage(vaultPath, query, limit, tokens, readAgentQuery(url), mode, strategy, contextCacheTtlMs)), 200, contentTypes['.json']);
459
+ return createResponse(createJsonResponse(await buildContextPackage(vaultPath, query, limit, tokens, readAgentQuery(url), mode, strategy, contextCacheTtlMs, cagPackTtlMs)), 200, contentTypes['.json']);
455
460
  }
456
461
  if (isReadMethod(request) && url.pathname === '/api/links') {
457
462
  return createResponse(createJsonResponse({ links: await listLinks(vaultPath, readAgentQuery(url)) }), 200, contentTypes['.json']);
@@ -0,0 +1,64 @@
1
+ // Encrypted vault versioning: the git repo tracks `<note>.md.enc` ciphertext
2
+ // (opaque without the local key), while the plaintext `.md` stays the canonical
3
+ // working copy and is git-ignored. Committing refreshes the ciphertext mirror;
4
+ // pulling/cloning/restoring decrypts it back to Markdown before reindexing. Only
5
+ // content that changed is rewritten, so encryption adds no git churn.
6
+ import { readFile, rm, writeFile } from 'node:fs/promises';
7
+ import { extname } from 'node:path';
8
+ import { loadBrainlinkConfig } from '../infrastructure/config.js';
9
+ import { ensureVault, listVaultFiles } from '../infrastructure/file-system-vault.js';
10
+ import { decryptVaultContent, encryptVaultContent, readOrCreateVaultKey } from '../infrastructure/vault-crypto.js';
11
+ const encryptedSuffix = '.md.enc';
12
+ const isMarkdown = (path) => extname(path).toLowerCase() === '.md';
13
+ const isEncryptedMarkdown = (path) => path.toLowerCase().endsWith(encryptedSuffix);
14
+ export const vaultEncryptionEnabled = async () => (await loadBrainlinkConfig()).vaultEncryption === true;
15
+ // Refresh the ciphertext mirror: (re)write `<note>.md.enc` for every Markdown
16
+ // file whose ciphertext is missing or stale, and drop orphan `.md.enc` whose
17
+ // source Markdown was deleted. Deterministic encryption means an unchanged note
18
+ // produces identical bytes, so it is skipped (no write, no git churn).
19
+ export const encryptVaultTree = async (vaultPath) => {
20
+ const root = await ensureVault(vaultPath);
21
+ const key = await readOrCreateVaultKey(root);
22
+ const files = await listVaultFiles(root);
23
+ const markdown = files.filter(isMarkdown);
24
+ const expected = new Set(markdown.map((path) => `${path}.enc`));
25
+ let written = 0;
26
+ for (const source of markdown) {
27
+ const payload = encryptVaultContent(key, await readFile(source));
28
+ const target = `${source}.enc`;
29
+ let current = null;
30
+ try {
31
+ current = await readFile(target);
32
+ }
33
+ catch {
34
+ current = null;
35
+ }
36
+ if (!current || !current.equals(payload)) {
37
+ await writeFile(target, payload, { mode: 0o600 });
38
+ written += 1;
39
+ }
40
+ }
41
+ let removed = 0;
42
+ for (const encrypted of files.filter(isEncryptedMarkdown)) {
43
+ if (!expected.has(encrypted)) {
44
+ await rm(encrypted, { force: true });
45
+ removed += 1;
46
+ }
47
+ }
48
+ return { written, removed };
49
+ };
50
+ // Rebuild plaintext Markdown from the ciphertext mirror after a checkout. Fails
51
+ // loudly if the key cannot decrypt a payload (wrong/missing key) rather than
52
+ // writing garbage.
53
+ export const decryptVaultTree = async (vaultPath) => {
54
+ const root = await ensureVault(vaultPath);
55
+ const key = await readOrCreateVaultKey(root);
56
+ const encrypted = (await listVaultFiles(root)).filter(isEncryptedMarkdown);
57
+ let written = 0;
58
+ for (const source of encrypted) {
59
+ const plaintext = decryptVaultContent(key, await readFile(source));
60
+ await writeFile(source.slice(0, -'.enc'.length), plaintext, { mode: 0o600 });
61
+ written += 1;
62
+ }
63
+ return { written };
64
+ };