@andespindola/brainlink 1.0.4 → 1.0.6
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/README.md +17 -9
- 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/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 +189 -0
- package/dist/application/index-vault.js +44 -165
- package/dist/application/server/routes.js +12 -9
- 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 +39 -3
- package/dist/domain/embeddings.js +31 -5
- 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/infrastructure/config.js +10 -4
- package/dist/infrastructure/file-index.js +13 -4
- package/dist/infrastructure/semantic-prefilter.js +24 -0
- package/dist/mcp/server.js +7 -0
- package/dist/mcp/tool-guard.js +29 -0
- package/dist/mcp/tools/maintenance-tools.js +409 -0
- package/dist/mcp/tools/read-tools.js +504 -0
- package/dist/mcp/tools/shared.js +216 -0
- package/dist/mcp/tools/write-tools.js +247 -0
- package/dist/mcp/tools.js +3 -1357
- package/docs/AGENT_USAGE.md +4 -4
- package/docs/QUICKSTART.md +5 -1
- package/package.json +2 -2
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:
|
|
@@ -569,7 +574,7 @@ By default, Brainlink enforces bootstrap and auto-runs it for read tools when se
|
|
|
569
574
|
If you disable `autoBootstrapOnRead` through `brainlink_policy`, read tools return a preflight instruction with suggested `brainlink_bootstrap` arguments.
|
|
570
575
|
`brainlink_bootstrap`, `brainlink_policy` and preflight responses include structured `nextActions` so MCP clients can continue automatically without custom parsing.
|
|
571
576
|
For one-call planning, use `brainlink_recommendations` to get the recommended tool sequence for the current vault/agent/query.
|
|
572
|
-
The MCP context tools are plug-and-play by default: omit `strategy` to use the configured default (`
|
|
577
|
+
The MCP context tools are plug-and-play by default: omit `strategy` to use the configured token-efficient default (`auto` unless changed), pass `strategy: "cag"` for repeated/stable task context, or pass `strategy: "rag"` when fresh retrieval is required. `brainlink_recommendations`, preflight responses and policy next actions include executable context arguments so clients can continue without custom parsing.
|
|
573
578
|
|
|
574
579
|
The same linking rule applies through MCP: `brainlink_context` is read-only, and real graph links require Markdown notes with explicit `## Context Links` sections. `brainlink_add_note` and `brainlink_add_file` reindex by default and include index + `writeConnectivity` metadata. Brainlink does not auto-link new notes to fallback hubs.
|
|
575
580
|
|
|
@@ -657,8 +662,8 @@ Routes:
|
|
|
657
662
|
- `GET /api/graph-view?x=<x>&y=<y>&w=<width>&h=<height>&scale=<scale>`
|
|
658
663
|
- `GET /api/graph-stream?x=<x>&y=<y>&w=<width>&h=<height>&scale=<scale>&nodeBudget=<n>&edgeBudget=<n>`
|
|
659
664
|
- `GET /api/graph-node?id=<node-id>`
|
|
660
|
-
- `GET /api/search?q=<query>&limit=
|
|
661
|
-
- `GET /api/context?q=<query>&limit=
|
|
665
|
+
- `GET /api/search?q=<query>&limit=8&mode=hybrid`
|
|
666
|
+
- `GET /api/context?q=<query>&limit=8&tokens=1500&mode=hybrid`
|
|
662
667
|
- `GET /api/links`
|
|
663
668
|
- `GET /api/backlinks?title=<title>`
|
|
664
669
|
- `GET /api/stats`
|
|
@@ -936,7 +941,7 @@ Context selection uses a middle-out strategy: it starts from the strongest chunk
|
|
|
936
941
|
### `context`
|
|
937
942
|
|
|
938
943
|
```bash
|
|
939
|
-
blink context "question" --vault ./vault --limit
|
|
944
|
+
blink context "question" --vault ./vault --limit 8 --tokens 1500
|
|
940
945
|
blink context "question" --vault ./vault --agent coding-agent --json
|
|
941
946
|
blink context "question" --vault ./vault --agent coding-agent --mode hybrid --json
|
|
942
947
|
blink context "question" --vault ./vault --agent coding-agent --strategy cag --json
|
|
@@ -947,7 +952,7 @@ blink context-packs --vault ./vault --stale --clear
|
|
|
947
952
|
|
|
948
953
|
Builds a compact context package for an agent.
|
|
949
954
|
Repeated calls with the same vault, agent, query, mode and token/limit settings are served from a short in-memory cache while the index is unchanged.
|
|
950
|
-
The default strategy is configured by `defaultContextStrategy` and starts as `
|
|
955
|
+
The default strategy is configured by `defaultContextStrategy` and starts as `auto`, which uses CAG when a fresh pack exists and RAG otherwise, refreshing a pack for future calls. `--strategy cag` enables cache-augmented context generation by reading or refreshing a persisted context pack under `.brainlink/context-packs`; `--strategy rag` forces fresh retrieval assembly from the current index. Context responses include `cache`, `metrics`, `requestedStrategy` and `recommendedStrategy` metadata. Packs are derived artifacts and become stale when the index or volatile memory signature changes.
|
|
951
956
|
|
|
952
957
|
### `links`
|
|
953
958
|
|
|
@@ -1088,9 +1093,11 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
|
|
|
1088
1093
|
"defaultAgent": "shared",
|
|
1089
1094
|
"autoIndexOnWrite": true,
|
|
1090
1095
|
"autoCanonicalContextLinks": true,
|
|
1091
|
-
"
|
|
1092
|
-
"
|
|
1093
|
-
"
|
|
1096
|
+
"autoUpdateCheck": true,
|
|
1097
|
+
"updateCheckIntervalMs": 86400000,
|
|
1098
|
+
"defaultSearchLimit": 8,
|
|
1099
|
+
"defaultContextTokens": 1500,
|
|
1100
|
+
"defaultContextStrategy": "auto",
|
|
1094
1101
|
"embeddingProvider": "local",
|
|
1095
1102
|
"defaultSearchMode": "hybrid",
|
|
1096
1103
|
"chunkSize": 1200,
|
|
@@ -1105,7 +1112,7 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
|
|
|
1105
1112
|
"coding-agent": {
|
|
1106
1113
|
"defaultSearchMode": "semantic",
|
|
1107
1114
|
"defaultSearchLimit": 8,
|
|
1108
|
-
"defaultContextTokens":
|
|
1115
|
+
"defaultContextTokens": 1500,
|
|
1109
1116
|
"defaultContextStrategy": "auto"
|
|
1110
1117
|
},
|
|
1111
1118
|
"*": {
|
|
@@ -1120,6 +1127,7 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
|
|
|
1120
1127
|
|
|
1121
1128
|
`autoIndexOnWrite` is optional and defaults to `true`. Set it to `false` to defer indexing after writes.
|
|
1122
1129
|
`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.
|
|
1130
|
+
`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
1131
|
|
|
1124
1132
|
## Remote MCP Server
|
|
1125
1133
|
|
|
@@ -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,236 @@
|
|
|
1
|
+
export const createChunkFetchJs = () => `
|
|
2
|
+
const fitFromChunk = () => {
|
|
3
|
+
const nodes = normalizeList(state.chunk.nodes)
|
|
4
|
+
if (nodes.length === 0) {
|
|
5
|
+
return
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
let minX = Infinity
|
|
9
|
+
let minY = Infinity
|
|
10
|
+
let maxX = -Infinity
|
|
11
|
+
let maxY = -Infinity
|
|
12
|
+
|
|
13
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
14
|
+
const node = nodes[index]
|
|
15
|
+
const x = Number(node[2])
|
|
16
|
+
const y = Number(node[3])
|
|
17
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
18
|
+
continue
|
|
19
|
+
}
|
|
20
|
+
if (x < minX) minX = x
|
|
21
|
+
if (y < minY) minY = y
|
|
22
|
+
if (x > maxX) maxX = x
|
|
23
|
+
if (y > maxY) maxY = y
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const width = Math.max(1, maxX - minX)
|
|
31
|
+
const height = Math.max(1, maxY - minY)
|
|
32
|
+
const scaleX = state.viewport.width / width
|
|
33
|
+
const scaleY = state.viewport.height / height
|
|
34
|
+
const scale = clampScale(Math.min(scaleX, scaleY) * 0.72)
|
|
35
|
+
|
|
36
|
+
state.camera.scale = scale
|
|
37
|
+
state.camera.x = state.viewport.width / 2 - (minX + width / 2) * scale
|
|
38
|
+
state.camera.y = state.viewport.height / 2 - (minY + height / 2) * scale
|
|
39
|
+
updateWorkerCamera()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const fetchChunk = async ({ fit } = { fit: false }) => {
|
|
43
|
+
const token = ++state.fetchToken
|
|
44
|
+
if (state.fetchAbortController) {
|
|
45
|
+
state.fetchAbortController.abort()
|
|
46
|
+
}
|
|
47
|
+
const controller = new AbortController()
|
|
48
|
+
state.fetchAbortController = controller
|
|
49
|
+
const worldTopLeft = screenToWorld(0, 0)
|
|
50
|
+
const worldBottomRight = screenToWorld(state.viewport.width, state.viewport.height)
|
|
51
|
+
const x = Math.min(worldTopLeft.x, worldBottomRight.x)
|
|
52
|
+
const y = Math.min(worldTopLeft.y, worldBottomRight.y)
|
|
53
|
+
const w = Math.abs(worldBottomRight.x - worldTopLeft.x)
|
|
54
|
+
const h = Math.abs(worldBottomRight.y - worldTopLeft.y)
|
|
55
|
+
|
|
56
|
+
const params = new URLSearchParams({
|
|
57
|
+
x: String(x),
|
|
58
|
+
y: String(y),
|
|
59
|
+
w: String(Math.max(1, w)),
|
|
60
|
+
h: String(Math.max(1, h)),
|
|
61
|
+
scale: String(state.camera.scale),
|
|
62
|
+
nodeBudget: String(getZoomNodeBudget()),
|
|
63
|
+
edgeBudget: String(getZoomEdgeBudget())
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
if (state.agentId) {
|
|
67
|
+
params.set('agent', state.agentId)
|
|
68
|
+
}
|
|
69
|
+
if (state.contextId) {
|
|
70
|
+
params.set('context', state.contextId)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const requestKey = graphStreamRequestKey({ x, y, w, h })
|
|
74
|
+
if (!fit && state.lastChunkRequestKey === requestKey && state.chunk.nodes.length > 0) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const response = await fetch('/api/graph-stream?' + params.toString(), { signal: controller.signal })
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
throw new Error('Failed to fetch graph stream chunk')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const chunk = await response.json()
|
|
84
|
+
if (controller.signal.aborted) {
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
if (token !== state.fetchToken) {
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
state.graphSignature = typeof chunk.signature === 'string' ? chunk.signature : ''
|
|
92
|
+
state.lastChunkRequestKey = requestKey
|
|
93
|
+
ensureNodePositionsLoaded()
|
|
94
|
+
await syncNodePositionsFromServer()
|
|
95
|
+
state.graphMode = typeof chunk.mode === 'string' ? chunk.mode : 'near'
|
|
96
|
+
const chunkNodes = applyManualNodePositions(chunk.nodes)
|
|
97
|
+
state.chunk = {
|
|
98
|
+
nodes: chunkNodes,
|
|
99
|
+
edges: normalizeList(chunk.edges)
|
|
100
|
+
}
|
|
101
|
+
state.miniMapDirty = true
|
|
102
|
+
state.spatialIndex.key = ''
|
|
103
|
+
const renderChunk = { ...chunk, nodes: chunkNodes }
|
|
104
|
+
state.totals = {
|
|
105
|
+
nodes: Number.isFinite(chunk?.totals?.nodes) ? Number(chunk.totals.nodes) : state.chunk.nodes.length,
|
|
106
|
+
edges: Number.isFinite(chunk?.totals?.edges) ? Number(chunk.totals.edges) : state.chunk.edges.length
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
updateTotals()
|
|
110
|
+
|
|
111
|
+
if (fit) {
|
|
112
|
+
fitFromChunk()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (state.renderWorker && state.workerReady) {
|
|
116
|
+
state.renderWorker.postMessage({ type: 'chunk', chunk: renderChunk })
|
|
117
|
+
state.renderWorker.postMessage({ type: 'select', id: state.selectedNodeId })
|
|
118
|
+
state.renderWorker.postMessage({ type: 'highlight', ids: Array.from(state.searchResultIds) })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
updateGraphOverlays()
|
|
122
|
+
drawFallback()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const scheduleChunkFetch = ({ fit } = { fit: false }) => {
|
|
126
|
+
if (state.fetchTimer) {
|
|
127
|
+
clearTimeout(state.fetchTimer)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const now = performance.now()
|
|
131
|
+
const recentlyWheeling = now - state.lastWheelAt < 320
|
|
132
|
+
const heavyScene = state.lastVisibleNodes > 1200 || state.lastVisibleEdges > 3500
|
|
133
|
+
const delay = fit ? 0 : (state.pointer.down ? 320 : (recentlyWheeling ? (heavyScene ? 420 : 300) : (heavyScene ? 120 : 72)))
|
|
134
|
+
state.fetchTimer = setTimeout(() => {
|
|
135
|
+
state.fetchTimer = null
|
|
136
|
+
fetchChunk({ fit }).catch((error) => {
|
|
137
|
+
if (error && error.name === 'AbortError') {
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
console.error(error)
|
|
141
|
+
})
|
|
142
|
+
}, delay)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const setViewportFromCanvas = () => {
|
|
146
|
+
const rect = canvas.getBoundingClientRect()
|
|
147
|
+
state.viewport.width = Math.max(320, rect.width)
|
|
148
|
+
state.viewport.height = Math.max(320, rect.height)
|
|
149
|
+
state.viewport.ratio = window.devicePixelRatio || 1
|
|
150
|
+
state.miniMapDirty = true
|
|
151
|
+
updateWorkerSize()
|
|
152
|
+
drawFallback()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const pickFallbackNode = (screenX, screenY) => {
|
|
156
|
+
const nodes = spatialCandidates(screenX, screenY)
|
|
157
|
+
if (nodes.length === 0) {
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let bestNode = null
|
|
162
|
+
let bestDistance = Infinity
|
|
163
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
164
|
+
const node = nodes[index]
|
|
165
|
+
const id = typeof node[0] === 'string' ? node[0] : ''
|
|
166
|
+
if (!id) continue
|
|
167
|
+
const x = Number(node[2])
|
|
168
|
+
const y = Number(node[3])
|
|
169
|
+
const weight = Number(node[7])
|
|
170
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) continue
|
|
171
|
+
const point = worldToScreen(x, y)
|
|
172
|
+
const radius = Math.max(3.2, Math.min(16.5, 5 + (Number.isFinite(weight) ? weight : 0) * 0.65))
|
|
173
|
+
const distance = Math.hypot(screenX - point.x, screenY - point.y)
|
|
174
|
+
if (distance <= radius && distance < bestDistance) {
|
|
175
|
+
bestDistance = distance
|
|
176
|
+
bestNode = node
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return bestNode
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const pickFallbackNodeId = (screenX, screenY) => {
|
|
184
|
+
const node = pickFallbackNode(screenX, screenY)
|
|
185
|
+
return typeof node?.[0] === 'string' ? node[0] : ''
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const handlePickedNode = (node) => {
|
|
189
|
+
const nodeId = typeof node?.id === 'string' ? node.id : typeof node?.[0] === 'string' ? node[0] : ''
|
|
190
|
+
if (!nodeId) {
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const kind = typeof node?.kind === 'string' ? node.kind : nodeKind(node)
|
|
195
|
+
if (kind === 'cluster') {
|
|
196
|
+
const currentScale = state.camera.scale
|
|
197
|
+
const targetScale = currentScale < 0.22 ? 0.28 : Math.min(1.1, currentScale * 1.6)
|
|
198
|
+
focusNodeInViewport(nodeId, targetScale)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
loadNodeDetails(nodeId).catch((error) => console.error(error))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const pickAt = (screenX, screenY) => {
|
|
206
|
+
if (state.rendererMode === 'fallback') {
|
|
207
|
+
const node = pickFallbackNode(screenX, screenY)
|
|
208
|
+
if (node) {
|
|
209
|
+
handlePickedNode(node)
|
|
210
|
+
}
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!state.renderWorker || !state.workerReady) {
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const requestId = Math.random().toString(36).slice(2)
|
|
219
|
+
state.renderWorker.postMessage({
|
|
220
|
+
type: 'pick',
|
|
221
|
+
requestId,
|
|
222
|
+
x: screenX,
|
|
223
|
+
y: screenY
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const zoomAtPoint = (screenX, screenY, factor) => {
|
|
228
|
+
const clamped = Math.max(0.92, Math.min(1.09, factor))
|
|
229
|
+
const before = screenToWorld(screenX, screenY)
|
|
230
|
+
state.camera.scale = clampScale(state.camera.scale * clamped)
|
|
231
|
+
state.camera.x = screenX - before.x * state.camera.scale
|
|
232
|
+
state.camera.y = screenY - before.y * state.camera.scale
|
|
233
|
+
updateWorkerCamera()
|
|
234
|
+
scheduleChunkFetch()
|
|
235
|
+
}
|
|
236
|
+
`;
|