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