@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.
- package/CHANGELOG.md +15 -1
- package/README.md +31 -5
- package/dist/application/analyze-vault.js +1 -1
- package/dist/application/build-context.js +14 -7
- package/dist/application/index-vault-phases.js +1 -0
- package/dist/application/index-vault.js +14 -0
- package/dist/application/provision-vault.js +74 -0
- package/dist/application/ranking/run-search.js +38 -3
- package/dist/application/search-knowledge.js +7 -16
- package/dist/application/server/routes.js +6 -1
- package/dist/application/vault-encryption.js +64 -0
- package/dist/application/vault-git-core.js +168 -0
- package/dist/application/vault-git.js +36 -141
- package/dist/cli/commands/read-commands.js +9 -5
- package/dist/cli/commands/vault-sync-commands.js +22 -0
- package/dist/cli/commands/write/vault-lifecycle-commands.js +1 -1
- package/dist/infrastructure/config.js +13 -2
- package/dist/infrastructure/context-packs.js +57 -18
- package/dist/infrastructure/file-index.js +2 -2
- package/dist/infrastructure/index-signature.js +27 -0
- package/dist/infrastructure/index-state.js +6 -0
- package/dist/infrastructure/knowledge-store/binary-store.js +173 -3
- package/dist/infrastructure/knowledge-store/index.js +3 -2
- package/dist/infrastructure/knowledge-store/stored-index.js +2 -2
- package/dist/infrastructure/vault-crypto.js +78 -0
- package/dist/mcp/server-instructions.js +22 -0
- package/dist/mcp/server.js +48 -2
- package/dist/mcp/tools/maintenance-tools.js +2 -2
- package/dist/mcp/tools/read-tools.js +8 -4
- package/dist/mcp/tools/vault-tools.js +163 -0
- package/dist/mcp/tools.js +1 -0
- package/docs/AGENT_USAGE.md +4 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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`,
|
|
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
|
-
- `
|
|
767
|
-
- `
|
|
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": "
|
|
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.
|
|
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 {
|
|
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
|
|
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 },
|
|
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 },
|
|
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
|
-
|
|
167
|
-
|
|
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 {
|
|
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,
|
|
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.
|
|
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
|
|
63
|
-
const cached = cacheKey ? cacheGet(cacheKey,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|