@andespindola/brainlink 1.0.6 → 1.0.7
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 +17 -0
- package/README.md +38 -0
- package/dist/application/find-similar-notes.js +75 -0
- package/dist/application/index-vault-phases.js +5 -4
- package/dist/application/index-vault.js +2 -2
- package/dist/application/memory-suggestions.js +4 -1
- package/dist/application/search-knowledge.js +19 -3
- 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 +27 -1
- package/dist/infrastructure/file-index.js +217 -59
- 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 +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- 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
|
+
- 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
|
+
|
|
8
|
+
- 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.
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
- 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.
|
|
15
|
+
|
|
16
|
+
- 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.
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
|
|
3
20
|
## 1.0.0
|
|
4
21
|
|
|
5
22
|
- 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.
|
|
@@ -1214,6 +1217,41 @@ spec:
|
|
|
1214
1217
|
|
|
1215
1218
|
Use `"embeddingProvider": "none"` when you want FTS-only indexing.
|
|
1216
1219
|
|
|
1220
|
+
### Embedding providers
|
|
1221
|
+
|
|
1222
|
+
`embeddingProvider` selects how chunk and query embeddings are produced:
|
|
1223
|
+
|
|
1224
|
+
- `local` (default): deterministic, dependency-free local vectors. No network.
|
|
1225
|
+
- `none`: FTS-only; no semantic vectors.
|
|
1226
|
+
- `ollama`: embeddings from a local [Ollama](https://ollama.com) server.
|
|
1227
|
+
- `openai`: embeddings from the OpenAI embeddings API.
|
|
1228
|
+
|
|
1229
|
+
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:
|
|
1230
|
+
|
|
1231
|
+
```json
|
|
1232
|
+
{
|
|
1233
|
+
"embeddingProvider": "ollama",
|
|
1234
|
+
"embedding": {
|
|
1235
|
+
"url": "http://127.0.0.1:11434",
|
|
1236
|
+
"model": "nomic-embed-text",
|
|
1237
|
+
"timeoutMs": 10000,
|
|
1238
|
+
"batchSize": 64
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
```
|
|
1242
|
+
|
|
1243
|
+
```json
|
|
1244
|
+
{
|
|
1245
|
+
"embeddingProvider": "openai",
|
|
1246
|
+
"embedding": {
|
|
1247
|
+
"model": "text-embedding-3-small",
|
|
1248
|
+
"apiKeyEnv": "OPENAI_API_KEY"
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
```
|
|
1252
|
+
|
|
1253
|
+
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.
|
|
1254
|
+
|
|
1217
1255
|
For local security checks, set your Snyk token in the environment:
|
|
1218
1256
|
|
|
1219
1257
|
```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,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,6 +1,6 @@
|
|
|
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';
|
|
@@ -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,
|
|
@@ -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,
|
|
@@ -4,6 +4,16 @@ import { ensurePrivatePacksFromLegacyIndex, searchInPacks } from '../infrastruct
|
|
|
4
4
|
import { indexStoragePath, openFileIndex } from '../infrastructure/file-index.js';
|
|
5
5
|
import { createEmbeddingProvider } from '../domain/embeddings.js';
|
|
6
6
|
import { loadBrainlinkConfig, sanitizeSearchMode } from '../infrastructure/config.js';
|
|
7
|
+
const embedQuerySafely = async (provider, query) => {
|
|
8
|
+
try {
|
|
9
|
+
return (await provider.embed([query]))[0] ?? [];
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
13
|
+
process.stderr.write(`brainlink: query embedding via ${provider.name} failed, using lexical search (${reason})\n`);
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
};
|
|
7
17
|
const hybridCacheTtlMs = 30_000;
|
|
8
18
|
const hybridCacheMaxEntries = 200;
|
|
9
19
|
const hybridSearchCache = new Map();
|
|
@@ -53,13 +63,19 @@ export const searchKnowledge = async (vaultPath, query, limit, agentId, mode) =>
|
|
|
53
63
|
if (cached) {
|
|
54
64
|
return cached;
|
|
55
65
|
}
|
|
56
|
-
const provider = createEmbeddingProvider(config.embeddingProvider);
|
|
66
|
+
const provider = createEmbeddingProvider(config.embeddingProvider, config.embedding);
|
|
57
67
|
const shouldEmbedQuery = searchMode !== 'fts' && provider.name !== 'none';
|
|
58
|
-
|
|
68
|
+
// An external provider can be unreachable at query time. Degrade to
|
|
69
|
+
// lexical-only retrieval rather than substituting a local-space vector that
|
|
70
|
+
// would not match the stored embeddings, keeping search available.
|
|
71
|
+
const queryEmbedding = shouldEmbedQuery ? await embedQuerySafely(provider, query) : [];
|
|
72
|
+
// Pure semantic mode with no usable embedding would return nothing; fall back
|
|
73
|
+
// to lexical (fts) so a transient provider outage still yields results.
|
|
74
|
+
const effectiveMode = searchMode === 'semantic' && shouldEmbedQuery && queryEmbedding.length === 0 ? 'fts' : searchMode;
|
|
59
75
|
try {
|
|
60
76
|
const index = openFileIndex(absoluteVaultPath);
|
|
61
77
|
try {
|
|
62
|
-
const results = await index.search(query, limit, agentId,
|
|
78
|
+
const results = await index.search(query, limit, agentId, effectiveMode, queryEmbedding);
|
|
63
79
|
if (cacheKey) {
|
|
64
80
|
cacheSet({
|
|
65
81
|
key: cacheKey,
|
package/dist/domain/context.js
CHANGED
|
@@ -1,21 +1,32 @@
|
|
|
1
1
|
import { middleOutIndices } from './middle-out.js';
|
|
2
|
+
import { estimateTokenCount } from './tokens.js';
|
|
3
|
+
import { orderByMaximalMarginalRelevance } from './diversity.js';
|
|
4
|
+
const contentTokenPattern = /[\p{L}\p{N}_-]+/gu;
|
|
5
|
+
const tokenizeForDiversity = (text) => new Set((text.toLowerCase().match(contentTokenPattern) ?? []).filter((token) => token.length > 1));
|
|
2
6
|
const maxSectionsPerDocument = 3;
|
|
3
|
-
|
|
7
|
+
// Character budget used only for truncating an overflowing chunk back down to a
|
|
8
|
+
// token target; the conservative per-token character ratio keeps the rendered
|
|
9
|
+
// slice within budget even though estimateTokenCount is the source of truth for
|
|
10
|
+
// counting.
|
|
11
|
+
const truncationCharsPerToken = 4;
|
|
4
12
|
const sectionSeparatorTokens = 1;
|
|
5
13
|
const headerReserveTokens = 8;
|
|
6
|
-
const estimateTokens = (text) => Math.ceil(text.length / charsPerToken);
|
|
7
14
|
const truncateToTokens = (text, maxTokens) => {
|
|
8
|
-
const maxChars = Math.max(0, maxTokens) *
|
|
15
|
+
const maxChars = Math.max(0, maxTokens) * truncationCharsPerToken;
|
|
9
16
|
return text.length <= maxChars ? text : text.slice(0, maxChars);
|
|
10
17
|
};
|
|
18
|
+
// Prefer the token count cached on the chunk at index time; fall back to a live
|
|
19
|
+
// estimate when the result did not carry one (legacy indexes, pack fallback).
|
|
20
|
+
const contentTokens = (result) => result.tokenCount ?? estimateTokenCount(result.content);
|
|
11
21
|
// Mirror the per-section framing that formatContextPackage renders (heading,
|
|
12
22
|
// Source/Tags/Score/Mode lines and the trailing separator) so the budget
|
|
13
23
|
// reflects the package the agent actually receives, not just chunk content.
|
|
14
|
-
const
|
|
24
|
+
const estimateFramingTokens = (result) => {
|
|
15
25
|
const tagsLine = result.tags.length > 0 ? `Tags: ${result.tags.map((tag) => `#${tag}`).join(' ')}\n` : '';
|
|
16
26
|
const framing = `## . ${result.title}\nSource: ${result.path}\n${tagsLine}Score: 0.000\nMode: ${result.searchMode}\n\n`;
|
|
17
|
-
return
|
|
27
|
+
return estimateTokenCount(framing) + sectionSeparatorTokens;
|
|
18
28
|
};
|
|
29
|
+
const estimateSectionTokens = (result) => contentTokens(result) + estimateFramingTokens(result);
|
|
19
30
|
const byScore = (left, right) => right.score - left.score || left.title.localeCompare(right.title);
|
|
20
31
|
const byOrdinal = (left, right) => (left.chunkOrdinal ?? Number.MAX_SAFE_INTEGER) - (right.chunkOrdinal ?? Number.MAX_SAFE_INTEGER);
|
|
21
32
|
const middleOutDocumentResults = (results) => {
|
|
@@ -36,14 +47,16 @@ export const selectContextSections = (results, maxTokens) => {
|
|
|
36
47
|
state.set(result.documentId, [...current, result]);
|
|
37
48
|
return state;
|
|
38
49
|
}, new Map());
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
.
|
|
46
|
-
.map((
|
|
50
|
+
// Order documents by maximal marginal relevance so the budget is not spent on
|
|
51
|
+
// several near-duplicate notes: each pick trades its own relevance against how
|
|
52
|
+
// similar it is to documents already chosen. The strongest document still
|
|
53
|
+
// leads (no redundancy penalty on an empty selection).
|
|
54
|
+
const documentCandidates = Array.from(grouped.entries()).map(([documentId, group]) => ({
|
|
55
|
+
id: documentId,
|
|
56
|
+
relevance: group.reduce((max, result) => Math.max(max, result.score), Number.NEGATIVE_INFINITY),
|
|
57
|
+
tokens: tokenizeForDiversity(group.map((result) => result.content).join(' '))
|
|
58
|
+
}));
|
|
59
|
+
const documentOrder = orderByMaximalMarginalRelevance(documentCandidates);
|
|
47
60
|
const selected = documentOrder.reduce((state, documentId) => {
|
|
48
61
|
const ordered = middleOutDocumentResults(grouped.get(documentId) ?? []);
|
|
49
62
|
let usedTokens = state.usedTokens;
|
|
@@ -91,7 +104,7 @@ export const selectContextSections = (results, maxTokens) => {
|
|
|
91
104
|
// brainlink_context consistent with brainlink_search instead of reporting
|
|
92
105
|
// "No relevant context found." whenever the strongest chunk is large.
|
|
93
106
|
const topResult = [...results].sort(byScore)[0];
|
|
94
|
-
const framingTokens =
|
|
107
|
+
const framingTokens = estimateFramingTokens(topResult);
|
|
95
108
|
const contentTokenBudget = maxTokens - headerReserveTokens - framingTokens;
|
|
96
109
|
return [
|
|
97
110
|
{
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Maximal Marginal Relevance ordering. Greedily orders candidates so each pick
|
|
2
|
+
// balances its own relevance against how redundant it is versus everything
|
|
3
|
+
// already chosen, preventing a context window from filling with near-duplicate
|
|
4
|
+
// material. Pure and deterministic: ties resolve by relevance then id.
|
|
5
|
+
const jaccardSimilarity = (left, right) => {
|
|
6
|
+
if (left.size === 0 || right.size === 0) {
|
|
7
|
+
return 0;
|
|
8
|
+
}
|
|
9
|
+
let intersection = 0;
|
|
10
|
+
const [small, large] = left.size <= right.size ? [left, right] : [right, left];
|
|
11
|
+
for (const token of small) {
|
|
12
|
+
if (large.has(token)) {
|
|
13
|
+
intersection += 1;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const union = left.size + right.size - intersection;
|
|
17
|
+
return union === 0 ? 0 : intersection / union;
|
|
18
|
+
};
|
|
19
|
+
const normalizeRelevance = (candidates) => {
|
|
20
|
+
// Reduce instead of Math.min(...values): the candidate list can be as large as
|
|
21
|
+
// the caller's result limit, and spreading a very large array overflows the
|
|
22
|
+
// call stack.
|
|
23
|
+
const bounds = candidates.reduce((range, candidate) => ({
|
|
24
|
+
min: Math.min(range.min, candidate.relevance),
|
|
25
|
+
max: Math.max(range.max, candidate.relevance)
|
|
26
|
+
}), { min: Infinity, max: -Infinity });
|
|
27
|
+
const span = bounds.max - bounds.min;
|
|
28
|
+
return new Map(candidates.map((candidate) => [candidate.id, span === 0 ? 1 : (candidate.relevance - bounds.min) / span]));
|
|
29
|
+
};
|
|
30
|
+
// lambda in [0,1]: 1 ignores redundancy (pure relevance order), lower values
|
|
31
|
+
// push diverse picks higher. The default balances relevance and diversity; the
|
|
32
|
+
// strongest candidate still always leads (no redundancy penalty on an empty
|
|
33
|
+
// selection).
|
|
34
|
+
export const orderByMaximalMarginalRelevance = (candidates, lambda = 0.5) => {
|
|
35
|
+
if (candidates.length <= 1) {
|
|
36
|
+
return candidates.map((candidate) => candidate.id);
|
|
37
|
+
}
|
|
38
|
+
const relevanceById = normalizeRelevance(candidates);
|
|
39
|
+
const remaining = new Map(candidates.map((candidate) => [candidate.id, candidate]));
|
|
40
|
+
const selected = [];
|
|
41
|
+
const order = [];
|
|
42
|
+
while (remaining.size > 0) {
|
|
43
|
+
let best = null;
|
|
44
|
+
for (const candidate of remaining.values()) {
|
|
45
|
+
const relevance = relevanceById.get(candidate.id) ?? 0;
|
|
46
|
+
const redundancy = selected.reduce((max, picked) => Math.max(max, jaccardSimilarity(candidate.tokens, picked.tokens)), 0);
|
|
47
|
+
const score = lambda * relevance - (1 - lambda) * redundancy;
|
|
48
|
+
if (best === null ||
|
|
49
|
+
score > best.score ||
|
|
50
|
+
(score === best.score &&
|
|
51
|
+
(relevance > best.relevance ||
|
|
52
|
+
(relevance === best.relevance && candidate.id.localeCompare(best.candidate.id) < 0)))) {
|
|
53
|
+
best = { candidate, score, relevance };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (best === null) {
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
selected.push(best.candidate);
|
|
60
|
+
order.push(best.candidate.id);
|
|
61
|
+
remaining.delete(best.candidate.id);
|
|
62
|
+
}
|
|
63
|
+
return order;
|
|
64
|
+
};
|
|
@@ -141,4 +141,120 @@ export const createLocalEmbeddingProvider = () => ({
|
|
|
141
141
|
name: 'local',
|
|
142
142
|
embed: async (input) => input.map(createLocalEmbedding)
|
|
143
143
|
});
|
|
144
|
-
|
|
144
|
+
const defaultExternalTimeoutMs = 10_000;
|
|
145
|
+
const defaultExternalBatchSize = 64;
|
|
146
|
+
const ollamaDefaultUrl = 'http://127.0.0.1:11434';
|
|
147
|
+
const ollamaDefaultModel = 'nomic-embed-text';
|
|
148
|
+
const openAiDefaultUrl = 'https://api.openai.com/v1';
|
|
149
|
+
const openAiDefaultModel = 'text-embedding-3-small';
|
|
150
|
+
const trimTrailingSlash = (value) => value.replace(/\/+$/, '');
|
|
151
|
+
const chunkBatches = (items, size) => {
|
|
152
|
+
const batchSize = Number.isFinite(size) && size > 0 ? Math.floor(size) : defaultExternalBatchSize;
|
|
153
|
+
const batches = [];
|
|
154
|
+
for (let index = 0; index < items.length; index += batchSize) {
|
|
155
|
+
batches.push(items.slice(index, index + batchSize));
|
|
156
|
+
}
|
|
157
|
+
return batches;
|
|
158
|
+
};
|
|
159
|
+
const isNumberArray = (value) => Array.isArray(value) && value.every((entry) => typeof entry === 'number' && Number.isFinite(entry));
|
|
160
|
+
const fetchJson = async (url, init, timeoutMs, providerName) => {
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
const timeout = setTimeout(() => controller.abort(), Math.max(1, timeoutMs));
|
|
163
|
+
try {
|
|
164
|
+
const response = await fetch(url, { ...init, signal: controller.signal });
|
|
165
|
+
if (!response.ok) {
|
|
166
|
+
throw new Error(`${providerName} embedding request failed with HTTP ${response.status}`);
|
|
167
|
+
}
|
|
168
|
+
return (await response.json());
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
172
|
+
throw new Error(`${providerName} embedding request to ${url} failed: ${reason}`);
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
clearTimeout(timeout);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
// External providers return vectors in their own space; L2-normalize so dot
|
|
179
|
+
// product equals cosine similarity, matching the contract local embeddings rely
|
|
180
|
+
// on across the retrieval and bucket-prefilter paths.
|
|
181
|
+
const createOllamaEmbeddingProvider = (config) => {
|
|
182
|
+
const baseUrl = trimTrailingSlash(config.url?.trim() || ollamaDefaultUrl);
|
|
183
|
+
const model = config.model?.trim() || ollamaDefaultModel;
|
|
184
|
+
const timeoutMs = config.timeoutMs ?? defaultExternalTimeoutMs;
|
|
185
|
+
return {
|
|
186
|
+
name: 'ollama',
|
|
187
|
+
embed: async (input) => {
|
|
188
|
+
const vectors = [];
|
|
189
|
+
for (const batch of chunkBatches(input, config.batchSize ?? defaultExternalBatchSize)) {
|
|
190
|
+
const payload = await fetchJson(`${baseUrl}/api/embed`, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
headers: { 'content-type': 'application/json' },
|
|
193
|
+
body: JSON.stringify({ model, input: batch })
|
|
194
|
+
}, timeoutMs, 'ollama');
|
|
195
|
+
const embeddings = payload.embeddings;
|
|
196
|
+
if (!Array.isArray(embeddings) || embeddings.length !== batch.length) {
|
|
197
|
+
throw new Error('ollama embedding response shape was invalid');
|
|
198
|
+
}
|
|
199
|
+
for (const embedding of embeddings) {
|
|
200
|
+
if (!isNumberArray(embedding) || embedding.length === 0) {
|
|
201
|
+
throw new Error('ollama embedding response contained an invalid vector');
|
|
202
|
+
}
|
|
203
|
+
vectors.push(normalizeVector(embedding));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return vectors;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
const createOpenAiEmbeddingProvider = (config) => {
|
|
211
|
+
const baseUrl = trimTrailingSlash(config.url?.trim() || openAiDefaultUrl);
|
|
212
|
+
const model = config.model?.trim() || openAiDefaultModel;
|
|
213
|
+
const timeoutMs = config.timeoutMs ?? defaultExternalTimeoutMs;
|
|
214
|
+
const apiKeyEnv = config.apiKeyEnv?.trim() || 'OPENAI_API_KEY';
|
|
215
|
+
return {
|
|
216
|
+
name: 'openai',
|
|
217
|
+
embed: async (input) => {
|
|
218
|
+
const apiKey = process.env[apiKeyEnv]?.trim();
|
|
219
|
+
if (!apiKey) {
|
|
220
|
+
throw new Error(`openai embedding requires the ${apiKeyEnv} environment variable to be set`);
|
|
221
|
+
}
|
|
222
|
+
const vectors = [];
|
|
223
|
+
for (const batch of chunkBatches(input, config.batchSize ?? defaultExternalBatchSize)) {
|
|
224
|
+
const payload = await fetchJson(`${baseUrl}/embeddings`, {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: {
|
|
227
|
+
'content-type': 'application/json',
|
|
228
|
+
authorization: `Bearer ${apiKey}`
|
|
229
|
+
},
|
|
230
|
+
body: JSON.stringify({ model, input: batch })
|
|
231
|
+
}, timeoutMs, 'openai');
|
|
232
|
+
const data = payload.data;
|
|
233
|
+
if (!Array.isArray(data) || data.length !== batch.length) {
|
|
234
|
+
throw new Error('openai embedding response shape was invalid');
|
|
235
|
+
}
|
|
236
|
+
const ordered = [...data].sort((left, right) => (left.index ?? 0) - (right.index ?? 0));
|
|
237
|
+
for (const entry of ordered) {
|
|
238
|
+
const embedding = entry.embedding;
|
|
239
|
+
if (!isNumberArray(embedding) || embedding.length === 0) {
|
|
240
|
+
throw new Error('openai embedding response contained an invalid vector');
|
|
241
|
+
}
|
|
242
|
+
vectors.push(normalizeVector(embedding));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return vectors;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
export const createEmbeddingProvider = (name, config = {}) => {
|
|
250
|
+
switch (name) {
|
|
251
|
+
case 'local':
|
|
252
|
+
return createLocalEmbeddingProvider();
|
|
253
|
+
case 'ollama':
|
|
254
|
+
return createOllamaEmbeddingProvider(config);
|
|
255
|
+
case 'openai':
|
|
256
|
+
return createOpenAiEmbeddingProvider(config);
|
|
257
|
+
default:
|
|
258
|
+
return createDisabledEmbeddingProvider();
|
|
259
|
+
}
|
|
260
|
+
};
|
package/dist/domain/markdown.js
CHANGED
|
@@ -188,7 +188,15 @@ const splitLongParagraph = (paragraph, maxCharacters) => {
|
|
|
188
188
|
return [...state, sentence];
|
|
189
189
|
}, []);
|
|
190
190
|
};
|
|
191
|
+
const headingParagraphPattern = /^#{1,6}\s/;
|
|
192
|
+
const startsWithHeading = (paragraph) => headingParagraphPattern.test(paragraph);
|
|
191
193
|
export const splitIntoChunks = (documentId, content, maxCharacters = 1200) => {
|
|
194
|
+
// A heading should begin a new chunk so a section's body is not glued onto the
|
|
195
|
+
// tail of the previous section — but only once the current chunk already holds
|
|
196
|
+
// substantial content. This keeps large notes split on topical (heading)
|
|
197
|
+
// boundaries while small notes (and trailing metadata sections like
|
|
198
|
+
// "## Context Links") stay in a single chunk instead of fragmenting.
|
|
199
|
+
const minChunkCharacters = Math.floor(maxCharacters / 2);
|
|
192
200
|
const paragraphs = normalizeChunkContent(stripFrontmatter(content))
|
|
193
201
|
.split(/\n{2,}/)
|
|
194
202
|
.filter(Boolean)
|
|
@@ -198,8 +206,9 @@ export const splitIntoChunks = (documentId, content, maxCharacters = 1200) => {
|
|
|
198
206
|
if (!lastChunk) {
|
|
199
207
|
return [paragraph];
|
|
200
208
|
}
|
|
209
|
+
const startsNewSection = startsWithHeading(paragraph) && lastChunk.length >= minChunkCharacters;
|
|
201
210
|
const merged = `${lastChunk}\n\n${paragraph}`;
|
|
202
|
-
if (merged.length <= maxCharacters) {
|
|
211
|
+
if (!startsNewSection && merged.length <= maxCharacters) {
|
|
203
212
|
return [...state.slice(0, -1), merged];
|
|
204
213
|
}
|
|
205
214
|
return [...state, paragraph];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Pure retrieval scoring primitives: BM25 lexical relevance and min-max
|
|
2
|
+
// normalization. Kept dependency-free and deterministic so the hot retrieval
|
|
3
|
+
// loop in the infrastructure layer stays testable and the domain owns the
|
|
4
|
+
// ranking math.
|
|
5
|
+
export const defaultBm25Parameters = {
|
|
6
|
+
k1: 1.2,
|
|
7
|
+
b: 0.75
|
|
8
|
+
};
|
|
9
|
+
// Robertson/Sparck-Jones probabilistic IDF with the +0.5 smoothing that keeps
|
|
10
|
+
// the value finite for terms present in every chunk. Floored at 0 so a term
|
|
11
|
+
// shared by the whole candidate set can never push a score negative.
|
|
12
|
+
export const inverseDocumentFrequency = (documentCount, documentFrequency) => {
|
|
13
|
+
if (documentCount <= 0) {
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
const raw = Math.log(1 + (documentCount - documentFrequency + 0.5) / (documentFrequency + 0.5));
|
|
17
|
+
return raw > 0 ? raw : 0;
|
|
18
|
+
};
|
|
19
|
+
// BM25 contribution for a single query term given its (field-weighted)
|
|
20
|
+
// frequency in a chunk. `documentLength` and `averageLength` are in the same
|
|
21
|
+
// weighted unit so length normalization stays consistent with how the caller
|
|
22
|
+
// counts terms.
|
|
23
|
+
export const bm25TermScore = (params) => {
|
|
24
|
+
if (params.termFrequency <= 0) {
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
const { k1, b } = params.parameters ?? defaultBm25Parameters;
|
|
28
|
+
const idf = inverseDocumentFrequency(params.documentCount, params.documentFrequency);
|
|
29
|
+
const normalizedLength = params.averageLength > 0 ? params.documentLength / params.averageLength : 1;
|
|
30
|
+
const denominator = params.termFrequency + k1 * (1 - b + b * normalizedLength);
|
|
31
|
+
return denominator > 0 ? (idf * (params.termFrequency * (k1 + 1))) / denominator : 0;
|
|
32
|
+
};
|
|
33
|
+
// Scale a value into [0, 1] given the observed range. When every candidate
|
|
34
|
+
// shares the same score (range collapses) a positive score maps to 1 and a
|
|
35
|
+
// zero score to 0, so a single result is never silently nulled out.
|
|
36
|
+
export const minMaxNormalize = (value, minimum, maximum) => {
|
|
37
|
+
if (maximum <= minimum) {
|
|
38
|
+
return value > 0 ? 1 : 0;
|
|
39
|
+
}
|
|
40
|
+
const scaled = (value - minimum) / (maximum - minimum);
|
|
41
|
+
return scaled < 0 ? 0 : scaled > 1 ? 1 : scaled;
|
|
42
|
+
};
|
package/dist/domain/tokens.js
CHANGED
|
@@ -1 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
// Subword tokenizers emit roughly 1.3 tokens per whitespace word for natural
|
|
2
|
+
// language. Pure prose tracks that ratio; punctuation- or symbol-dense content
|
|
3
|
+
// (code, URLs, long unbroken strings) tokenizes far denser, which the word
|
|
4
|
+
// count alone underestimates. Taking the max with the character heuristic keeps
|
|
5
|
+
// the estimate conservative — never below chars/4 — so callers that budget
|
|
6
|
+
// against it can rely on the rendered output staying within the token limit.
|
|
7
|
+
const tokensPerWord = 1.3;
|
|
8
|
+
const charactersPerToken = 4;
|
|
9
|
+
export const estimateTokenCount = (content) => {
|
|
10
|
+
const words = content.trim().split(/\s+/).filter(Boolean);
|
|
11
|
+
if (words.length === 0) {
|
|
12
|
+
return 0;
|
|
13
|
+
}
|
|
14
|
+
const characterEstimate = Math.ceil(words.join(' ').length / charactersPerToken);
|
|
15
|
+
const wordEstimate = Math.ceil(words.length * tokensPerWord);
|
|
16
|
+
return Math.max(characterEstimate, wordEstimate);
|
|
17
|
+
};
|