@andespindola/brainlink 1.0.5 → 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 +46 -0
- package/dist/application/add-note.js +2 -2
- package/dist/application/build-context.js +16 -10
- package/dist/application/canonical-context-links.js +44 -5
- package/dist/application/check-package-update.js +105 -0
- package/dist/application/find-similar-notes.js +75 -0
- package/dist/application/frontend/client/chunk-fetch.js +236 -0
- package/dist/application/frontend/client/controls.js +178 -0
- package/dist/application/frontend/client/elements.js +122 -0
- package/dist/application/frontend/client/input.js +202 -0
- package/dist/application/frontend/client/node-details.js +191 -0
- package/dist/application/frontend/client/rendering.js +296 -0
- package/dist/application/frontend/client/scope-theme.js +114 -0
- package/dist/application/frontend/client/spatial.js +98 -0
- package/dist/application/frontend/client/storage.js +215 -0
- package/dist/application/frontend/client/upload.js +90 -0
- package/dist/application/frontend/client/worker-bootstrap.js +147 -0
- package/dist/application/frontend/client-js.js +24 -1837
- package/dist/application/frontend/client-render-worker-js.js +1 -1
- package/dist/application/index-vault-phases.js +190 -0
- package/dist/application/index-vault.js +46 -167
- package/dist/application/memory-suggestions.js +4 -1
- package/dist/application/search-knowledge.js +19 -3
- package/dist/cli/commands/write/dedupe-commands.js +59 -0
- package/dist/cli/commands/write/index-commands.js +205 -0
- package/dist/cli/commands/write/link-commands.js +68 -0
- package/dist/cli/commands/write/note-commands.js +146 -0
- package/dist/cli/commands/write/server-commands.js +553 -0
- package/dist/cli/commands/write/shared.js +35 -0
- package/dist/cli/commands/write/vault-lifecycle-commands.js +270 -0
- package/dist/cli/commands/write-commands.js +12 -1303
- package/dist/cli/main.js +39 -3
- package/dist/domain/context.js +60 -11
- package/dist/domain/diversity.js +64 -0
- package/dist/domain/embeddings.js +148 -6
- package/dist/domain/graph-contexts.js +62 -57
- package/dist/domain/graph-layout/cauliflower-layout.js +116 -0
- package/dist/domain/graph-layout/collisions.js +100 -0
- package/dist/domain/graph-layout/hierarchy.js +135 -0
- package/dist/domain/graph-layout/metrics.js +111 -0
- package/dist/domain/graph-layout/segments.js +76 -0
- package/dist/domain/graph-layout/star-layout.js +110 -0
- package/dist/domain/graph-layout.js +4 -625
- 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 +33 -1
- package/dist/infrastructure/file-index.js +227 -60
- package/dist/infrastructure/semantic-prefilter.js +24 -0
- package/dist/mcp/server.js +23 -1
- package/dist/mcp/tool-guard.js +29 -0
- package/dist/mcp/tools/maintenance-tools.js +409 -0
- package/dist/mcp/tools/read-tools.js +537 -0
- package/dist/mcp/tools/shared.js +216 -0
- package/dist/mcp/tools/write-tools.js +353 -0
- package/dist/mcp/tools.js +3 -1357
- package/docs/AGENT_USAGE.md +4 -1
- package/docs/ARCHITECTURE.md +4 -3
- package/docs/QUICKSTART.md +4 -0
- package/package.json +2 -2
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
|
@@ -115,6 +115,11 @@ blink --help
|
|
|
115
115
|
|
|
116
116
|
Use `brainlink` when clarity matters. Use `blink` for faster daily terminal usage.
|
|
117
117
|
|
|
118
|
+
Brainlink checks npm for a newer package version at most once per day and prints
|
|
119
|
+
an update notice to `stderr` when one is available. It does not update itself
|
|
120
|
+
silently. Disable this with `BRAINLINK_NO_UPDATE_CHECK=1` or with
|
|
121
|
+
`"autoUpdateCheck": false` in config.
|
|
122
|
+
|
|
118
123
|
The npm package page may show `npm i @andespindola/brainlink`. That installs
|
|
119
124
|
Brainlink as a project dependency. Use `-g` when you want the terminal commands
|
|
120
125
|
available globally, or run it without a global install:
|
|
@@ -535,14 +540,17 @@ Available tools:
|
|
|
535
540
|
- `brainlink_context_packs`: list or clear persisted CAG context packs.
|
|
536
541
|
- `brainlink_search`: search indexed notes.
|
|
537
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.
|
|
538
544
|
- `brainlink_dedupe`: detect duplicate candidates using exact hash + semantic similarity scores.
|
|
539
545
|
- `brainlink_resolve_duplicate`: resolve duplicate pairs (`merge`, `link`, `ignore`) with connectivity-safe fallback edges.
|
|
540
546
|
- `brainlink_add_note`: write durable Markdown memory and reindex.
|
|
547
|
+
- `brainlink_add_notes`: write several notes in one batch, reindexing once.
|
|
541
548
|
- `brainlink_remember`: capture durable memory with inferred title, tags and Context Links; supports dry-run.
|
|
542
549
|
- `brainlink_inbox_add`: capture a quick untriaged memory item.
|
|
543
550
|
- `brainlink_inbox_list`: list untriaged inbox memory items.
|
|
544
551
|
- `brainlink_inbox_process`: suggest titles, tags and links for inbox items.
|
|
545
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.
|
|
546
554
|
- `brainlink_add_file`: ingest a local file as a note and reindex.
|
|
547
555
|
- `brainlink_canonicalize_context_links`: ensure existing notes link to inferred context hubs.
|
|
548
556
|
- `brainlink_volatile_add`: write temporary agent-decided memory with TTL; volatile sections are included in context and never create durable graph edges.
|
|
@@ -1088,6 +1096,8 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
|
|
|
1088
1096
|
"defaultAgent": "shared",
|
|
1089
1097
|
"autoIndexOnWrite": true,
|
|
1090
1098
|
"autoCanonicalContextLinks": true,
|
|
1099
|
+
"autoUpdateCheck": true,
|
|
1100
|
+
"updateCheckIntervalMs": 86400000,
|
|
1091
1101
|
"defaultSearchLimit": 8,
|
|
1092
1102
|
"defaultContextTokens": 1500,
|
|
1093
1103
|
"defaultContextStrategy": "auto",
|
|
@@ -1120,6 +1130,7 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
|
|
|
1120
1130
|
|
|
1121
1131
|
`autoIndexOnWrite` is optional and defaults to `true`. Set it to `false` to defer indexing after writes.
|
|
1122
1132
|
`autoCanonicalContextLinks` is optional and defaults to `true`. When enabled, `blink add`, `brainlink_add_note` and `brainlink_add_file` add a canonical `## Context Links` entry to the inferred context hub, creating that hub when needed.
|
|
1133
|
+
`autoUpdateCheck` is optional and defaults to `true`. Brainlink checks the npm registry no more often than `updateCheckIntervalMs` and prints an update notice to `stderr`; it never installs updates automatically.
|
|
1123
1134
|
|
|
1124
1135
|
## Remote MCP Server
|
|
1125
1136
|
|
|
@@ -1206,6 +1217,41 @@ spec:
|
|
|
1206
1217
|
|
|
1207
1218
|
Use `"embeddingProvider": "none"` when you want FTS-only indexing.
|
|
1208
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
|
+
|
|
1209
1255
|
For local security checks, set your Snyk token in the environment:
|
|
1210
1256
|
|
|
1211
1257
|
```bash
|
|
@@ -2,7 +2,7 @@ import { writeMarkdownFile } from '../infrastructure/file-system-vault.js';
|
|
|
2
2
|
import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
|
|
3
3
|
import { validateNoteInput } from '../domain/note-safety.js';
|
|
4
4
|
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
5
|
-
import { addCanonicalContextLinkToContent, ensureCanonicalContextHub } from './canonical-context-links.js';
|
|
5
|
+
import { addCanonicalContextLinkToContent, ensureCanonicalContextHub, loadVisualContextRules } from './canonical-context-links.js';
|
|
6
6
|
const slugify = (title) => title
|
|
7
7
|
.normalize('NFKD')
|
|
8
8
|
.replace(/[\u0300-\u036f]/g, '')
|
|
@@ -31,7 +31,7 @@ export const addNoteWithMetadata = async (vaultPath, title, content, agentId = s
|
|
|
31
31
|
await ensureVault(vaultPath);
|
|
32
32
|
const canonical = options.autoContextLinks === false
|
|
33
33
|
? null
|
|
34
|
-
: addCanonicalContextLinkToContent(title, content.trim());
|
|
34
|
+
: addCanonicalContextLinkToContent(title, content.trim(), await loadVisualContextRules(vaultPath), filename);
|
|
35
35
|
const hub = canonical?.changed
|
|
36
36
|
? await ensureCanonicalContextHub(vaultPath, canonical.context, sanitizedAgentId)
|
|
37
37
|
: null;
|
|
@@ -74,7 +74,7 @@ export const buildContextPackage = async (vaultPath, query, limit, maxTokens, ag
|
|
|
74
74
|
const cacheKey = toCacheKey(vaultPath, query, limit, maxTokens, agentId, mode, strategy);
|
|
75
75
|
const dataSignature = await readContextDataSignature(vaultPath);
|
|
76
76
|
const cached = contextCacheGet(cacheKey, dataSignature, contextCacheTtlMs);
|
|
77
|
-
if (cached) {
|
|
77
|
+
if (cached && cached.sections.length > 0) {
|
|
78
78
|
return cached;
|
|
79
79
|
}
|
|
80
80
|
const shouldUseContextPack = strategy === 'cag' || strategy === 'auto';
|
|
@@ -83,7 +83,7 @@ export const buildContextPackage = async (vaultPath, query, limit, maxTokens, ag
|
|
|
83
83
|
const packReadStart = performance.now();
|
|
84
84
|
const pack = await readContextPack(vaultPath, { query, limit, maxTokens, agentId, mode }, dataSignature);
|
|
85
85
|
packReadMs = elapsedMs(packReadStart);
|
|
86
|
-
if (pack.status === 'hit') {
|
|
86
|
+
if (pack.status === 'hit' && pack.context.sections.length > 0) {
|
|
87
87
|
const contextFromPack = {
|
|
88
88
|
...pack.context,
|
|
89
89
|
strategy: 'cag',
|
|
@@ -133,8 +133,12 @@ export const buildContextPackage = async (vaultPath, query, limit, maxTokens, ag
|
|
|
133
133
|
: 'RAG was requested; Brainlink assembled context directly from current retrieval results.'
|
|
134
134
|
}
|
|
135
135
|
};
|
|
136
|
+
// Never persist an empty assembly: a transient empty context (e.g. an index
|
|
137
|
+
// that was momentarily unavailable) must not be cached as a fresh pack and
|
|
138
|
+
// then served on every later read until the data signature changes.
|
|
139
|
+
const hasSections = sections.length > 0;
|
|
136
140
|
const packWriteStart = performance.now();
|
|
137
|
-
const packPath = shouldUseContextPack
|
|
141
|
+
const packPath = shouldUseContextPack && hasSections
|
|
138
142
|
? await writeContextPack(vaultPath, { query, limit, maxTokens, agentId, mode }, dataSignature, context)
|
|
139
143
|
: undefined;
|
|
140
144
|
const packWriteMs = packPath ? elapsedMs(packWriteStart) : 0;
|
|
@@ -154,13 +158,15 @@ export const buildContextPackage = async (vaultPath, query, limit, maxTokens, ag
|
|
|
154
158
|
packWriteMs
|
|
155
159
|
})
|
|
156
160
|
};
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
161
|
+
if (hasSections) {
|
|
162
|
+
contextCacheSet({
|
|
163
|
+
key: cacheKey,
|
|
164
|
+
createdAt: Date.now(),
|
|
165
|
+
dataSignature,
|
|
166
|
+
strategy,
|
|
167
|
+
context: contextWithMetrics
|
|
168
|
+
});
|
|
169
|
+
}
|
|
164
170
|
return contextWithMetrics;
|
|
165
171
|
};
|
|
166
172
|
export const buildContext = async (vaultPath, query, limit, maxTokens, agentId, mode, strategy, contextCacheTtlMs = 120_000) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { inferVisualGraphContext } from '../domain/graph-contexts.js';
|
|
3
|
+
import { deriveVisualContextRules, inferVisualGraphContext } from '../domain/graph-contexts.js';
|
|
4
4
|
import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
|
|
5
5
|
import { extractContextLinkWeights, parseMarkdownDocument } from '../domain/markdown.js';
|
|
6
6
|
import { ensureVault, readMarkdownFileSummaries, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
|
|
@@ -60,6 +60,42 @@ const buildHubContent = (hubTitle, contextTitle, agentId) => [
|
|
|
60
60
|
`Canonical hub for the ${contextTitle} context. #memory #hub`,
|
|
61
61
|
''
|
|
62
62
|
].join('\n');
|
|
63
|
+
const isHubNotePath = (relativePath) => /(^|\/)[^/]*-hub\.md$/i.test(relativePath);
|
|
64
|
+
// Hub notes change rarely, but loadVisualContextRules runs on every auto-linked
|
|
65
|
+
// note write. Cache the derived rules per vault for a short window so a burst of
|
|
66
|
+
// writes reads the hub notes once. Creating a hub clears the cache so a new
|
|
67
|
+
// context is picked up immediately.
|
|
68
|
+
const visualContextRulesCacheTtlMs = 15_000;
|
|
69
|
+
const visualContextRulesCache = new Map();
|
|
70
|
+
const invalidateVisualContextRulesCache = () => {
|
|
71
|
+
visualContextRulesCache.clear();
|
|
72
|
+
};
|
|
73
|
+
// Build visual-context rules from the vault's hub notes only, so a single-note
|
|
74
|
+
// write path can classify into vault-specific contexts without reading the
|
|
75
|
+
// whole vault.
|
|
76
|
+
export const loadVisualContextRules = async (vaultPath) => {
|
|
77
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
78
|
+
const cached = visualContextRulesCache.get(absoluteVaultPath);
|
|
79
|
+
if (cached && Date.now() - cached.createdAt <= visualContextRulesCacheTtlMs) {
|
|
80
|
+
return cached.rules;
|
|
81
|
+
}
|
|
82
|
+
const summaries = await readMarkdownFileSummaries(absoluteVaultPath);
|
|
83
|
+
const documents = await Promise.all(summaries
|
|
84
|
+
.filter((summary) => isHubNotePath(summary.relativePath))
|
|
85
|
+
.map(async (summary) => {
|
|
86
|
+
const content = await readFile(summary.absolutePath, 'utf8');
|
|
87
|
+
return parseMarkdownDocument({
|
|
88
|
+
absolutePath: summary.absolutePath,
|
|
89
|
+
vaultPath: absoluteVaultPath,
|
|
90
|
+
content,
|
|
91
|
+
createdAt: summary.createdAt,
|
|
92
|
+
updatedAt: summary.updatedAt
|
|
93
|
+
});
|
|
94
|
+
}));
|
|
95
|
+
const rules = deriveVisualContextRules(documents);
|
|
96
|
+
visualContextRulesCache.set(absoluteVaultPath, { createdAt: Date.now(), rules });
|
|
97
|
+
return rules;
|
|
98
|
+
};
|
|
63
99
|
const readNotes = async (vaultPath) => {
|
|
64
100
|
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
65
101
|
const summaries = await readMarkdownFileSummaries(absoluteVaultPath);
|
|
@@ -92,6 +128,7 @@ export const ensureCanonicalContextHub = async (vaultPath, contextTitle, agentId
|
|
|
92
128
|
};
|
|
93
129
|
}
|
|
94
130
|
const path = await writeMarkdownFile(vaultPath, hubPath, buildHubContent(hubTitle, contextTitle, agentId));
|
|
131
|
+
invalidateVisualContextRulesCache();
|
|
95
132
|
return {
|
|
96
133
|
created: true,
|
|
97
134
|
title: hubTitle,
|
|
@@ -102,6 +139,7 @@ export const canonicalizeContextLinks = async (vaultPath, options = {}) => {
|
|
|
102
139
|
const agentId = options.agentId ? sanitizeAgentId(options.agentId) : undefined;
|
|
103
140
|
const createMissingHubs = options.createMissingHubs !== false;
|
|
104
141
|
const notes = await readNotes(vaultPath);
|
|
142
|
+
const rules = deriveVisualContextRules(notes.map((note) => note.document));
|
|
105
143
|
const scopedNotes = agentId ? notes.filter((note) => note.document.agentId === agentId) : notes;
|
|
106
144
|
const knownTitles = new Set(notes.map((note) => normalizeTitle(note.document.title)));
|
|
107
145
|
const entries = [];
|
|
@@ -124,6 +162,7 @@ export const canonicalizeContextLinks = async (vaultPath, options = {}) => {
|
|
|
124
162
|
knownTitles.add(normalizeTitle(hubTitle));
|
|
125
163
|
if (!options.dryRun) {
|
|
126
164
|
await writeMarkdownFile(vaultPath, path, buildHubContent(hubTitle, contextTitle, targetAgentId));
|
|
165
|
+
invalidateVisualContextRulesCache();
|
|
127
166
|
}
|
|
128
167
|
entries.push({
|
|
129
168
|
path,
|
|
@@ -136,7 +175,7 @@ export const canonicalizeContextLinks = async (vaultPath, options = {}) => {
|
|
|
136
175
|
return true;
|
|
137
176
|
};
|
|
138
177
|
for (const note of scopedNotes) {
|
|
139
|
-
const context = inferVisualGraphContext(note.document);
|
|
178
|
+
const context = inferVisualGraphContext(note.document, rules);
|
|
140
179
|
const hubTitle = hubTitleForContext(context.title);
|
|
141
180
|
const isHub = normalizeTitle(note.document.title) === normalizeTitle(hubTitle);
|
|
142
181
|
if (isHub) {
|
|
@@ -189,15 +228,15 @@ export const canonicalizeContextLinks = async (vaultPath, options = {}) => {
|
|
|
189
228
|
entries
|
|
190
229
|
};
|
|
191
230
|
};
|
|
192
|
-
export const addCanonicalContextLinkToContent = (title, content) => {
|
|
231
|
+
export const addCanonicalContextLinkToContent = (title, content, rules = [], notePath = '') => {
|
|
193
232
|
const context = inferVisualGraphContext({
|
|
194
233
|
id: '',
|
|
195
234
|
agentId: sharedAgentId,
|
|
196
235
|
title,
|
|
197
|
-
path:
|
|
236
|
+
path: notePath,
|
|
198
237
|
content,
|
|
199
238
|
tags: [],
|
|
200
|
-
});
|
|
239
|
+
}, rules);
|
|
201
240
|
const hubTitle = hubTitleForContext(context.title);
|
|
202
241
|
const nextContent = normalizeTitle(title) === normalizeTitle(hubTitle) ? content : upsertCanonicalContextLink(content, hubTitle);
|
|
203
242
|
return {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { getBrainlinkHomePath } from '../infrastructure/paths.js';
|
|
4
|
+
const defaultTimeoutMs = 800;
|
|
5
|
+
const registryBaseUrl = 'https://registry.npmjs.org';
|
|
6
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
7
|
+
const parseCache = (value) => isRecord(value) ? { checkedAt: typeof value.checkedAt === 'string' ? value.checkedAt : undefined, latestVersion: typeof value.latestVersion === 'string' ? value.latestVersion : undefined } : {};
|
|
8
|
+
const readCache = async (path) => {
|
|
9
|
+
try {
|
|
10
|
+
return parseCache(JSON.parse(await readFile(path, 'utf8')));
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const writeCache = async (path, cache) => {
|
|
20
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
21
|
+
await writeFile(path, `${JSON.stringify(cache, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
22
|
+
};
|
|
23
|
+
const parseVersionParts = (version) => {
|
|
24
|
+
const [core] = version.split('-');
|
|
25
|
+
const parts = core?.split('.').map((part) => Number.parseInt(part, 10)) ?? [];
|
|
26
|
+
return parts.length > 0 && parts.every((part) => Number.isFinite(part) && part >= 0) ? parts : null;
|
|
27
|
+
};
|
|
28
|
+
export const comparePackageVersions = (left, right) => {
|
|
29
|
+
const leftParts = parseVersionParts(left);
|
|
30
|
+
const rightParts = parseVersionParts(right);
|
|
31
|
+
if (!leftParts || !rightParts) {
|
|
32
|
+
return left.localeCompare(right);
|
|
33
|
+
}
|
|
34
|
+
const length = Math.max(leftParts.length, rightParts.length);
|
|
35
|
+
for (let index = 0; index < length; index += 1) {
|
|
36
|
+
const leftPart = leftParts[index] ?? 0;
|
|
37
|
+
const rightPart = rightParts[index] ?? 0;
|
|
38
|
+
if (leftPart !== rightPart) {
|
|
39
|
+
return leftPart > rightPart ? 1 : -1;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return 0;
|
|
43
|
+
};
|
|
44
|
+
const isCacheFresh = (cache, now, intervalMs) => {
|
|
45
|
+
if (!cache.checkedAt) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
const checkedAt = new Date(cache.checkedAt).getTime();
|
|
49
|
+
return Number.isFinite(checkedAt) && now.getTime() - checkedAt < intervalMs;
|
|
50
|
+
};
|
|
51
|
+
const createSkippedStatus = (input, now, reason, latestVersion = null) => ({
|
|
52
|
+
currentVersion: input.currentVersion,
|
|
53
|
+
latestVersion,
|
|
54
|
+
updateAvailable: latestVersion ? comparePackageVersions(latestVersion, input.currentVersion) > 0 : false,
|
|
55
|
+
checkedAt: now.toISOString(),
|
|
56
|
+
installCommand: `npm install -g ${input.packageName}@latest`,
|
|
57
|
+
skipped: true,
|
|
58
|
+
reason
|
|
59
|
+
});
|
|
60
|
+
const readLatestVersion = async (packageName, fetchImpl, timeoutMs) => {
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetchImpl(`${registryBaseUrl}/${encodeURIComponent(packageName)}/latest`, { signal: controller.signal });
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const payload = await response.json();
|
|
69
|
+
return isRecord(payload) && typeof payload.version === 'string' ? payload.version : null;
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
clearTimeout(timeout);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
export const checkPackageUpdate = async (input) => {
|
|
76
|
+
const now = input.now ?? new Date();
|
|
77
|
+
const cachePath = input.cachePath ?? join(getBrainlinkHomePath(), 'update-check.json');
|
|
78
|
+
const cache = await readCache(cachePath);
|
|
79
|
+
if (!input.enabled) {
|
|
80
|
+
return createSkippedStatus(input, now, 'disabled');
|
|
81
|
+
}
|
|
82
|
+
if (isCacheFresh(cache, now, input.intervalMs)) {
|
|
83
|
+
return createSkippedStatus(input, now, 'cache-fresh', cache.latestVersion ?? null);
|
|
84
|
+
}
|
|
85
|
+
const fetchImpl = input.fetch ?? globalThis.fetch;
|
|
86
|
+
if (!fetchImpl) {
|
|
87
|
+
return createSkippedStatus(input, now, 'fetch-unavailable');
|
|
88
|
+
}
|
|
89
|
+
const latestVersion = await readLatestVersion(input.packageName, fetchImpl, input.timeoutMs ?? defaultTimeoutMs);
|
|
90
|
+
if (!latestVersion) {
|
|
91
|
+
return createSkippedStatus(input, now, 'latest-unavailable');
|
|
92
|
+
}
|
|
93
|
+
await writeCache(cachePath, {
|
|
94
|
+
checkedAt: now.toISOString(),
|
|
95
|
+
latestVersion
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
currentVersion: input.currentVersion,
|
|
99
|
+
latestVersion,
|
|
100
|
+
updateAvailable: comparePackageVersions(latestVersion, input.currentVersion) > 0,
|
|
101
|
+
checkedAt: now.toISOString(),
|
|
102
|
+
installCommand: `npm install -g ${input.packageName}@latest`,
|
|
103
|
+
skipped: false
|
|
104
|
+
};
|
|
105
|
+
};
|
|
@@ -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
|
+
};
|