@andespindola/brainlink 0.1.0-beta.152 → 0.1.0-beta.154
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/AGENTS.md +2 -2
- package/README.md +26 -7
- package/dist/application/add-note.js +12 -3
- package/dist/application/canonical-context-links.js +209 -0
- package/dist/application/frontend/client-css.js +8 -4
- package/dist/application/frontend/client-html.js +3 -0
- package/dist/application/frontend/client-js.js +103 -7
- package/dist/application/get-graph-contexts.js +19 -0
- package/dist/application/get-graph-layout.js +38 -17
- package/dist/application/get-graph-stream-chunk.js +4 -1
- package/dist/application/get-graph-view.js +4 -1
- package/dist/application/search-graph-node-ids.js +14 -5
- package/dist/application/server/routes.js +17 -4
- package/dist/cli/commands/write-commands.js +47 -8
- package/dist/domain/graph-contexts.js +159 -0
- package/dist/domain/graph-layout.js +10 -1
- package/dist/infrastructure/config.js +4 -0
- package/dist/mcp/server.js +11 -1
- package/dist/mcp/tools.js +64 -5
- package/docs/AGENT_USAGE.md +7 -2
- package/docs/ARCHITECTURE.md +2 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -78,10 +78,10 @@ http://127.0.0.1:4321/
|
|
|
78
78
|
http://127.0.0.1:4321/api/graph
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
The graph server watches Markdown files by default while editing notes:
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
|
-
npm run dev -- server --vault ./vault
|
|
84
|
+
npm run dev -- server --vault ./vault
|
|
85
85
|
npm run dev -- watch --vault ./vault
|
|
86
86
|
npm run dev -- bench --vault ./vault
|
|
87
87
|
npm run dev -- bench --vault ./vault --watch
|
package/README.md
CHANGED
|
@@ -265,7 +265,7 @@ blink search "jwt auth" --vault ./vault
|
|
|
265
265
|
|
|
266
266
|
blink context "how does auth work?" --vault ./vault
|
|
267
267
|
|
|
268
|
-
blink server --vault ./vault
|
|
268
|
+
blink server --vault ./vault
|
|
269
269
|
```
|
|
270
270
|
|
|
271
271
|
Open the graph UI:
|
|
@@ -536,6 +536,7 @@ Available tools:
|
|
|
536
536
|
- `brainlink_resolve_duplicate`: resolve duplicate pairs (`merge`, `link`, `ignore`) with connectivity-safe fallback edges.
|
|
537
537
|
- `brainlink_add_note`: write durable Markdown memory and reindex.
|
|
538
538
|
- `brainlink_add_file`: ingest a local file as a note and reindex.
|
|
539
|
+
- `brainlink_canonicalize_context_links`: ensure existing notes link to inferred context hubs.
|
|
539
540
|
- `brainlink_volatile_add`: write temporary agent-decided memory with TTL; volatile sections are included in context and never create durable graph edges.
|
|
540
541
|
- `brainlink_volatile_clear`: clear temporary memory for the current vault/agent namespace.
|
|
541
542
|
- `brainlink_index`: rebuild the vault index. Pass `full=true` for a complete source reindex.
|
|
@@ -543,6 +544,7 @@ Available tools:
|
|
|
543
544
|
- `brainlink_validate`: validate broken links and orphan notes.
|
|
544
545
|
- `brainlink_sync`: run index, stats, validation, broken-link and orphan checks in one call.
|
|
545
546
|
- `brainlink_graph`: read indexed graph nodes and weighted links.
|
|
547
|
+
- `brainlink_graph_contexts`: list the visual graph contexts used by the local server.
|
|
546
548
|
- `brainlink_broken_links`: list unresolved wiki links.
|
|
547
549
|
- `brainlink_orphans`: list disconnected notes.
|
|
548
550
|
|
|
@@ -577,10 +579,11 @@ blink index --vault ./vault --full
|
|
|
577
579
|
Start the local frontend:
|
|
578
580
|
|
|
579
581
|
```bash
|
|
580
|
-
blink server --host 127.0.0.1 --port 4321
|
|
582
|
+
blink server --host 127.0.0.1 --port 4321
|
|
581
583
|
```
|
|
582
584
|
|
|
583
585
|
By default, the server uses `$HOME/.brainlink/vault`. Pass `--vault ./vault` only when you want to inspect a custom vault.
|
|
586
|
+
By default, the server watches Markdown files in local filesystem vaults and reindexes after note changes. Use `--no-watch` to disable realtime reindexing.
|
|
584
587
|
By default, `blink server` tries to open the graph in a native desktop GUI window:
|
|
585
588
|
- macOS: Swift + WebKit
|
|
586
589
|
- Windows: PowerShell WinForms WebBrowser
|
|
@@ -601,9 +604,10 @@ The graph UI shows:
|
|
|
601
604
|
- details opened in a non-modal side panel (tags, outgoing links, backlinks, full Markdown content), so zoom and pan remain available while inspecting data
|
|
602
605
|
- neutral graph nodes with segment/group metadata
|
|
603
606
|
- agent selector (id-only labels) for isolated views
|
|
607
|
+
- context selector for segment-scoped star subgraphs derived from the visual graph context
|
|
604
608
|
- graph filter matches title, path, tags and note content
|
|
605
609
|
- graph filter keeps hub context nodes visible (`Memory Hub`/`MOC`/high-degree fallback) to preserve relationship readability
|
|
606
|
-
- realtime refresh while
|
|
610
|
+
- realtime refresh while watch mode is enabled
|
|
607
611
|
- graph controls for zoom in, zoom out, fit visible nodes and reset-to-fit-all
|
|
608
612
|
- wheel zoom (including `cmd+scroll` and `ctrl+scroll`) anchored to cursor position for faster navigation in large graphs
|
|
609
613
|
- wheel/button zoom updates immediately at the cursor anchor without delayed focus-transition interpolation
|
|
@@ -632,6 +636,7 @@ The server always refuses non-loopback hosts. Brainlink HTTP only runs on localh
|
|
|
632
636
|
Routes:
|
|
633
637
|
|
|
634
638
|
- `GET /api/agents`
|
|
639
|
+
- `GET /api/graph-contexts`
|
|
635
640
|
- `GET /api/graph`
|
|
636
641
|
- `GET /api/graph-layout`
|
|
637
642
|
- `GET /api/graph-view?x=<x>&y=<y>&w=<width>&h=<height>&scale=<scale>`
|
|
@@ -650,6 +655,7 @@ Read routes accept `agent=<agent-id>`:
|
|
|
650
655
|
|
|
651
656
|
```txt
|
|
652
657
|
/api/graph-layout?agent=coding-agent
|
|
658
|
+
/api/graph-layout?agent=coding-agent&context=Architecture
|
|
653
659
|
/api/search?q=typescript&agent=coding-agent&mode=hybrid
|
|
654
660
|
/api/context?q=module-boundaries&agent=coding-agent&mode=semantic
|
|
655
661
|
```
|
|
@@ -941,15 +947,26 @@ blink watch --vault ./vault
|
|
|
941
947
|
|
|
942
948
|
Watches Markdown files and rebuilds the index when notes change.
|
|
943
949
|
|
|
950
|
+
### `canonicalize-context-links`
|
|
951
|
+
|
|
952
|
+
```bash
|
|
953
|
+
blink canonicalize-context-links --vault ./vault --dry-run
|
|
954
|
+
blink canonicalize-context-links --vault ./vault
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
Ensures existing notes have canonical `## Context Links` entries to inferred context hubs such as `User Preferences Hub`, `GitHub Repositories Hub` or `Brainlink Hub`. The command is idempotent, creates missing hub notes by default, and fully reindexes after writes unless `--no-index` is passed.
|
|
958
|
+
|
|
944
959
|
### `server`
|
|
945
960
|
|
|
946
961
|
```bash
|
|
947
|
-
blink server
|
|
948
|
-
blink server --vault ./vault
|
|
949
|
-
blink server --vault ./vault --
|
|
962
|
+
blink server
|
|
963
|
+
blink server --vault ./vault
|
|
964
|
+
blink server --vault ./vault --no-open
|
|
965
|
+
blink server --vault ./vault --no-watch
|
|
950
966
|
```
|
|
951
967
|
|
|
952
968
|
Starts the local read-only graph UI and HTTP API.
|
|
969
|
+
Watch mode is enabled by default for Markdown changes in local filesystem vaults. Use `--no-watch` to run without the watcher.
|
|
953
970
|
By default, it tries to open a native desktop GUI window for the graph URL.
|
|
954
971
|
On Linux, native GUI is disabled by default; enable it with `BRAINLINK_LINUX_NATIVE_GUI=1`.
|
|
955
972
|
If native GUI launch is unavailable, it falls back to dedicated app-window mode and then browser open.
|
|
@@ -990,6 +1007,7 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
|
|
|
990
1007
|
"allowedVaults": [".brainlink-vault"],
|
|
991
1008
|
"defaultAgent": "shared",
|
|
992
1009
|
"autoIndexOnWrite": true,
|
|
1010
|
+
"autoCanonicalContextLinks": true,
|
|
993
1011
|
"defaultSearchLimit": 10,
|
|
994
1012
|
"defaultContextTokens": 2000,
|
|
995
1013
|
"embeddingProvider": "local",
|
|
@@ -1019,6 +1037,7 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
|
|
|
1019
1037
|
`agentProfiles` is optional. When present, CLI and MCP resolve `mode`, `limit` and `tokens` per agent automatically, then fallback to global defaults.
|
|
1020
1038
|
|
|
1021
1039
|
`autoIndexOnWrite` is optional and defaults to `true`. Set it to `false` to defer indexing after writes.
|
|
1040
|
+
`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.
|
|
1022
1041
|
|
|
1023
1042
|
Use `"embeddingProvider": "none"` when you want FTS-only indexing.
|
|
1024
1043
|
|
|
@@ -1097,7 +1116,7 @@ Local CLI:
|
|
|
1097
1116
|
|
|
1098
1117
|
```bash
|
|
1099
1118
|
npm run dev -- --help
|
|
1100
|
-
npm run dev -- server --vault .brainlink-vault
|
|
1119
|
+
npm run dev -- server --vault .brainlink-vault
|
|
1101
1120
|
```
|
|
1102
1121
|
|
|
1103
1122
|
Package smoke test:
|
|
@@ -2,6 +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
6
|
const slugify = (title) => title
|
|
6
7
|
.normalize('NFKD')
|
|
7
8
|
.replace(/[\u0300-\u036f]/g, '')
|
|
@@ -28,12 +29,20 @@ export const addNoteWithMetadata = async (vaultPath, title, content, agentId = s
|
|
|
28
29
|
const sanitizedAgentId = sanitizeAgentId(agentId);
|
|
29
30
|
const filename = `agents/${sanitizedAgentId}/${slugify(title) || 'untitled'}.md`;
|
|
30
31
|
await ensureVault(vaultPath);
|
|
31
|
-
const
|
|
32
|
+
const canonical = options.autoContextLinks === false
|
|
33
|
+
? null
|
|
34
|
+
: addCanonicalContextLinkToContent(title, content.trim());
|
|
35
|
+
const hub = canonical?.changed
|
|
36
|
+
? await ensureCanonicalContextHub(vaultPath, canonical.context, sanitizedAgentId)
|
|
37
|
+
: null;
|
|
38
|
+
const note = buildNote(title, canonical?.content ?? content.trim(), sanitizedAgentId);
|
|
32
39
|
const path = await writeMarkdownFile(vaultPath, filename, note);
|
|
33
40
|
return {
|
|
34
41
|
path,
|
|
35
|
-
autoLinked: false,
|
|
36
|
-
linkTarget: null
|
|
42
|
+
autoLinked: canonical?.changed ?? false,
|
|
43
|
+
linkTarget: canonical?.changed ? canonical.hubTitle : null,
|
|
44
|
+
context: canonical?.context ?? null,
|
|
45
|
+
hubCreated: hub?.created ?? false
|
|
37
46
|
};
|
|
38
47
|
};
|
|
39
48
|
export const addNote = async (vaultPath, title, content, agentId = sharedAgentId, options = {}) => (await addNoteWithMetadata(vaultPath, title, content, agentId, options)).path;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { inferVisualGraphContext } from '../domain/graph-contexts.js';
|
|
4
|
+
import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
|
|
5
|
+
import { extractContextLinkWeights, parseMarkdownDocument } from '../domain/markdown.js';
|
|
6
|
+
import { ensureVault, readMarkdownFileSummaries, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
|
|
7
|
+
const canonicalPriority = 'high';
|
|
8
|
+
const slugify = (title) => title
|
|
9
|
+
.normalize('NFKD')
|
|
10
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
13
|
+
.replace(/^-+|-+$/g, '');
|
|
14
|
+
export const hubTitleForContext = (contextTitle) => `${contextTitle} Hub`;
|
|
15
|
+
const hubPathForContext = (contextTitle, agentId) => {
|
|
16
|
+
if (contextTitle === 'GitHub Repositories')
|
|
17
|
+
return 'github-repos/github-repositories-hub.md';
|
|
18
|
+
if (contextTitle === 'GitHub Organizations')
|
|
19
|
+
return 'github-org-repos/github-organizations-hub.md';
|
|
20
|
+
if (contextTitle === 'Machine Configuration')
|
|
21
|
+
return 'machine-config/machine-configuration-hub.md';
|
|
22
|
+
return join('agents', sanitizeAgentId(agentId), `${slugify(hubTitleForContext(contextTitle))}.md`).replaceAll('\\', '/');
|
|
23
|
+
};
|
|
24
|
+
const normalizeTitle = (title) => title.trim().replace(/\.md$/i, '').toLowerCase();
|
|
25
|
+
const hasCanonicalLink = (content, hubTitle) => extractContextLinkWeights(content).some((link) => normalizeTitle(link.title) === normalizeTitle(hubTitle));
|
|
26
|
+
const linkLine = (hubTitle) => `- [[${hubTitle}]] priority: ${canonicalPriority}`;
|
|
27
|
+
const contextLinksHeading = (line) => line.match(/^(#{2,6})\s+(Context Links|Links de Contexto)\s*$/i);
|
|
28
|
+
export const upsertCanonicalContextLink = (content, hubTitle) => {
|
|
29
|
+
if (hasCanonicalLink(content, hubTitle)) {
|
|
30
|
+
return content;
|
|
31
|
+
}
|
|
32
|
+
const lines = content.replace(/\s+$/u, '').split('\n');
|
|
33
|
+
const headingIndex = lines.findIndex((line) => contextLinksHeading(line.trim()));
|
|
34
|
+
if (headingIndex === -1) {
|
|
35
|
+
return `${lines.join('\n')}\n\n## Context Links\n\n${linkLine(hubTitle)}\n`;
|
|
36
|
+
}
|
|
37
|
+
const heading = contextLinksHeading(lines[headingIndex].trim());
|
|
38
|
+
const headingDepth = heading?.[1]?.length ?? 2;
|
|
39
|
+
const insertIndex = lines.findIndex((line, index) => {
|
|
40
|
+
if (index <= headingIndex)
|
|
41
|
+
return false;
|
|
42
|
+
const candidate = line.match(/^(#{2,6})\s+/);
|
|
43
|
+
return Boolean(candidate && candidate[1].length <= headingDepth);
|
|
44
|
+
});
|
|
45
|
+
const targetIndex = insertIndex === -1 ? lines.length : insertIndex;
|
|
46
|
+
const before = lines.slice(0, targetIndex);
|
|
47
|
+
const after = lines.slice(targetIndex);
|
|
48
|
+
const needsSpacer = before[before.length - 1]?.trim() !== '';
|
|
49
|
+
const nextLines = [...before, ...(needsSpacer ? [''] : []), linkLine(hubTitle), ...after];
|
|
50
|
+
return `${nextLines.join('\n').replace(/\s+$/u, '')}\n`;
|
|
51
|
+
};
|
|
52
|
+
const buildHubContent = (hubTitle, contextTitle, agentId) => [
|
|
53
|
+
'---',
|
|
54
|
+
`title: "${hubTitle.replaceAll('"', '\\"')}"`,
|
|
55
|
+
`agent: "${sanitizeAgentId(agentId)}"`,
|
|
56
|
+
'---',
|
|
57
|
+
'',
|
|
58
|
+
`# ${hubTitle}`,
|
|
59
|
+
'',
|
|
60
|
+
`Canonical hub for the ${contextTitle} context. #memory #hub`,
|
|
61
|
+
''
|
|
62
|
+
].join('\n');
|
|
63
|
+
const readNotes = async (vaultPath) => {
|
|
64
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
65
|
+
const summaries = await readMarkdownFileSummaries(absoluteVaultPath);
|
|
66
|
+
return Promise.all(summaries.map(async (summary) => {
|
|
67
|
+
const content = await readFile(summary.absolutePath, 'utf8');
|
|
68
|
+
const document = parseMarkdownDocument({
|
|
69
|
+
absolutePath: summary.absolutePath,
|
|
70
|
+
vaultPath: absoluteVaultPath,
|
|
71
|
+
content,
|
|
72
|
+
createdAt: summary.createdAt,
|
|
73
|
+
updatedAt: summary.updatedAt
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
summary,
|
|
77
|
+
content,
|
|
78
|
+
document
|
|
79
|
+
};
|
|
80
|
+
}));
|
|
81
|
+
};
|
|
82
|
+
export const ensureCanonicalContextHub = async (vaultPath, contextTitle, agentId = sharedAgentId) => {
|
|
83
|
+
const hubTitle = hubTitleForContext(contextTitle);
|
|
84
|
+
const notes = await readNotes(vaultPath);
|
|
85
|
+
const existing = notes.find((note) => normalizeTitle(note.document.title) === normalizeTitle(hubTitle));
|
|
86
|
+
const hubPath = existing?.summary.relativePath ?? hubPathForContext(contextTitle, agentId);
|
|
87
|
+
if (existing) {
|
|
88
|
+
return {
|
|
89
|
+
created: false,
|
|
90
|
+
title: hubTitle,
|
|
91
|
+
path: hubPath
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const path = await writeMarkdownFile(vaultPath, hubPath, buildHubContent(hubTitle, contextTitle, agentId));
|
|
95
|
+
return {
|
|
96
|
+
created: true,
|
|
97
|
+
title: hubTitle,
|
|
98
|
+
path
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
export const canonicalizeContextLinks = async (vaultPath, options = {}) => {
|
|
102
|
+
const agentId = options.agentId ? sanitizeAgentId(options.agentId) : undefined;
|
|
103
|
+
const createMissingHubs = options.createMissingHubs !== false;
|
|
104
|
+
const notes = await readNotes(vaultPath);
|
|
105
|
+
const scopedNotes = agentId ? notes.filter((note) => note.document.agentId === agentId) : notes;
|
|
106
|
+
const knownTitles = new Set(notes.map((note) => normalizeTitle(note.document.title)));
|
|
107
|
+
const entries = [];
|
|
108
|
+
const ensureHub = async (contextTitle, hubTitle, targetAgentId) => {
|
|
109
|
+
if (knownTitles.has(normalizeTitle(hubTitle))) {
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
const path = hubPathForContext(contextTitle, targetAgentId);
|
|
113
|
+
if (!createMissingHubs) {
|
|
114
|
+
entries.push({
|
|
115
|
+
path,
|
|
116
|
+
title: hubTitle,
|
|
117
|
+
context: contextTitle,
|
|
118
|
+
hubTitle,
|
|
119
|
+
changed: false,
|
|
120
|
+
reason: 'missing-hub'
|
|
121
|
+
});
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
knownTitles.add(normalizeTitle(hubTitle));
|
|
125
|
+
if (!options.dryRun) {
|
|
126
|
+
await writeMarkdownFile(vaultPath, path, buildHubContent(hubTitle, contextTitle, targetAgentId));
|
|
127
|
+
}
|
|
128
|
+
entries.push({
|
|
129
|
+
path,
|
|
130
|
+
title: hubTitle,
|
|
131
|
+
context: contextTitle,
|
|
132
|
+
hubTitle,
|
|
133
|
+
changed: true,
|
|
134
|
+
reason: 'created-hub'
|
|
135
|
+
});
|
|
136
|
+
return true;
|
|
137
|
+
};
|
|
138
|
+
for (const note of scopedNotes) {
|
|
139
|
+
const context = inferVisualGraphContext(note.document);
|
|
140
|
+
const hubTitle = hubTitleForContext(context.title);
|
|
141
|
+
const isHub = normalizeTitle(note.document.title) === normalizeTitle(hubTitle);
|
|
142
|
+
if (isHub) {
|
|
143
|
+
entries.push({
|
|
144
|
+
path: note.summary.relativePath,
|
|
145
|
+
title: note.document.title,
|
|
146
|
+
context: context.title,
|
|
147
|
+
hubTitle,
|
|
148
|
+
changed: false,
|
|
149
|
+
reason: 'hub-note'
|
|
150
|
+
});
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const hubAvailable = await ensureHub(context.title, hubTitle, note.document.agentId || sharedAgentId);
|
|
154
|
+
if (!hubAvailable) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (hasCanonicalLink(note.content, hubTitle)) {
|
|
158
|
+
entries.push({
|
|
159
|
+
path: note.summary.relativePath,
|
|
160
|
+
title: note.document.title,
|
|
161
|
+
context: context.title,
|
|
162
|
+
hubTitle,
|
|
163
|
+
changed: false,
|
|
164
|
+
reason: 'already-linked'
|
|
165
|
+
});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const nextContent = upsertCanonicalContextLink(note.content, hubTitle);
|
|
169
|
+
if (!options.dryRun) {
|
|
170
|
+
await writeMarkdownFile(vaultPath, note.summary.relativePath, nextContent);
|
|
171
|
+
}
|
|
172
|
+
entries.push({
|
|
173
|
+
path: note.summary.relativePath,
|
|
174
|
+
title: note.document.title,
|
|
175
|
+
context: context.title,
|
|
176
|
+
hubTitle,
|
|
177
|
+
changed: true,
|
|
178
|
+
reason: 'added-context-link'
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const changed = entries.filter((entry) => entry.changed).length;
|
|
182
|
+
const createdHubs = entries.filter((entry) => entry.reason === 'created-hub' && entry.changed).length;
|
|
183
|
+
return {
|
|
184
|
+
dryRun: options.dryRun === true,
|
|
185
|
+
scanned: scopedNotes.length,
|
|
186
|
+
changed,
|
|
187
|
+
createdHubs,
|
|
188
|
+
skipped: entries.length - changed,
|
|
189
|
+
entries
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
export const addCanonicalContextLinkToContent = (title, content) => {
|
|
193
|
+
const context = inferVisualGraphContext({
|
|
194
|
+
id: '',
|
|
195
|
+
agentId: sharedAgentId,
|
|
196
|
+
title,
|
|
197
|
+
path: '',
|
|
198
|
+
content,
|
|
199
|
+
tags: [],
|
|
200
|
+
});
|
|
201
|
+
const hubTitle = hubTitleForContext(context.title);
|
|
202
|
+
const nextContent = normalizeTitle(title) === normalizeTitle(hubTitle) ? content : upsertCanonicalContextLink(content, hubTitle);
|
|
203
|
+
return {
|
|
204
|
+
content: nextContent,
|
|
205
|
+
context: context.title,
|
|
206
|
+
hubTitle,
|
|
207
|
+
changed: nextContent !== content
|
|
208
|
+
};
|
|
209
|
+
};
|
|
@@ -113,7 +113,8 @@ select {
|
|
|
113
113
|
min-width: 220px;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
.agent-filter
|
|
116
|
+
.agent-filter,
|
|
117
|
+
.context-filter {
|
|
117
118
|
width: min(220px, 28vw);
|
|
118
119
|
}
|
|
119
120
|
|
|
@@ -125,7 +126,8 @@ select {
|
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
.search input,
|
|
128
|
-
.agent-filter select
|
|
129
|
+
.agent-filter select,
|
|
130
|
+
.context-filter select {
|
|
129
131
|
width: 100%;
|
|
130
132
|
height: 40px;
|
|
131
133
|
border: 1px solid var(--line);
|
|
@@ -137,7 +139,8 @@ select {
|
|
|
137
139
|
}
|
|
138
140
|
|
|
139
141
|
.search input:focus,
|
|
140
|
-
.agent-filter select:focus
|
|
142
|
+
.agent-filter select:focus,
|
|
143
|
+
.context-filter select:focus {
|
|
141
144
|
border-color: var(--accent);
|
|
142
145
|
}
|
|
143
146
|
|
|
@@ -405,7 +408,8 @@ li small {
|
|
|
405
408
|
order: 3;
|
|
406
409
|
}
|
|
407
410
|
|
|
408
|
-
.agent-filter
|
|
411
|
+
.agent-filter,
|
|
412
|
+
.context-filter {
|
|
409
413
|
width: 100%;
|
|
410
414
|
}
|
|
411
415
|
|
|
@@ -35,6 +35,9 @@ export const createClientHtml = () => `<!doctype html>
|
|
|
35
35
|
<label class="agent-filter">
|
|
36
36
|
<select id="agent"></select>
|
|
37
37
|
</label>
|
|
38
|
+
<label class="context-filter">
|
|
39
|
+
<select id="context"></select>
|
|
40
|
+
</label>
|
|
38
41
|
<div class="toolbar" aria-label="Graph controls">
|
|
39
42
|
<button id="zoomIn" type="button" title="Zoom in">+</button>
|
|
40
43
|
<button id="zoomOut" type="button" title="Zoom out">-</button>
|
|
@@ -4,6 +4,7 @@ const byId = (id) => document.getElementById(id)
|
|
|
4
4
|
const elements = {
|
|
5
5
|
search: byId('search'),
|
|
6
6
|
agent: byId('agent'),
|
|
7
|
+
context: byId('context'),
|
|
7
8
|
nodeCount: byId('nodeCount'),
|
|
8
9
|
edgeCount: byId('edgeCount'),
|
|
9
10
|
tagCount: byId('tagCount'),
|
|
@@ -49,6 +50,7 @@ const state = {
|
|
|
49
50
|
rendererMode: 'worker',
|
|
50
51
|
renderWorker: null,
|
|
51
52
|
agentId: '',
|
|
53
|
+
contextId: '',
|
|
52
54
|
graphSignature: '',
|
|
53
55
|
graphMode: 'near',
|
|
54
56
|
chunk: {
|
|
@@ -77,6 +79,7 @@ const zoomRange = {
|
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
const selectedAgentStorageKey = 'brainlink:selected-agent'
|
|
82
|
+
const selectedContextStorageKey = 'brainlink:selected-context'
|
|
80
83
|
|
|
81
84
|
const escapeHtml = (value) => String(value)
|
|
82
85
|
.replaceAll('&', '&')
|
|
@@ -104,6 +107,25 @@ const writeStoredAgent = (agentId) => {
|
|
|
104
107
|
} catch {}
|
|
105
108
|
}
|
|
106
109
|
|
|
110
|
+
const readStoredContext = () => {
|
|
111
|
+
try {
|
|
112
|
+
const value = window.localStorage.getItem(selectedContextStorageKey)?.trim() ?? ''
|
|
113
|
+
return value.length > 0 ? value : ''
|
|
114
|
+
} catch {
|
|
115
|
+
return ''
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const writeStoredContext = (contextId) => {
|
|
120
|
+
try {
|
|
121
|
+
if (!contextId) {
|
|
122
|
+
window.localStorage.removeItem(selectedContextStorageKey)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
window.localStorage.setItem(selectedContextStorageKey, contextId)
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
128
|
+
|
|
107
129
|
const syncAgentInUrl = (agentId) => {
|
|
108
130
|
try {
|
|
109
131
|
const url = new URL(window.location.href)
|
|
@@ -116,6 +138,18 @@ const syncAgentInUrl = (agentId) => {
|
|
|
116
138
|
} catch {}
|
|
117
139
|
}
|
|
118
140
|
|
|
141
|
+
const syncContextInUrl = (contextId) => {
|
|
142
|
+
try {
|
|
143
|
+
const url = new URL(window.location.href)
|
|
144
|
+
if (contextId && contextId.trim().length > 0) {
|
|
145
|
+
url.searchParams.set('context', contextId)
|
|
146
|
+
} else {
|
|
147
|
+
url.searchParams.delete('context')
|
|
148
|
+
}
|
|
149
|
+
window.history.replaceState({}, '', url.toString())
|
|
150
|
+
} catch {}
|
|
151
|
+
}
|
|
152
|
+
|
|
119
153
|
const initialAgentFromUrl = (() => {
|
|
120
154
|
try {
|
|
121
155
|
const raw = new URL(window.location.href).searchParams.get('agent')
|
|
@@ -126,7 +160,28 @@ const initialAgentFromUrl = (() => {
|
|
|
126
160
|
}
|
|
127
161
|
})()
|
|
128
162
|
|
|
129
|
-
const
|
|
163
|
+
const initialContextFromUrl = (() => {
|
|
164
|
+
try {
|
|
165
|
+
const raw = new URL(window.location.href).searchParams.get('context')
|
|
166
|
+
const value = raw?.trim() ?? ''
|
|
167
|
+
return value.length > 0 ? value : ''
|
|
168
|
+
} catch {
|
|
169
|
+
return ''
|
|
170
|
+
}
|
|
171
|
+
})()
|
|
172
|
+
|
|
173
|
+
const scopeQuery = (separator = '?') => {
|
|
174
|
+
const params = new URLSearchParams()
|
|
175
|
+
if (state.agentId) {
|
|
176
|
+
params.set('agent', state.agentId)
|
|
177
|
+
}
|
|
178
|
+
if (state.contextId) {
|
|
179
|
+
params.set('context', state.contextId)
|
|
180
|
+
}
|
|
181
|
+
const query = params.toString()
|
|
182
|
+
|
|
183
|
+
return query ? separator + query : ''
|
|
184
|
+
}
|
|
130
185
|
|
|
131
186
|
const parseColor = (hex) => {
|
|
132
187
|
const normalized = String(hex || '#ffffff').replace('#', '')
|
|
@@ -375,7 +430,7 @@ const loadNodeDetails = async (nodeId) => {
|
|
|
375
430
|
return
|
|
376
431
|
}
|
|
377
432
|
|
|
378
|
-
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(nodeId) +
|
|
433
|
+
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(nodeId) + scopeQuery('&'))
|
|
379
434
|
if (!response.ok) {
|
|
380
435
|
throw new Error('Failed to load graph node details')
|
|
381
436
|
}
|
|
@@ -479,6 +534,9 @@ const fetchChunk = async ({ fit } = { fit: false }) => {
|
|
|
479
534
|
if (state.agentId) {
|
|
480
535
|
params.set('agent', state.agentId)
|
|
481
536
|
}
|
|
537
|
+
if (state.contextId) {
|
|
538
|
+
params.set('context', state.contextId)
|
|
539
|
+
}
|
|
482
540
|
|
|
483
541
|
const response = await fetch('/api/graph-stream?' + params.toString(), { signal: controller.signal })
|
|
484
542
|
if (!response.ok) {
|
|
@@ -751,7 +809,7 @@ const setupControls = () => {
|
|
|
751
809
|
return
|
|
752
810
|
}
|
|
753
811
|
|
|
754
|
-
fetch('/api/graph-filter?q=' + encodeURIComponent(query) + '&limit=1800' +
|
|
812
|
+
fetch('/api/graph-filter?q=' + encodeURIComponent(query) + '&limit=1800' + scopeQuery('&'))
|
|
755
813
|
.then((response) => response.json())
|
|
756
814
|
.then((payload) => {
|
|
757
815
|
if (token !== state.searchToken) {
|
|
@@ -795,12 +853,50 @@ const loadAgents = async () => {
|
|
|
795
853
|
state.agentId = elements.agent.value || ''
|
|
796
854
|
writeStoredAgent(state.agentId)
|
|
797
855
|
syncAgentInUrl(state.agentId)
|
|
798
|
-
scheduleChunkFetch({ fit: true })
|
|
856
|
+
loadContexts().then(() => scheduleChunkFetch({ fit: true })).catch((error) => console.error(error))
|
|
799
857
|
})
|
|
800
858
|
|
|
801
859
|
syncAgentInUrl(state.agentId)
|
|
802
860
|
}
|
|
803
861
|
|
|
862
|
+
const loadContexts = async () => {
|
|
863
|
+
const response = await fetch('/api/graph-contexts' + (state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''))
|
|
864
|
+
if (!response.ok) {
|
|
865
|
+
throw new Error('Failed to load graph contexts')
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const payload = await response.json()
|
|
869
|
+
const contexts = Array.isArray(payload?.contexts) ? payload.contexts : []
|
|
870
|
+
const options = [
|
|
871
|
+
'<option value="">All contexts</option>',
|
|
872
|
+
...contexts.map((context) => {
|
|
873
|
+
const id = String(context?.id || '')
|
|
874
|
+
const title = String(context?.title || id || 'Untitled')
|
|
875
|
+
const count = Number.isFinite(context?.nodeCount) ? context.nodeCount : 0
|
|
876
|
+
return '<option value="' + escapeHtml(id) + '">' + escapeHtml(title) + ' (' + count + ')</option>'
|
|
877
|
+
})
|
|
878
|
+
]
|
|
879
|
+
|
|
880
|
+
elements.context.innerHTML = options.join('')
|
|
881
|
+
|
|
882
|
+
const preferredContext = initialContextFromUrl || readStoredContext()
|
|
883
|
+
const hasPreferred = preferredContext && contexts.some((context) => context?.id === preferredContext)
|
|
884
|
+
state.contextId = hasPreferred ? preferredContext : ''
|
|
885
|
+
elements.context.value = state.contextId
|
|
886
|
+
writeStoredContext(state.contextId)
|
|
887
|
+
syncContextInUrl(state.contextId)
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const setupContextControl = () => {
|
|
891
|
+
elements.context.addEventListener('change', () => {
|
|
892
|
+
state.contextId = elements.context.value || ''
|
|
893
|
+
state.selectedNodeId = null
|
|
894
|
+
writeStoredContext(state.contextId)
|
|
895
|
+
syncContextInUrl(state.contextId)
|
|
896
|
+
scheduleChunkFetch({ fit: true })
|
|
897
|
+
})
|
|
898
|
+
}
|
|
899
|
+
|
|
804
900
|
const setupRenderWorker = () => {
|
|
805
901
|
const hasWorker = typeof Worker !== 'undefined'
|
|
806
902
|
const canTransfer = typeof canvas.transferControlToOffscreen === 'function'
|
|
@@ -892,6 +988,7 @@ const bootstrap = async () => {
|
|
|
892
988
|
setupRenderWorker()
|
|
893
989
|
setupInput()
|
|
894
990
|
setupControls()
|
|
991
|
+
setupContextControl()
|
|
895
992
|
wireNodeLinkClicks()
|
|
896
993
|
|
|
897
994
|
window.addEventListener('resize', () => {
|
|
@@ -900,12 +997,11 @@ const bootstrap = async () => {
|
|
|
900
997
|
})
|
|
901
998
|
|
|
902
999
|
await loadAgents()
|
|
1000
|
+
await loadContexts()
|
|
903
1001
|
updateTotals()
|
|
904
1002
|
updateTagCount()
|
|
905
1003
|
|
|
906
|
-
|
|
907
|
-
scheduleChunkFetch({ fit: true })
|
|
908
|
-
}
|
|
1004
|
+
scheduleChunkFetch({ fit: true })
|
|
909
1005
|
}
|
|
910
1006
|
|
|
911
1007
|
bootstrap().catch((error) => {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getGraphLayout } from './get-graph-layout.js';
|
|
2
|
+
export const getGraphContexts = async (vaultPath, agentId) => {
|
|
3
|
+
const { layout } = await getGraphLayout(vaultPath, { agentId });
|
|
4
|
+
const nodeIdsByContext = layout.nodes.reduce((contexts, node) => {
|
|
5
|
+
const title = node.segment || node.group || 'root';
|
|
6
|
+
const nodeIds = contexts.get(title) ?? new Set();
|
|
7
|
+
nodeIds.add(node.id);
|
|
8
|
+
contexts.set(title, nodeIds);
|
|
9
|
+
return contexts;
|
|
10
|
+
}, new Map());
|
|
11
|
+
return Array.from(nodeIdsByContext.entries())
|
|
12
|
+
.map(([title, nodeIds]) => ({
|
|
13
|
+
id: title,
|
|
14
|
+
title,
|
|
15
|
+
nodeCount: nodeIds.size,
|
|
16
|
+
edgeCount: layout.edges.filter((edge) => nodeIds.has(edge.source) && Boolean(edge.target && nodeIds.has(edge.target))).length
|
|
17
|
+
}))
|
|
18
|
+
.sort((left, right) => right.nodeCount - left.nodeCount || left.title.localeCompare(right.title));
|
|
19
|
+
};
|