@andespindola/brainlink 1.0.7 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Added an optional **binary storage backend** (`storageBackend: "binary"`) that stores chunk embeddings as dense little-endian Float32 (`vectors.f32`) and document/chunk/link metadata as Brotli-compressed JSON, cutting the on-disk index ~75% on a typical vault with much cheaper loads than parsing decimal-text vectors. The graph/search/link reads now run through a shared query core so the JSON and binary backends are behavior-identical for the same candidates. Switching to `binary` converts an existing `index.json` in place on first use (no re-embedding) and falls back to it for reads until the binary store is materialized. The canonical Markdown is unaffected and the index stays fully regenerable.
6
+ - Introduced a `KnowledgeStore` port and a shared, backend-agnostic ranker, so the index storage layout can evolve without touching the application layer and every backend produces an identical ranking.
7
+ - Added **vault versioning** for the canonical Markdown. Git: `vault-init-git`, `vault-commit`, `vault-status`, `vault-history`, `vault-push`, `vault-pull`, `vault-restore`, `vault-clone` (driven through the system `git` binary; the derived `.brainlink/` is git-ignored). Portable snapshots: `vault-export`/`vault-import` produce and restore a self-contained gzip `.blvault` with per-file SHA-256. Every operation that changes Markdown reindexes automatically so the vault is immediately usable, merge conflicts abort cleanly instead of leaving markers in Markdown, conflicting local edits are preserved as `*.conflict-<ts>.md`, and remote URLs with inline credentials are rejected.
8
+ - Pinned the transitive `fast-uri` dependency to `>=4.1.0` to clear a high-severity Snyk advisory affecting the entire 3.x line.
9
+
5
10
  - Added `brainlink_similar_notes` MCP tool: find the notes most similar to a given note (selected by title or path), using the note's own content as the retrieval query and excluding the note itself.
6
11
  - Added batch write tools `brainlink_add_notes` and `brainlink_delete_notes` that write or delete up to 100 notes in a single call and reindex once for the whole batch; batch deletion reports per-note failures without aborting the batch.
7
12
 
package/README.md CHANGED
@@ -759,6 +759,17 @@ When the configured default vault is changed manually in config files, Brainlink
759
759
  Use `--global` to write to `$BRAINLINK_HOME/brainlink.config.json`, `--no-migrate` to skip migration, and `--no-index` to skip post-migration indexing.
760
760
  `config doctor` is dry-run by default; use `--fix` to apply safe config normalization and allowlist fixes.
761
761
 
762
+ #### Storage backend
763
+
764
+ `storageBackend` selects how the derived index is stored (the canonical Markdown is unaffected):
765
+
766
+ - `json` (default) — the original single `index.json`.
767
+ - `binary` — Float32 vectors plus Brotli-compressed metadata; a smaller, faster-to-load index (~75% smaller on a typical vault). Switching is seamless: an existing `index.json` is converted in place on first use (no re-embedding) and reads fall back to it until then.
768
+
769
+ ```json
770
+ { "storageBackend": "binary" }
771
+ ```
772
+
762
773
  ### `vaults`
763
774
 
764
775
  ```bash
@@ -912,6 +923,30 @@ blink pack-backup --vault ./vault --json
912
923
  Creates an offline backup artifact of encrypted search packs with a second compression pass.
913
924
  This is intentionally outside the online retrieval path (`index`, `search`, `context`).
914
925
 
926
+ ### Vault Versioning
927
+
928
+ Version the canonical Markdown with git, or move a vault as a single portable file.
929
+ The derived `.brainlink/` directory is never versioned; any operation that changes
930
+ Markdown reindexes automatically so the vault stays immediately usable.
931
+
932
+ ```bash
933
+ # Git: history, remotes, restore
934
+ blink vault-init-git --remote git@github.com:you/your-vault.git
935
+ blink vault-commit -m "snapshot notes"
936
+ blink vault-push
937
+ blink vault-pull # pulls and reindexes changed notes
938
+ blink vault-history --limit 20
939
+ blink vault-restore <commit> # restore Markdown from a ref and reindex
940
+ blink vault-clone git@github.com:you/your-vault.git ./vault # clone + build index
941
+ blink vault-status
942
+
943
+ # Portable snapshot (self-contained, index excluded; import rebuilds it)
944
+ blink vault-export -o vault.blvault
945
+ blink vault-import vault.blvault # conflicting local edits preserved as *.conflict-<ts>.md
946
+ ```
947
+
948
+ Inline credentials in a remote URL are rejected — use SSH or a git credential helper.
949
+
915
950
  ### `agents`
916
951
 
917
952
  ```bash
@@ -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
  }
@@ -4,7 +4,7 @@ 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
8
  import { assembleIndexedDocuments, createTitleMaps, decidePackRebuild, detectChanges, embedChangedDocuments, rebuildSearchPacksIfNeeded } from './index-vault-phases.js';
9
9
  const toIndexResult = (documents) => ({
10
10
  documentCount: documents.length,
@@ -56,7 +56,7 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
56
56
  hasPreviousState: previousState != null
57
57
  });
58
58
  const fullReindex = options.full === true;
59
- const index = openFileIndex(absoluteVaultPath);
59
+ const index = openKnowledgeStore(absoluteVaultPath);
60
60
  try {
61
61
  const existingIndexedDocuments = await index.getIndexedDocuments();
62
62
  const existingByPath = new Map(existingIndexedDocuments.map((document) => [document.document.path, document]));
@@ -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,177 @@
1
+ // Backend-agnostic ranking. Given a candidate set (chunks already selected by a
2
+ // storage backend), this module owns the deterministic scoring math: field
3
+ // weighting, BM25 lexical relevance, cosine semantic relevance, hybrid fusion,
4
+ // and stable ordering. Keeping it here means every KnowledgeStore backend
5
+ // (JSON, binary, LMDB) produces an identical ranking for the same candidates.
6
+ import { dotProduct } from '../../domain/embeddings.js';
7
+ import { bm25TermScore, minMaxNormalize } from '../../domain/scoring.js';
8
+ const queryTokenPattern = /[\p{L}\p{N}_-]+/gu;
9
+ export const normalizeToken = (value) => value
10
+ .normalize('NFKD')
11
+ .replace(/\p{Diacritic}/gu, '')
12
+ .toLowerCase();
13
+ export const tokenize = (query) => query
14
+ .match(queryTokenPattern)
15
+ ?.map(normalizeToken)
16
+ .filter((token) => token.length > 1) ?? [];
17
+ const countOccurrences = (text, token) => {
18
+ let hits = 0;
19
+ let cursor = 0;
20
+ while (cursor < text.length) {
21
+ const index = text.indexOf(token, cursor);
22
+ if (index < 0) {
23
+ break;
24
+ }
25
+ hits += 1;
26
+ cursor = index + token.length;
27
+ }
28
+ return hits;
29
+ };
30
+ const titleFieldWeight = 5;
31
+ const tagFieldWeight = 4;
32
+ const pathFieldWeight = 2;
33
+ const contentHitCap = 6;
34
+ const hybridTextWeight = 0.6;
35
+ const hybridSemanticWeight = 0.4;
36
+ const normalizeFields = (row) => ({
37
+ title: normalizeToken(row.title),
38
+ path: normalizeToken(row.path),
39
+ content: normalizeToken(row.content),
40
+ tags: normalizeToken(row.tags.join(' '))
41
+ });
42
+ // Field-weighted term frequency: title/tag/path matches count more than body
43
+ // matches, and body hits saturate at a cap so a term repeated many times in one
44
+ // chunk cannot dominate. This weighted frequency feeds BM25 below.
45
+ const weightedTermFrequency = (fields, token) => {
46
+ const titleHits = countOccurrences(fields.title, token);
47
+ const tagHits = countOccurrences(fields.tags, token);
48
+ const pathHits = countOccurrences(fields.path, token);
49
+ const contentHits = countOccurrences(fields.content, token);
50
+ return (titleHits * titleFieldWeight +
51
+ tagHits * tagFieldWeight +
52
+ pathHits * pathFieldWeight +
53
+ Math.min(contentHits, contentHitCap));
54
+ };
55
+ // Naive additive field score retained for graph-node title/path lookup, where
56
+ // the candidate set is documents (not chunks) and corpus-wide BM25 statistics
57
+ // add no signal over a direct weighted hit count.
58
+ export const lexicalFieldScore = (row, tokens) => {
59
+ if (tokens.length === 0) {
60
+ return 0;
61
+ }
62
+ const fields = normalizeFields(row);
63
+ return tokens.reduce((score, token) => score + weightedTermFrequency(fields, token), 0);
64
+ };
65
+ // A single chunk with a non-finite cached tokenCount (legacy index, partial
66
+ // write, manual edit) would otherwise poison the shared averageLength and zero
67
+ // out lexical scoring for the whole corpus, so coerce to a safe positive length.
68
+ const safeDocumentLength = (tokenCount) => Number.isFinite(tokenCount) && tokenCount > 0 ? tokenCount : 1;
69
+ const buildCorpusStatistics = (prepared, tokens) => {
70
+ const documentCount = prepared.length;
71
+ const totalLength = prepared.reduce((sum, entry) => sum + safeDocumentLength(entry.row.tokenCount), 0);
72
+ const documentFrequencyByTerm = new Map();
73
+ for (const term of tokens) {
74
+ let documentFrequency = 0;
75
+ for (const entry of prepared) {
76
+ if ((entry.weightedTermFrequencies.get(term) ?? 0) > 0) {
77
+ documentFrequency += 1;
78
+ }
79
+ }
80
+ documentFrequencyByTerm.set(term, documentFrequency);
81
+ }
82
+ return {
83
+ documentCount,
84
+ averageLength: documentCount > 0 ? totalLength / documentCount : 0,
85
+ documentFrequencyByTerm
86
+ };
87
+ };
88
+ const bm25TextScore = (entry, tokens, stats) => tokens.reduce((score, term) => score +
89
+ bm25TermScore({
90
+ termFrequency: entry.weightedTermFrequencies.get(term) ?? 0,
91
+ documentLength: safeDocumentLength(entry.row.tokenCount),
92
+ documentCount: stats.documentCount,
93
+ documentFrequency: stats.documentFrequencyByTerm.get(term) ?? 0,
94
+ averageLength: stats.averageLength
95
+ }), 0);
96
+ const semanticScore = (row, queryEmbedding) => queryEmbedding.length > 0 && row.embedding.length > 0 ? dotProduct(queryEmbedding, row.embedding) : 0;
97
+ const rangeOf = (values) => values.reduce((range, value) => ({ min: Math.min(range.min, value), max: Math.max(range.max, value) }), { min: Infinity, max: -Infinity });
98
+ const toResult = (row, mode, score, text, semantic) => ({
99
+ documentId: row.documentId,
100
+ agentId: row.agentId,
101
+ title: row.title,
102
+ path: row.path,
103
+ chunkId: row.chunkId,
104
+ chunkOrdinal: row.chunkOrdinal,
105
+ content: row.content,
106
+ score,
107
+ textScore: text,
108
+ semanticScore: semantic,
109
+ tokenCount: row.tokenCount,
110
+ searchMode: mode,
111
+ tags: row.tags
112
+ });
113
+ // Rank an already-selected candidate set. Backends own candidate selection
114
+ // (full scan, bucket prefilter, ANN); this function owns scoring and ordering so
115
+ // the result is identical regardless of how the candidates were gathered.
116
+ export const runSearch = (candidates, query, limit, mode, queryEmbedding = []) => {
117
+ const tokens = tokenize(query);
118
+ // Pass 1: field-weighted term frequencies per candidate, then corpus-wide
119
+ // statistics (document frequency, average length) needed for BM25 IDF and
120
+ // length normalization.
121
+ const prepared = candidates.map((row) => {
122
+ const fields = normalizeFields(row);
123
+ const weightedTermFrequencies = new Map();
124
+ for (const term of tokens) {
125
+ const frequency = weightedTermFrequency(fields, term);
126
+ if (frequency > 0) {
127
+ weightedTermFrequencies.set(term, frequency);
128
+ }
129
+ }
130
+ return { row, weightedTermFrequencies };
131
+ });
132
+ const stats = buildCorpusStatistics(prepared, tokens);
133
+ // Pass 2: raw lexical (BM25) and semantic (cosine) component scores.
134
+ const componentScored = prepared.map((entry) => ({
135
+ row: entry.row,
136
+ text: tokens.length > 0 ? bm25TextScore(entry, tokens, stats) : 0,
137
+ semantic: semanticScore(entry.row, queryEmbedding)
138
+ }));
139
+ // Filter on raw relevance before normalization so a legitimate match that
140
+ // happens to be the weakest in its mode is never min-max scaled to zero
141
+ // and dropped. fts stays lexical-only and semantic stays vector-only.
142
+ const relevant = componentScored.filter((entry) => {
143
+ if (tokens.length === 0) {
144
+ return true;
145
+ }
146
+ if (mode === 'fts') {
147
+ return entry.text > 0;
148
+ }
149
+ if (mode === 'semantic') {
150
+ return entry.semantic > 0;
151
+ }
152
+ return entry.text > 0 || entry.semantic > 0;
153
+ });
154
+ if (relevant.length === 0) {
155
+ return [];
156
+ }
157
+ const textRange = rangeOf(relevant.map((entry) => entry.text));
158
+ const semanticRange = rangeOf(relevant.map((entry) => entry.semantic));
159
+ const finalScore = (entry) => {
160
+ if (mode === 'fts') {
161
+ return entry.text;
162
+ }
163
+ if (mode === 'semantic') {
164
+ return entry.semantic;
165
+ }
166
+ // Hybrid mixes the two components on a shared [0, 1] scale so neither the
167
+ // unbounded BM25 magnitude nor the bounded cosine value can swamp the
168
+ // other, replacing the previous fixed `text + semantic * 8` blend.
169
+ const textNorm = minMaxNormalize(entry.text, textRange.min, textRange.max);
170
+ const semanticNorm = minMaxNormalize(entry.semantic, semanticRange.min, semanticRange.max);
171
+ return textNorm * hybridTextWeight + semanticNorm * hybridSemanticWeight;
172
+ };
173
+ return relevant
174
+ .map((entry) => toResult(entry.row, mode, finalScore(entry), entry.text, entry.semantic))
175
+ .sort((left, right) => right.score - left.score || left.title.localeCompare(right.title))
176
+ .slice(0, Math.max(0, limit));
177
+ };
@@ -1,6 +1,7 @@
1
1
  import { stat } from 'node:fs/promises';
2
2
  import { ensureVault } from '../infrastructure/file-system-vault.js';
3
- import { indexStoragePath, openFileIndex } from '../infrastructure/file-index.js';
3
+ import { indexStoragePath } from '../infrastructure/file-index.js';
4
+ import { openKnowledgeStore } from '../infrastructure/knowledge-store/index.js';
4
5
  import { getGraphLayout } from './get-graph-layout.js';
5
6
  const graphSearchCacheTtlMs = 20_000;
6
7
  const graphSearchCacheMaxEntries = 120;
@@ -52,7 +53,7 @@ export const searchGraphNodeIds = async (vaultPath, query, limit, agentId, conte
52
53
  const contextNodeIds = context
53
54
  ? new Set((await getGraphLayout(absoluteVaultPath, { agentId, context })).layout.nodes.map((node) => node.id))
54
55
  : new Set();
55
- const index = openFileIndex(absoluteVaultPath);
56
+ const index = openKnowledgeStore(absoluteVaultPath);
56
57
  try {
57
58
  const searchLimit = context ? Math.max(limit, 5000) : limit;
58
59
  const foundNodeIds = await index.searchGraphNodeIds(query, searchLimit, agentId);
@@ -1,7 +1,8 @@
1
1
  import { stat } from 'node:fs/promises';
2
2
  import { ensureVault } from '../infrastructure/file-system-vault.js';
3
3
  import { ensurePrivatePacksFromLegacyIndex, searchInPacks } from '../infrastructure/search-packs.js';
4
- import { indexStoragePath, openFileIndex } from '../infrastructure/file-index.js';
4
+ import { indexStoragePath } from '../infrastructure/file-index.js';
5
+ import { openKnowledgeStore } from '../infrastructure/knowledge-store/index.js';
5
6
  import { createEmbeddingProvider } from '../domain/embeddings.js';
6
7
  import { loadBrainlinkConfig, sanitizeSearchMode } from '../infrastructure/config.js';
7
8
  const embedQuerySafely = async (provider, query) => {
@@ -73,7 +74,7 @@ export const searchKnowledge = async (vaultPath, query, limit, agentId, mode) =>
73
74
  // to lexical (fts) so a transient provider outage still yields results.
74
75
  const effectiveMode = searchMode === 'semantic' && shouldEmbedQuery && queryEmbedding.length === 0 ? 'fts' : searchMode;
75
76
  try {
76
- const index = openFileIndex(absoluteVaultPath);
77
+ const index = openKnowledgeStore(absoluteVaultPath);
77
78
  try {
78
79
  const results = await index.search(query, limit, agentId, effectiveMode, queryEmbedding);
79
80
  if (cacheKey) {
@@ -0,0 +1,180 @@
1
+ // Git-backed versioning for the vault's canonical Markdown. The derived
2
+ // `.brainlink/` directory is git-ignored; only `.md` is versioned. After any
3
+ // operation that changes Markdown on disk (pull, restore), the index is rebuilt
4
+ // so the vault stays immediately usable ("no losing the use of the data").
5
+ //
6
+ // Git is driven through the system binary via execFile (never a shell, args as
7
+ // an array) to keep runtime dependencies minimal and history/merge behavior
8
+ // native. A missing git binary degrades to a clear, actionable error.
9
+ import { execFile } from 'node:child_process';
10
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
11
+ import { join } from 'node:path';
12
+ import { promisify } from 'node:util';
13
+ import { indexVault } from './index-vault.js';
14
+ import { ensureVault } from '../infrastructure/file-system-vault.js';
15
+ const execFileAsync = promisify(execFile);
16
+ export class GitUnavailableError extends Error {
17
+ constructor() {
18
+ super('git executable not found. Install git to use vault versioning.');
19
+ this.name = 'GitUnavailableError';
20
+ }
21
+ }
22
+ export class GitCommandError extends Error {
23
+ constructor(args, stderr) {
24
+ super(`git ${args.join(' ')} failed: ${stderr.trim() || 'unknown error'}`);
25
+ this.name = 'GitCommandError';
26
+ }
27
+ }
28
+ const isMissingBinary = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
29
+ const runGit = async (cwd, args) => {
30
+ try {
31
+ const { stdout } = await execFileAsync('git', [...args], { cwd, maxBuffer: 64 * 1024 * 1024 });
32
+ return stdout;
33
+ }
34
+ catch (error) {
35
+ if (isMissingBinary(error)) {
36
+ throw new GitUnavailableError();
37
+ }
38
+ const stderr = error.stderr ?? (error instanceof Error ? error.message : String(error));
39
+ throw new GitCommandError(args, stderr);
40
+ }
41
+ };
42
+ const isInsideGitRepo = async (cwd) => {
43
+ try {
44
+ const out = await runGit(cwd, ['rev-parse', '--is-inside-work-tree']);
45
+ return out.trim() === 'true';
46
+ }
47
+ catch (error) {
48
+ if (error instanceof GitUnavailableError) {
49
+ throw error;
50
+ }
51
+ return false;
52
+ }
53
+ };
54
+ const gitignoreLines = ['.brainlink/', '*.tmp', '.DS_Store'];
55
+ const ensureGitignore = async (vaultRoot) => {
56
+ const path = join(vaultRoot, '.gitignore');
57
+ let current = '';
58
+ try {
59
+ current = await readFile(path, 'utf8');
60
+ }
61
+ catch {
62
+ current = '';
63
+ }
64
+ const existing = new Set(current.split('\n').map((line) => line.trim()));
65
+ const missing = gitignoreLines.filter((line) => !existing.has(line));
66
+ if (missing.length === 0 && current.length > 0) {
67
+ return;
68
+ }
69
+ const next = `${[current.trimEnd(), ...missing].filter(Boolean).join('\n')}\n`;
70
+ await writeFile(path, next, 'utf8');
71
+ };
72
+ // Reject credentials embedded in a remote URL: they would be written to
73
+ // .git/config in clear text. Users should rely on SSH or a git credential
74
+ // helper instead.
75
+ const assertSafeRemote = (remote) => {
76
+ if (/\/\/[^/@]*:[^/@]*@/.test(remote) || /x-access-token|:[^/]*@github/.test(remote)) {
77
+ throw new Error('Refusing a remote URL with an inline credential. Use SSH or a git credential helper.');
78
+ }
79
+ };
80
+ const markdownOnly = (paths) => paths.filter((path) => path.toLowerCase().endsWith('.md'));
81
+ export const initVaultGit = async (vaultPath, options = {}) => {
82
+ const root = await ensureVault(vaultPath);
83
+ await mkdir(root, { recursive: true });
84
+ const already = await isInsideGitRepo(root);
85
+ if (!already) {
86
+ await runGit(root, ['init']);
87
+ }
88
+ await ensureGitignore(root);
89
+ if (options.remote) {
90
+ assertSafeRemote(options.remote);
91
+ const remotes = await runGit(root, ['remote']);
92
+ const verb = remotes.split('\n').map((line) => line.trim()).includes('origin') ? 'set-url' : 'add';
93
+ await runGit(root, ['remote', verb, 'origin', options.remote]);
94
+ }
95
+ return { root, initialized: !already };
96
+ };
97
+ export const vaultGitStatus = async (vaultPath) => {
98
+ const root = await ensureVault(vaultPath);
99
+ const branch = (await runGit(root, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => 'HEAD')).trim();
100
+ const porcelain = await runGit(root, ['status', '--porcelain']);
101
+ const changed = porcelain
102
+ .split('\n')
103
+ .map((line) => line.slice(3).trim())
104
+ .filter(Boolean);
105
+ return { branch, dirty: changed.length > 0, changedMarkdown: markdownOnly(changed) };
106
+ };
107
+ // Stage and commit only Markdown. Returns null when there is nothing to commit.
108
+ export const commitVault = async (vaultPath, message) => {
109
+ const root = await ensureVault(vaultPath);
110
+ await runGit(root, ['add', '-A', '--', '*.md', ':(glob)**/*.md', '.gitignore']);
111
+ const staged = await runGit(root, ['diff', '--cached', '--name-only']);
112
+ if (staged.trim().length === 0) {
113
+ return null;
114
+ }
115
+ await runGit(root, ['commit', '-m', message]);
116
+ return (await runGit(root, ['rev-parse', 'HEAD'])).trim();
117
+ };
118
+ export const vaultHistory = async (vaultPath, limit = 20) => {
119
+ const root = await ensureVault(vaultPath);
120
+ const out = await runGit(root, [
121
+ 'log',
122
+ `-n${Math.max(1, Math.floor(limit))}`,
123
+ '--format=%H%x00%an%x00%aI%x00%s',
124
+ '--',
125
+ '*.md'
126
+ ]).catch(() => '');
127
+ return out
128
+ .split('\n')
129
+ .map((line) => line.split('\u0000'))
130
+ .filter((parts) => parts.length === 4)
131
+ .map(([hash, author, date, subject]) => ({ hash: hash, author: author, date: date, subject: subject }));
132
+ };
133
+ const reindexAfterMarkdownChange = async (vaultPath, changedMarkdown, skipIndex) => !skipIndex && changedMarkdown.length > 0 ? indexVault(vaultPath) : undefined;
134
+ export const pushVault = async (vaultPath, branch) => {
135
+ const root = await ensureVault(vaultPath);
136
+ const target = branch ?? (await runGit(root, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
137
+ await runGit(root, ['push', '-u', 'origin', target]);
138
+ };
139
+ // Pull from origin and reindex when Markdown changed, so the vault is usable
140
+ // immediately. On a merge conflict the merge is aborted and a clear error is
141
+ // raised rather than leaving conflict markers in the Markdown.
142
+ export const pullVault = async (vaultPath, options = {}) => {
143
+ const root = await ensureVault(vaultPath);
144
+ const before = (await runGit(root, ['rev-parse', 'HEAD']).catch(() => '')).trim();
145
+ try {
146
+ await runGit(root, ['pull', '--no-rebase', '--no-edit', 'origin']);
147
+ }
148
+ catch (error) {
149
+ await runGit(root, ['merge', '--abort']).catch(() => undefined);
150
+ throw error instanceof GitCommandError
151
+ ? new GitCommandError(['pull'], 'merge conflict; aborted. Resolve manually, then commit.')
152
+ : error;
153
+ }
154
+ const after = (await runGit(root, ['rev-parse', 'HEAD']).catch(() => '')).trim();
155
+ const diff = before && after && before !== after
156
+ ? await runGit(root, ['diff', '--name-only', before, after, '--', '*.md'])
157
+ : '';
158
+ const changedMarkdown = markdownOnly(diff.split('\n').map((line) => line.trim()).filter(Boolean));
159
+ const index = await reindexAfterMarkdownChange(vaultPath, changedMarkdown, options.skipIndex === true);
160
+ return { changedMarkdown, index };
161
+ };
162
+ export const restoreVault = async (vaultPath, ref, options = {}) => {
163
+ const root = await ensureVault(vaultPath);
164
+ const head = (await runGit(root, ['rev-parse', 'HEAD']).catch(() => '')).trim();
165
+ const diff = await runGit(root, ['diff', '--name-only', ref, '--', '*.md']).catch(() => '');
166
+ await runGit(root, ['checkout', ref, '--', '.']);
167
+ const changedMarkdown = markdownOnly(diff.split('\n').map((line) => line.trim()).filter(Boolean));
168
+ const index = await reindexAfterMarkdownChange(vaultPath, changedMarkdown, options.skipIndex === true);
169
+ void head;
170
+ return { changedMarkdown, index };
171
+ };
172
+ // Clone a remote vault and build its index from scratch so it is immediately
173
+ // searchable. The clone target must not already exist.
174
+ export const cloneVault = async (remote, targetPath, options = {}) => {
175
+ assertSafeRemote(remote);
176
+ await runGit(process.cwd(), ['clone', remote, targetPath]);
177
+ const root = await ensureVault(targetPath);
178
+ const index = options.skipIndex === true ? undefined : await indexVault(root, { full: true });
179
+ return { root, index };
180
+ };
@@ -0,0 +1,87 @@
1
+ // Portable vault snapshot (`.blvault`): a single gzip-compressed envelope of the
2
+ // canonical Markdown (with per-file SHA-256), self-contained and movable to any
3
+ // machine. The derived index is never included — import rebuilds it so the vault
4
+ // is immediately usable. Conflicts on import are preserved as `*.conflict-<ts>.md`
5
+ // rather than overwriting local edits.
6
+ import { createHash } from 'node:crypto';
7
+ import { gzipSync, gunzipSync } from 'node:zlib';
8
+ import { extname, relative } from 'node:path';
9
+ import { readFile, writeFile } from 'node:fs/promises';
10
+ import { indexVault } from './index-vault.js';
11
+ import { ensureVault, listVaultFiles, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
12
+ const snapshotVersion = 1;
13
+ const isMarkdown = (path) => extname(path).toLowerCase() === '.md';
14
+ const toPosix = (path) => path.split('\\').join('/');
15
+ const sha256 = (data) => createHash('sha256').update(data).digest('hex');
16
+ const conflictName = (path) => {
17
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
18
+ const extension = extname(path);
19
+ const base = extension ? path.slice(0, -extension.length) : path;
20
+ return `${base}.conflict-${stamp}${extension}`;
21
+ };
22
+ export const exportVaultSnapshot = async (vaultPath, outputFile) => {
23
+ const root = await ensureVault(vaultPath);
24
+ const markdownFiles = (await listVaultFiles(root)).filter(isMarkdown);
25
+ const entries = await Promise.all(markdownFiles.map(async (absolutePath) => {
26
+ const content = await readFile(absolutePath);
27
+ const path = toPosix(relative(root, absolutePath));
28
+ return {
29
+ manifest: { path, sha256: sha256(content), bytes: content.byteLength },
30
+ file: { path, contentB64: content.toString('base64') }
31
+ };
32
+ }));
33
+ const envelope = {
34
+ version: snapshotVersion,
35
+ createdAt: new Date().toISOString(),
36
+ manifest: entries.map((entry) => entry.manifest),
37
+ files: entries.map((entry) => entry.file)
38
+ };
39
+ const packed = gzipSync(Buffer.from(JSON.stringify(envelope), 'utf8'), { level: 9 });
40
+ await writeFile(outputFile, packed, { mode: 0o600 });
41
+ return { file: outputFile, fileCount: entries.length, bytes: packed.byteLength };
42
+ };
43
+ const parseEnvelope = (raw) => {
44
+ const envelope = JSON.parse(gunzipSync(raw).toString('utf8'));
45
+ if (!envelope || envelope.version !== snapshotVersion || !Array.isArray(envelope.files)) {
46
+ throw new Error('Unrecognized .blvault snapshot format.');
47
+ }
48
+ return envelope;
49
+ };
50
+ export const importVaultSnapshot = async (snapshotFile, vaultPath, options = {}) => {
51
+ const root = await ensureVault(vaultPath);
52
+ const envelope = parseEnvelope(await readFile(snapshotFile));
53
+ const manifestByPath = new Map(envelope.manifest.map((entry) => [entry.path, entry]));
54
+ let imported = 0;
55
+ let conflicted = 0;
56
+ for (const file of envelope.files) {
57
+ if (!isMarkdown(file.path)) {
58
+ continue;
59
+ }
60
+ const content = Buffer.from(file.contentB64, 'base64');
61
+ const expected = manifestByPath.get(file.path)?.sha256;
62
+ if (expected && sha256(content) !== expected) {
63
+ throw new Error(`Checksum mismatch for ${file.path}; snapshot is corrupt.`);
64
+ }
65
+ // Preserve local edits: write divergent incoming files under a conflict name
66
+ // instead of overwriting. writeMarkdownFile guards against path traversal.
67
+ let existing = null;
68
+ try {
69
+ existing = await readFile(`${root}/${file.path}`);
70
+ }
71
+ catch {
72
+ existing = null;
73
+ }
74
+ if (existing && !existing.equals(content)) {
75
+ await writeMarkdownFile(root, conflictName(file.path), content.toString('utf8'));
76
+ conflicted += 1;
77
+ continue;
78
+ }
79
+ if (existing && existing.equals(content)) {
80
+ continue;
81
+ }
82
+ await writeMarkdownFile(root, file.path, content.toString('utf8'));
83
+ imported += 1;
84
+ }
85
+ const index = !options.skipIndex && imported + conflicted > 0 ? await indexVault(root) : undefined;
86
+ return { imported, conflicted, index };
87
+ };