@andespindola/brainlink 0.1.0-beta.151 → 0.1.0-beta.153
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 +4 -4
- package/README.md +17 -7
- package/dist/application/add-note.js +4 -44
- 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 +119 -53
- package/dist/application/get-graph-contexts.js +19 -0
- package/dist/application/get-graph-layout.js +37 -17
- package/dist/application/get-graph-stream-chunk.js +4 -1
- package/dist/application/get-graph-view.js +4 -1
- package/dist/application/import-legacy-sqlite.js +3 -33
- package/dist/application/migrate-context-links.js +79 -0
- package/dist/application/search-graph-node-ids.js +53 -2
- package/dist/application/server/routes.js +16 -3
- package/dist/cli/commands/write-commands.js +33 -1
- package/dist/domain/markdown.js +33 -2
- package/dist/infrastructure/file-index.js +6 -3
- package/dist/mcp/tools.js +2 -2
- package/docs/ARCHITECTURE.md +2 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -6,7 +6,7 @@ This file tells coding agents and AI assistants how to use this repository.
|
|
|
6
6
|
|
|
7
7
|
Brainlink is a local-first knowledge memory for agents.
|
|
8
8
|
|
|
9
|
-
It reads a Markdown vault, extracts
|
|
9
|
+
It reads a Markdown vault, extracts concise graph links from `## Context Links`, extracts `#tags`, builds a local file index at `.brainlink/index.json`, and returns compact context packages that agents can inject into prompts.
|
|
10
10
|
|
|
11
11
|
## Source Of Truth
|
|
12
12
|
|
|
@@ -27,15 +27,15 @@ By default, the installed Brainlink CLI uses `$HOME/.brainlink/vault` as its vau
|
|
|
27
27
|
Use this loop when using Brainlink as memory:
|
|
28
28
|
|
|
29
29
|
1. Write durable knowledge into Markdown notes.
|
|
30
|
-
2. Link related notes with explicit `[[Note Title]]` wiki links inside
|
|
30
|
+
2. Link related notes with explicit `[[Note Title]]` wiki links inside a `## Context Links` section.
|
|
31
31
|
3. Add explicit `#tags` for retrieval.
|
|
32
32
|
4. Run `index` after writes.
|
|
33
33
|
5. Run `context "<task or question>"` before answering.
|
|
34
34
|
6. Use the returned sources as grounded context.
|
|
35
35
|
|
|
36
|
-
`context` is read-only. It does not create notes, backlinks, graph edges or durable memory by itself. A relationship exists only when a Markdown note contains a `[[wiki link]]`
|
|
36
|
+
`context` is read-only. It does not create notes, backlinks, graph edges or durable memory by itself. A relationship exists only when a Markdown note contains a `[[wiki link]]` inside `## Context Links` and the vault has been indexed after that write.
|
|
37
37
|
|
|
38
|
-
When an agent adds durable memory, it should
|
|
38
|
+
When an agent adds durable memory, it should add only the canonical relationships to `## Context Links`. Prefer exact note titles in links, for example `[[Architecture]]`, and run `broken-links`, `orphans` or `validate` when graph health matters.
|
|
39
39
|
|
|
40
40
|
Agents can mark important relationships by placing priority hints on the same line as a wiki link, for example `[[Architecture]] priority: high`, `[[Incident Runbook]] #important` or `[[Incident Runbook]] #critical`. Indexed graph edges expose `weight` and `priority` so agents can sort related notes by importance.
|
|
41
41
|
|
package/README.md
CHANGED
|
@@ -210,19 +210,19 @@ Only store knowledge that is likely to matter later:
|
|
|
210
210
|
```bash
|
|
211
211
|
blink add "Testing Policy" \
|
|
212
212
|
--agent "$BLINK_AGENT" \
|
|
213
|
-
--content
|
|
213
|
+
--content $'Run npm run check before final delivery. #testing #process\n\n## Context Links\n\n- [[Release Checklist]]'
|
|
214
214
|
```
|
|
215
215
|
|
|
216
|
-
Brainlink does not infer durable graph relationships from generated context. A context result is only a read package for the model. To create a real link
|
|
216
|
+
Brainlink does not infer durable graph relationships from generated context. A context result is only a read package for the model. To create a real graph link, write a concise `## Context Links` section and put the canonical `[[Note Title]]` links there.
|
|
217
217
|
|
|
218
218
|
Writes with `blink add` reindex the vault automatically by default. This can be disabled with `--no-auto-index` and controlled globally with `autoIndexOnWrite` in `brainlink.config.json`.
|
|
219
219
|
|
|
220
220
|
When adding memory, follow this contract:
|
|
221
221
|
|
|
222
|
-
- Link the new note to
|
|
222
|
+
- Link the new note to existing notes through `## Context Links` when there is a related concept.
|
|
223
223
|
- Use the exact target note title inside `[[...]]`.
|
|
224
224
|
- Add retrieval tags such as `#architecture`, `#decision`, `#runbook` or `#preference`.
|
|
225
|
-
-
|
|
225
|
+
- General wiki-link mentions outside `## Context Links` remain searchable Markdown content, but they do not become graph edges.
|
|
226
226
|
|
|
227
227
|
If you disable auto-index, run `blink index` after batched writes.
|
|
228
228
|
|
|
@@ -554,7 +554,7 @@ If you disable `autoBootstrapOnRead` through `brainlink_policy`, read tools retu
|
|
|
554
554
|
`brainlink_bootstrap`, `brainlink_policy` and preflight responses include structured `nextActions` so MCP clients can continue automatically without custom parsing.
|
|
555
555
|
For one-call planning, use `brainlink_recommendations` to get the recommended tool sequence for the current vault/agent/query.
|
|
556
556
|
|
|
557
|
-
The same linking rule applies through MCP: `brainlink_context` is read-only, and real graph links require Markdown notes with explicit `
|
|
557
|
+
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.
|
|
558
558
|
|
|
559
559
|
Agents can raise the importance of a relationship by putting priority markers on the same line as a wiki link:
|
|
560
560
|
|
|
@@ -563,7 +563,14 @@ Agents can raise the importance of a relationship by putting priority markers on
|
|
|
563
563
|
Related: [[Incident Runbook]] #critical
|
|
564
564
|
```
|
|
565
565
|
|
|
566
|
-
Indexed edges expose `weight` and `priority` (`low`, `normal`, `high`, `critical`) through CLI JSON, HTTP graph APIs and `brainlink_graph`. Brainlink indexes every non-self
|
|
566
|
+
Indexed edges expose `weight` and `priority` (`low`, `normal`, `high`, `critical`) through CLI JSON, HTTP graph APIs and `brainlink_graph`. Brainlink indexes every non-self `[[wiki link]]` inside `## Context Links` as a graph edge. Old indexes are rebuilt automatically when their graph link model version is missing or stale.
|
|
567
|
+
|
|
568
|
+
To migrate older vaults without deleting existing Markdown, generate concise context-link sections from current wiki-link mentions:
|
|
569
|
+
|
|
570
|
+
```bash
|
|
571
|
+
blink migrate-context-links --vault ./vault --limit 5
|
|
572
|
+
blink index --vault ./vault --full
|
|
573
|
+
```
|
|
567
574
|
|
|
568
575
|
## Graph UI
|
|
569
576
|
|
|
@@ -589,11 +596,12 @@ When native GUI is used, the GUI window automatically closes when the `blink ser
|
|
|
589
596
|
The graph UI shows:
|
|
590
597
|
|
|
591
598
|
- notes as nodes
|
|
592
|
-
- all non-self `[[wiki links]]` as weighted edges
|
|
599
|
+
- all non-self `[[wiki links]]` inside `## Context Links` as weighted edges
|
|
593
600
|
- default star layout centered on the primary hub, without rewriting or flattening underlying relationships
|
|
594
601
|
- details opened in a non-modal side panel (tags, outgoing links, backlinks, full Markdown content), so zoom and pan remain available while inspecting data
|
|
595
602
|
- neutral graph nodes with segment/group metadata
|
|
596
603
|
- agent selector (id-only labels) for isolated views
|
|
604
|
+
- context selector for segment-scoped star subgraphs derived from the visual graph context
|
|
597
605
|
- graph filter matches title, path, tags and note content
|
|
598
606
|
- graph filter keeps hub context nodes visible (`Memory Hub`/`MOC`/high-degree fallback) to preserve relationship readability
|
|
599
607
|
- realtime refresh while `--watch` is enabled
|
|
@@ -625,6 +633,7 @@ The server always refuses non-loopback hosts. Brainlink HTTP only runs on localh
|
|
|
625
633
|
Routes:
|
|
626
634
|
|
|
627
635
|
- `GET /api/agents`
|
|
636
|
+
- `GET /api/graph-contexts`
|
|
628
637
|
- `GET /api/graph`
|
|
629
638
|
- `GET /api/graph-layout`
|
|
630
639
|
- `GET /api/graph-view?x=<x>&y=<y>&w=<width>&h=<height>&scale=<scale>`
|
|
@@ -643,6 +652,7 @@ Read routes accept `agent=<agent-id>`:
|
|
|
643
652
|
|
|
644
653
|
```txt
|
|
645
654
|
/api/graph-layout?agent=coding-agent
|
|
655
|
+
/api/graph-layout?agent=coding-agent&context=Architecture
|
|
646
656
|
/api/search?q=typescript&agent=coding-agent&mode=hybrid
|
|
647
657
|
/api/context?q=module-boundaries&agent=coding-agent&mode=semantic
|
|
648
658
|
```
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import { access } from 'node:fs/promises';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
1
|
import { writeMarkdownFile } from '../infrastructure/file-system-vault.js';
|
|
4
2
|
import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
|
|
5
|
-
import { extractWikiLinks } from '../domain/markdown.js';
|
|
6
3
|
import { validateNoteInput } from '../domain/note-safety.js';
|
|
7
4
|
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
8
5
|
const slugify = (title) => title
|
|
@@ -11,10 +8,6 @@ const slugify = (title) => title
|
|
|
11
8
|
.toLowerCase()
|
|
12
9
|
.replace(/[^a-z0-9]+/g, '-')
|
|
13
10
|
.replace(/^-+|-+$/g, '');
|
|
14
|
-
const systemHubTitle = 'Memory Hub';
|
|
15
|
-
const systemRootTitle = 'Knowledge Root';
|
|
16
|
-
const normalizeTitle = (title) => title.trim().replace(/\.md$/i, '').toLowerCase();
|
|
17
|
-
const noteFilename = (agentId, title) => `agents/${agentId}/${slugify(title) || 'untitled'}.md`;
|
|
18
11
|
const buildNote = (title, content, agentId) => [
|
|
19
12
|
`---`,
|
|
20
13
|
`title: "${title.replaceAll('"', '\\"')}"`,
|
|
@@ -26,38 +19,6 @@ const buildNote = (title, content, agentId) => [
|
|
|
26
19
|
content.trim(),
|
|
27
20
|
''
|
|
28
21
|
].join('\n');
|
|
29
|
-
const ensureSystemNote = async (vaultPath, absoluteVaultPath, agentId, title, content) => {
|
|
30
|
-
const filename = noteFilename(agentId, title);
|
|
31
|
-
const absolutePath = join(absoluteVaultPath, filename);
|
|
32
|
-
try {
|
|
33
|
-
await access(absolutePath);
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
catch { }
|
|
37
|
-
await writeMarkdownFile(vaultPath, filename, buildNote(title, content, agentId));
|
|
38
|
-
};
|
|
39
|
-
const ensureNonOrphanContent = async (vaultPath, absoluteVaultPath, title, content, agentId) => {
|
|
40
|
-
const links = extractWikiLinks(content).filter((link) => normalizeTitle(link) !== normalizeTitle(title));
|
|
41
|
-
if (links.length > 0) {
|
|
42
|
-
return {
|
|
43
|
-
content: content.trim(),
|
|
44
|
-
autoLinked: false,
|
|
45
|
-
linkTarget: null
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
const fallbackTitle = normalizeTitle(title) === normalizeTitle(systemHubTitle) ? systemRootTitle : systemHubTitle;
|
|
49
|
-
if (fallbackTitle === systemRootTitle) {
|
|
50
|
-
await ensureSystemNote(vaultPath, absoluteVaultPath, agentId, systemRootTitle, `Entry point for agent memory. [[${systemHubTitle}]] #memory #root`);
|
|
51
|
-
}
|
|
52
|
-
else {
|
|
53
|
-
await ensureSystemNote(vaultPath, absoluteVaultPath, agentId, systemHubTitle, 'Central memory index for this agent namespace. #memory #hub');
|
|
54
|
-
}
|
|
55
|
-
return {
|
|
56
|
-
content: `${content.trim()}\n\nRelated: [[${fallbackTitle}]]`,
|
|
57
|
-
autoLinked: true,
|
|
58
|
-
linkTarget: fallbackTitle
|
|
59
|
-
};
|
|
60
|
-
};
|
|
61
22
|
export const addNoteWithMetadata = async (vaultPath, title, content, agentId = sharedAgentId, options = {}) => {
|
|
62
23
|
validateNoteInput({
|
|
63
24
|
title,
|
|
@@ -65,15 +26,14 @@ export const addNoteWithMetadata = async (vaultPath, title, content, agentId = s
|
|
|
65
26
|
allowSensitive: options.allowSensitive
|
|
66
27
|
});
|
|
67
28
|
const sanitizedAgentId = sanitizeAgentId(agentId);
|
|
68
|
-
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
69
29
|
const filename = `agents/${sanitizedAgentId}/${slugify(title) || 'untitled'}.md`;
|
|
70
|
-
|
|
71
|
-
const note = buildNote(title,
|
|
30
|
+
await ensureVault(vaultPath);
|
|
31
|
+
const note = buildNote(title, content.trim(), sanitizedAgentId);
|
|
72
32
|
const path = await writeMarkdownFile(vaultPath, filename, note);
|
|
73
33
|
return {
|
|
74
34
|
path,
|
|
75
|
-
autoLinked:
|
|
76
|
-
linkTarget:
|
|
35
|
+
autoLinked: false,
|
|
36
|
+
linkTarget: null
|
|
77
37
|
};
|
|
78
38
|
};
|
|
79
39
|
export const addNote = async (vaultPath, title, content, agentId = sharedAgentId, options = {}) => (await addNoteWithMetadata(vaultPath, title, content, agentId, options)).path;
|
|
@@ -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('#', '')
|
|
@@ -298,51 +353,6 @@ const list = (items) => {
|
|
|
298
353
|
.join('')
|
|
299
354
|
}
|
|
300
355
|
|
|
301
|
-
const extractContextLinks = (content) => {
|
|
302
|
-
if (typeof content !== 'string' || content.length === 0) {
|
|
303
|
-
return []
|
|
304
|
-
}
|
|
305
|
-
const lines = content.split(/\\r?\\n/)
|
|
306
|
-
let start = -1
|
|
307
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
308
|
-
if (/^#{1,6}\\s+(?:context\\s+links?|links?\\s+de\\s+contexto)\\b/i.test(lines[index].trim())) {
|
|
309
|
-
start = index + 1
|
|
310
|
-
break
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
if (start < 0) {
|
|
314
|
-
return []
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
const links = []
|
|
318
|
-
const seenTitles = new Set()
|
|
319
|
-
for (let index = start; index < lines.length; index += 1) {
|
|
320
|
-
const line = lines[index].trim()
|
|
321
|
-
if (!line) {
|
|
322
|
-
continue
|
|
323
|
-
}
|
|
324
|
-
if (/^#{1,6}\\s+/.test(line)) {
|
|
325
|
-
break
|
|
326
|
-
}
|
|
327
|
-
const matches = Array.from(line.matchAll(/\\[\\[([^\\]]+)\\]\\]/g))
|
|
328
|
-
if (matches.length === 0) {
|
|
329
|
-
continue
|
|
330
|
-
}
|
|
331
|
-
const priorityMatch = line.match(/#(critical|important)\\b|priority:\\s*(high|critical)/i)
|
|
332
|
-
const priority = priorityMatch ? String(priorityMatch[1] || priorityMatch[2] || 'normal').toLowerCase() : 'normal'
|
|
333
|
-
|
|
334
|
-
for (let matchIndex = 0; matchIndex < matches.length; matchIndex += 1) {
|
|
335
|
-
const title = String(matches[matchIndex][1] || '').trim()
|
|
336
|
-
if (!title || seenTitles.has(title.toLowerCase())) {
|
|
337
|
-
continue
|
|
338
|
-
}
|
|
339
|
-
seenTitles.add(title.toLowerCase())
|
|
340
|
-
links.push({ title, priority })
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
return links
|
|
344
|
-
}
|
|
345
|
-
|
|
346
356
|
const buildFacts = (node, outgoingCount, incomingCount) => {
|
|
347
357
|
const content = typeof node?.content === 'string' ? node.content : ''
|
|
348
358
|
const words = content.trim().length > 0 ? content.trim().split(/\\s+/).length : 0
|
|
@@ -368,6 +378,21 @@ const listContextLinks = (links) => {
|
|
|
368
378
|
.join('')
|
|
369
379
|
}
|
|
370
380
|
|
|
381
|
+
const nodeContextLinks = (node, outgoing) => {
|
|
382
|
+
const titles = Array.isArray(node?.contextLinks) ? node.contextLinks : []
|
|
383
|
+
const outgoingByTitle = new Map(normalizeList(outgoing).map((link) => [String(link.title || '').toLowerCase(), link]))
|
|
384
|
+
|
|
385
|
+
return titles
|
|
386
|
+
.map((title) => {
|
|
387
|
+
const match = outgoingByTitle.get(String(title).toLowerCase())
|
|
388
|
+
return {
|
|
389
|
+
title,
|
|
390
|
+
priority: match?.priority || 'normal'
|
|
391
|
+
}
|
|
392
|
+
})
|
|
393
|
+
.filter((link) => typeof link.title === 'string' && link.title.trim().length > 0)
|
|
394
|
+
}
|
|
395
|
+
|
|
371
396
|
const linkedNodes = (node) => {
|
|
372
397
|
const nodeById = new Map((state.chunk.nodes || []).map((item) => [item[0], item]))
|
|
373
398
|
const edges = normalizeList(state.chunk.edges)
|
|
@@ -405,7 +430,7 @@ const loadNodeDetails = async (nodeId) => {
|
|
|
405
430
|
return
|
|
406
431
|
}
|
|
407
432
|
|
|
408
|
-
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(nodeId) +
|
|
433
|
+
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(nodeId) + scopeQuery('&'))
|
|
409
434
|
if (!response.ok) {
|
|
410
435
|
throw new Error('Failed to load graph node details')
|
|
411
436
|
}
|
|
@@ -431,7 +456,7 @@ const loadNodeDetails = async (nodeId) => {
|
|
|
431
456
|
: '<span>No tags</span>'
|
|
432
457
|
|
|
433
458
|
const related = linkedNodes(node)
|
|
434
|
-
const contextLinks =
|
|
459
|
+
const contextLinks = nodeContextLinks(node, related.outgoing)
|
|
435
460
|
const facts = buildFacts(node, related.outgoing.length, related.incoming.length)
|
|
436
461
|
elements.contentFacts.innerHTML = listFacts(facts)
|
|
437
462
|
elements.contentContextLinks.innerHTML = listContextLinks(contextLinks)
|
|
@@ -509,6 +534,9 @@ const fetchChunk = async ({ fit } = { fit: false }) => {
|
|
|
509
534
|
if (state.agentId) {
|
|
510
535
|
params.set('agent', state.agentId)
|
|
511
536
|
}
|
|
537
|
+
if (state.contextId) {
|
|
538
|
+
params.set('context', state.contextId)
|
|
539
|
+
}
|
|
512
540
|
|
|
513
541
|
const response = await fetch('/api/graph-stream?' + params.toString(), { signal: controller.signal })
|
|
514
542
|
if (!response.ok) {
|
|
@@ -781,7 +809,7 @@ const setupControls = () => {
|
|
|
781
809
|
return
|
|
782
810
|
}
|
|
783
811
|
|
|
784
|
-
fetch('/api/graph-filter?q=' + encodeURIComponent(query) + '&limit=1800' +
|
|
812
|
+
fetch('/api/graph-filter?q=' + encodeURIComponent(query) + '&limit=1800' + scopeQuery('&'))
|
|
785
813
|
.then((response) => response.json())
|
|
786
814
|
.then((payload) => {
|
|
787
815
|
if (token !== state.searchToken) {
|
|
@@ -825,12 +853,50 @@ const loadAgents = async () => {
|
|
|
825
853
|
state.agentId = elements.agent.value || ''
|
|
826
854
|
writeStoredAgent(state.agentId)
|
|
827
855
|
syncAgentInUrl(state.agentId)
|
|
828
|
-
scheduleChunkFetch({ fit: true })
|
|
856
|
+
loadContexts().then(() => scheduleChunkFetch({ fit: true })).catch((error) => console.error(error))
|
|
829
857
|
})
|
|
830
858
|
|
|
831
859
|
syncAgentInUrl(state.agentId)
|
|
832
860
|
}
|
|
833
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
|
+
|
|
834
900
|
const setupRenderWorker = () => {
|
|
835
901
|
const hasWorker = typeof Worker !== 'undefined'
|
|
836
902
|
const canTransfer = typeof canvas.transferControlToOffscreen === 'function'
|
|
@@ -922,6 +988,7 @@ const bootstrap = async () => {
|
|
|
922
988
|
setupRenderWorker()
|
|
923
989
|
setupInput()
|
|
924
990
|
setupControls()
|
|
991
|
+
setupContextControl()
|
|
925
992
|
wireNodeLinkClicks()
|
|
926
993
|
|
|
927
994
|
window.addEventListener('resize', () => {
|
|
@@ -930,12 +997,11 @@ const bootstrap = async () => {
|
|
|
930
997
|
})
|
|
931
998
|
|
|
932
999
|
await loadAgents()
|
|
1000
|
+
await loadContexts()
|
|
933
1001
|
updateTotals()
|
|
934
1002
|
updateTagCount()
|
|
935
1003
|
|
|
936
|
-
|
|
937
|
-
scheduleChunkFetch({ fit: true })
|
|
938
|
-
}
|
|
1004
|
+
scheduleChunkFetch({ fit: true })
|
|
939
1005
|
}
|
|
940
1006
|
|
|
941
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
|
+
};
|
|
@@ -4,20 +4,25 @@ import { dirname, join } from 'node:path';
|
|
|
4
4
|
import { createStarGraphLayout } from '../domain/graph-layout.js';
|
|
5
5
|
import { indexStoragePath } from '../infrastructure/file-index.js';
|
|
6
6
|
import { getGraphSummary } from './get-graph-summary.js';
|
|
7
|
-
const graphLayoutVersion =
|
|
7
|
+
const graphLayoutVersion = 4;
|
|
8
8
|
const graphLayoutCache = new Map();
|
|
9
|
-
const
|
|
10
|
-
const
|
|
9
|
+
const safeCacheSegment = (value, fallback) => value?.replace(/[^a-zA-Z0-9_-]/g, '_') || fallback;
|
|
10
|
+
const graphLayoutStoragePath = (vaultPath, options) => {
|
|
11
|
+
const agent = safeCacheSegment(options.agentId, 'all');
|
|
12
|
+
const context = safeCacheSegment(options.context, 'all-contexts');
|
|
13
|
+
return join(vaultPath, '.brainlink', `graph-layout-${agent}-${context}.json`);
|
|
14
|
+
};
|
|
15
|
+
const readPersistedLayout = async (vaultPath, databaseSignature, options) => {
|
|
11
16
|
try {
|
|
12
|
-
const parsed = JSON.parse(await readFile(graphLayoutStoragePath(vaultPath,
|
|
17
|
+
const parsed = JSON.parse(await readFile(graphLayoutStoragePath(vaultPath, options), 'utf8'));
|
|
13
18
|
return parsed.databaseSignature === databaseSignature && parsed.layoutVersion === graphLayoutVersion ? parsed : null;
|
|
14
19
|
}
|
|
15
20
|
catch {
|
|
16
21
|
return null;
|
|
17
22
|
}
|
|
18
23
|
};
|
|
19
|
-
const writePersistedLayout = async (vaultPath,
|
|
20
|
-
const target = graphLayoutStoragePath(vaultPath,
|
|
24
|
+
const writePersistedLayout = async (vaultPath, options, cached) => {
|
|
25
|
+
const target = graphLayoutStoragePath(vaultPath, options);
|
|
21
26
|
const temp = `${target}.tmp`;
|
|
22
27
|
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
23
28
|
await writeFile(temp, `${JSON.stringify(cached)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
@@ -41,9 +46,27 @@ const createGraphSignature = (graph) => {
|
|
|
41
46
|
.update(`${graph.nodes.length}:${nodesSignature}|${graph.edges.length}:${edgesSignature}`)
|
|
42
47
|
.digest('hex');
|
|
43
48
|
};
|
|
44
|
-
|
|
49
|
+
const createLayout = (graph) => {
|
|
50
|
+
const rawLayout = createStarGraphLayout(graph);
|
|
51
|
+
return {
|
|
52
|
+
...rawLayout,
|
|
53
|
+
nodes: rawLayout.nodes.map((node) => ({ ...node, content: '' }))
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
const filterGraphByContext = (graph, context) => {
|
|
57
|
+
const baseLayout = createStarGraphLayout(graph);
|
|
58
|
+
const selectedNodeIds = new Set(baseLayout.nodes
|
|
59
|
+
.filter((node) => node.segment === context)
|
|
60
|
+
.map((node) => node.id));
|
|
61
|
+
return {
|
|
62
|
+
nodes: graph.nodes.filter((node) => selectedNodeIds.has(node.id)),
|
|
63
|
+
edges: graph.edges.filter((edge) => selectedNodeIds.has(edge.source) && Boolean(edge.target && selectedNodeIds.has(edge.target)))
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
export const getGraphLayout = async (vaultPath, optionsOrAgentId) => {
|
|
67
|
+
const options = typeof optionsOrAgentId === 'string' ? { agentId: optionsOrAgentId } : optionsOrAgentId ?? {};
|
|
45
68
|
const databaseSignature = await readDatabaseSignature(vaultPath);
|
|
46
|
-
const cacheKey = `${vaultPath}:${agentId ?? ''}`;
|
|
69
|
+
const cacheKey = `${vaultPath}:${options.agentId ?? ''}:${options.context ?? ''}`;
|
|
47
70
|
const cached = graphLayoutCache.get(cacheKey);
|
|
48
71
|
if (cached?.databaseSignature === databaseSignature && cached.layoutVersion === graphLayoutVersion) {
|
|
49
72
|
return {
|
|
@@ -51,7 +74,7 @@ export const getGraphLayout = async (vaultPath, agentId) => {
|
|
|
51
74
|
layout: cached.layout
|
|
52
75
|
};
|
|
53
76
|
}
|
|
54
|
-
const persisted = await readPersistedLayout(vaultPath, databaseSignature,
|
|
77
|
+
const persisted = await readPersistedLayout(vaultPath, databaseSignature, options);
|
|
55
78
|
if (persisted) {
|
|
56
79
|
graphLayoutCache.set(cacheKey, persisted);
|
|
57
80
|
return {
|
|
@@ -59,16 +82,13 @@ export const getGraphLayout = async (vaultPath, agentId) => {
|
|
|
59
82
|
layout: persisted.layout
|
|
60
83
|
};
|
|
61
84
|
}
|
|
62
|
-
const graph = await getGraphSummary(vaultPath, agentId);
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const layout =
|
|
66
|
-
...rawLayout,
|
|
67
|
-
nodes: rawLayout.nodes.map((node) => ({ ...node, content: '' }))
|
|
68
|
-
};
|
|
85
|
+
const graph = await getGraphSummary(vaultPath, options.agentId);
|
|
86
|
+
const scopedGraph = options.context ? filterGraphByContext(graph, options.context) : graph;
|
|
87
|
+
const signature = createGraphSignature(scopedGraph);
|
|
88
|
+
const layout = createLayout(scopedGraph);
|
|
69
89
|
const nextCache = { layoutVersion: graphLayoutVersion, databaseSignature, signature, layout };
|
|
70
90
|
graphLayoutCache.set(cacheKey, nextCache);
|
|
71
|
-
await writePersistedLayout(vaultPath,
|
|
91
|
+
await writePersistedLayout(vaultPath, options, nextCache);
|
|
72
92
|
return {
|
|
73
93
|
signature,
|
|
74
94
|
layout
|
|
@@ -244,7 +244,10 @@ const normalizeBudget = (value, fallback, min, max) => {
|
|
|
244
244
|
export const getGraphStreamChunk = async (vaultPath, input) => {
|
|
245
245
|
const nodeBudget = normalizeBudget(input.nodeBudget, 1800, 80, 12_000);
|
|
246
246
|
const edgeBudget = normalizeBudget(input.edgeBudget, 5000, 120, 60_000);
|
|
247
|
-
const { signature, layout } = await getGraphLayout(vaultPath,
|
|
247
|
+
const { signature, layout } = await getGraphLayout(vaultPath, {
|
|
248
|
+
agentId: input.agentId,
|
|
249
|
+
context: input.context
|
|
250
|
+
});
|
|
248
251
|
const groups = layout.groups ?? [];
|
|
249
252
|
const cache = getOrCreateLayoutCache(signature, layout.nodes, layout.edges, groups);
|
|
250
253
|
if (layout.nodes.length === 0) {
|
|
@@ -180,7 +180,10 @@ const arrangeChildGraphNodes = (nodes, group, degrees) => {
|
|
|
180
180
|
};
|
|
181
181
|
const limitEdges = (edges) => edges.slice(0, edgeLimit);
|
|
182
182
|
export const getGraphView = async (vaultPath, input) => {
|
|
183
|
-
const { signature, layout } = await getGraphLayout(vaultPath,
|
|
183
|
+
const { signature, layout } = await getGraphLayout(vaultPath, {
|
|
184
|
+
agentId: input.agentId,
|
|
185
|
+
context: input.context
|
|
186
|
+
});
|
|
184
187
|
const groups = layout.groups ?? [];
|
|
185
188
|
const degrees = degreeMap(layout.edges);
|
|
186
189
|
const groupById = new Map(groups.map((group) => [group.id, group]));
|
|
@@ -3,7 +3,7 @@ import { access } from 'node:fs/promises';
|
|
|
3
3
|
import { basename, extname, join, relative, resolve } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
-
import { extractTags
|
|
6
|
+
import { extractTags } from '../domain/markdown.js';
|
|
7
7
|
import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
|
|
8
8
|
import { ensureVault, listVaultFiles, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
|
|
9
9
|
import { getBrainlinkHomePath } from '../infrastructure/paths.js';
|
|
@@ -17,9 +17,6 @@ const agentColumnCandidates = ['agent', 'agent_id', 'namespace', 'scope'];
|
|
|
17
17
|
const tagColumnCandidates = ['tags', 'tag_list', 'keywords'];
|
|
18
18
|
const createdColumnCandidates = ['created_at', 'createdat', 'created', 'ctime'];
|
|
19
19
|
const updatedColumnCandidates = ['updated_at', 'updatedat', 'updated', 'mtime'];
|
|
20
|
-
const systemHubTitle = 'Memory Hub';
|
|
21
|
-
const systemRootTitle = 'Knowledge Root';
|
|
22
|
-
const normalizeTitle = (title) => title.trim().replace(/\.md$/i, '').toLowerCase();
|
|
23
20
|
const slugify = (title) => title
|
|
24
21
|
.normalize('NFKD')
|
|
25
22
|
.replace(/[\u0300-\u036f]/g, '')
|
|
@@ -203,31 +200,6 @@ const reserveUniquePath = (agentId, title, reserved) => {
|
|
|
203
200
|
}
|
|
204
201
|
throw new Error(`Could not allocate unique path for imported note: ${title}`);
|
|
205
202
|
};
|
|
206
|
-
const ensureSystemNote = async (vaultPath, reserved, created, agentId, title, content, dryRun) => {
|
|
207
|
-
const filename = noteRelativePath(agentId, slugify(title));
|
|
208
|
-
if (reserved.has(filename)) {
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
reserved.add(filename);
|
|
212
|
-
created.add(filename);
|
|
213
|
-
if (dryRun) {
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
await writeMarkdownFile(vaultPath, filename, buildNote(title, content, agentId));
|
|
217
|
-
};
|
|
218
|
-
const applyConnectivityRule = async (vaultPath, reserved, created, title, content, agentId, dryRun) => {
|
|
219
|
-
const links = extractWikiLinks(content).filter((link) => normalizeTitle(link) !== normalizeTitle(title));
|
|
220
|
-
if (links.length > 0) {
|
|
221
|
-
return content.trim();
|
|
222
|
-
}
|
|
223
|
-
const normalized = normalizeTitle(title);
|
|
224
|
-
if (normalized === normalizeTitle(systemHubTitle)) {
|
|
225
|
-
await ensureSystemNote(vaultPath, reserved, created, agentId, systemRootTitle, `Entry point for agent memory. [[${systemHubTitle}]] #memory #root`, dryRun);
|
|
226
|
-
return `${content.trim()}\n\nRelated: [[${systemRootTitle}]]`;
|
|
227
|
-
}
|
|
228
|
-
await ensureSystemNote(vaultPath, reserved, created, agentId, systemHubTitle, 'Central memory index for this agent namespace. #memory #hub', dryRun);
|
|
229
|
-
return `${content.trim()}\n\nRelated: [[${systemHubTitle}]]`;
|
|
230
|
-
};
|
|
231
203
|
const importRowsFromMapping = async (vaultPath, dbPath, mapping, options, reserved) => {
|
|
232
204
|
const limit = Number.isFinite(options.limit) && (options.limit ?? 0) > 0 ? Math.floor(options.limit ?? 0) : undefined;
|
|
233
205
|
const sql = [
|
|
@@ -243,7 +215,6 @@ const importRowsFromMapping = async (vaultPath, dbPath, mapping, options, reserv
|
|
|
243
215
|
...(limit ? [`LIMIT ${limit}`] : [])
|
|
244
216
|
].join(' ');
|
|
245
217
|
const rows = await runSqliteQuery(dbPath, sql);
|
|
246
|
-
const createdSystemNotes = new Set();
|
|
247
218
|
const importedFiles = [];
|
|
248
219
|
let imported = 0;
|
|
249
220
|
let skipped = 0;
|
|
@@ -256,8 +227,7 @@ const importRowsFromMapping = async (vaultPath, dbPath, mapping, options, reserv
|
|
|
256
227
|
const agentId = sanitizeAgentId(options.agentOverride || row.agent || sharedAgentId);
|
|
257
228
|
const filename = reserveUniquePath(agentId, row.title, reserved);
|
|
258
229
|
const mergedContent = appendMissingTags(row.content, row.tags);
|
|
259
|
-
const
|
|
260
|
-
const note = buildNote(row.title, connectedContent, agentId);
|
|
230
|
+
const note = buildNote(row.title, mergedContent.trim(), agentId);
|
|
261
231
|
if (options.dryRun !== true) {
|
|
262
232
|
await writeMarkdownFile(vaultPath, filename, note);
|
|
263
233
|
}
|
|
@@ -268,7 +238,7 @@ const importRowsFromMapping = async (vaultPath, dbPath, mapping, options, reserv
|
|
|
268
238
|
rowsRead: rows.length,
|
|
269
239
|
imported,
|
|
270
240
|
skipped,
|
|
271
|
-
createdSystemNotes:
|
|
241
|
+
createdSystemNotes: 0,
|
|
272
242
|
importedFiles
|
|
273
243
|
};
|
|
274
244
|
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { ensureVault, readMarkdownFileSummaries, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
|
|
3
|
+
import { extractContextLinkWeights, extractWikiLinkWeights, hasContextLinksSection, parseMarkdownDocument } from '../domain/markdown.js';
|
|
4
|
+
const defaultContextLinkLimit = 5;
|
|
5
|
+
const normalizeTitle = (title) => title.trim().replace(/\.md$/i, '').toLowerCase();
|
|
6
|
+
const formatPriority = (priority) => priority === 'normal' ? '' : ` priority: ${priority}`;
|
|
7
|
+
const formatContextLinksSection = (links) => [
|
|
8
|
+
'## Context Links',
|
|
9
|
+
'',
|
|
10
|
+
...links.map((link) => `- [[${link.title}]]${formatPriority(link.priority)}`)
|
|
11
|
+
].join('\n');
|
|
12
|
+
const appendContextLinksSection = (content, links) => `${content.trimEnd()}\n\n${formatContextLinksSection(links)}\n`;
|
|
13
|
+
const selectContextLinkCandidates = (content, title, limit) => extractWikiLinkWeights(content)
|
|
14
|
+
.filter((link) => normalizeTitle(link.title) !== normalizeTitle(title))
|
|
15
|
+
.slice(0, limit)
|
|
16
|
+
.map((link) => ({
|
|
17
|
+
title: link.title,
|
|
18
|
+
priority: link.priority,
|
|
19
|
+
weight: link.weight
|
|
20
|
+
}));
|
|
21
|
+
export const migrateContextLinks = async (vaultPath, options = {}) => {
|
|
22
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
23
|
+
const limit = Math.max(1, Math.floor(options.limit ?? defaultContextLinkLimit));
|
|
24
|
+
const summaries = await readMarkdownFileSummaries(absoluteVaultPath);
|
|
25
|
+
const entries = [];
|
|
26
|
+
for (const summary of summaries) {
|
|
27
|
+
const content = await readFile(summary.absolutePath, 'utf8');
|
|
28
|
+
const document = parseMarkdownDocument({
|
|
29
|
+
absolutePath: summary.absolutePath,
|
|
30
|
+
vaultPath: absoluteVaultPath,
|
|
31
|
+
content,
|
|
32
|
+
createdAt: summary.createdAt,
|
|
33
|
+
updatedAt: summary.updatedAt
|
|
34
|
+
});
|
|
35
|
+
if (options.agentId && document.agentId !== options.agentId) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (hasContextLinksSection(content)) {
|
|
39
|
+
entries.push({
|
|
40
|
+
path: summary.relativePath,
|
|
41
|
+
title: document.title,
|
|
42
|
+
changed: false,
|
|
43
|
+
reason: 'already-has-context-links',
|
|
44
|
+
links: extractContextLinkWeights(content)
|
|
45
|
+
});
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const links = selectContextLinkCandidates(content, document.title, limit);
|
|
49
|
+
if (links.length === 0) {
|
|
50
|
+
entries.push({
|
|
51
|
+
path: summary.relativePath,
|
|
52
|
+
title: document.title,
|
|
53
|
+
changed: false,
|
|
54
|
+
reason: 'no-link-candidates',
|
|
55
|
+
links
|
|
56
|
+
});
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!options.dryRun) {
|
|
60
|
+
await writeMarkdownFile(vaultPath, summary.relativePath, appendContextLinksSection(content, links));
|
|
61
|
+
}
|
|
62
|
+
entries.push({
|
|
63
|
+
path: summary.relativePath,
|
|
64
|
+
title: document.title,
|
|
65
|
+
changed: true,
|
|
66
|
+
reason: 'added-context-links',
|
|
67
|
+
links
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const changed = entries.filter((entry) => entry.changed).length;
|
|
71
|
+
return {
|
|
72
|
+
dryRun: options.dryRun === true,
|
|
73
|
+
scanned: entries.length,
|
|
74
|
+
changed,
|
|
75
|
+
skipped: entries.length - changed,
|
|
76
|
+
limit,
|
|
77
|
+
entries
|
|
78
|
+
};
|
|
79
|
+
};
|
|
@@ -1,10 +1,61 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
1
2
|
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
2
|
-
import { openFileIndex } from '../infrastructure/file-index.js';
|
|
3
|
+
import { indexStoragePath, openFileIndex } from '../infrastructure/file-index.js';
|
|
4
|
+
const graphSearchCacheTtlMs = 20_000;
|
|
5
|
+
const graphSearchCacheMaxEntries = 120;
|
|
6
|
+
const graphSearchCache = new Map();
|
|
7
|
+
const readIndexSignature = async (vaultPath) => {
|
|
8
|
+
try {
|
|
9
|
+
const info = await stat(indexStoragePath(vaultPath));
|
|
10
|
+
return `${Math.floor(info.mtimeMs)}:${info.size}`;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return '0:0';
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const cacheKey = (vaultPath, query, limit, agentId) => JSON.stringify({
|
|
17
|
+
vaultPath,
|
|
18
|
+
query: query.trim().toLowerCase(),
|
|
19
|
+
limit,
|
|
20
|
+
agentId: agentId?.trim().toLowerCase() ?? '*'
|
|
21
|
+
});
|
|
22
|
+
const readCached = (key, indexSignature) => {
|
|
23
|
+
const entry = graphSearchCache.get(key);
|
|
24
|
+
if (!entry) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
const fresh = Date.now() - entry.createdAt <= graphSearchCacheTtlMs && entry.indexSignature === indexSignature;
|
|
28
|
+
if (!fresh) {
|
|
29
|
+
graphSearchCache.delete(key);
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return entry.nodeIds;
|
|
33
|
+
};
|
|
34
|
+
const writeCached = (key, entry) => {
|
|
35
|
+
graphSearchCache.set(key, entry);
|
|
36
|
+
if (graphSearchCache.size <= graphSearchCacheMaxEntries) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const overflow = graphSearchCache.size - graphSearchCacheMaxEntries;
|
|
40
|
+
Array.from(graphSearchCache.keys()).slice(0, overflow).forEach((cacheKey) => graphSearchCache.delete(cacheKey));
|
|
41
|
+
};
|
|
3
42
|
export const searchGraphNodeIds = async (vaultPath, query, limit, agentId) => {
|
|
4
43
|
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
44
|
+
const indexSignature = await readIndexSignature(absoluteVaultPath);
|
|
45
|
+
const key = cacheKey(absoluteVaultPath, query, limit, agentId);
|
|
46
|
+
const cached = readCached(key, indexSignature);
|
|
47
|
+
if (cached) {
|
|
48
|
+
return cached;
|
|
49
|
+
}
|
|
5
50
|
const index = openFileIndex(absoluteVaultPath);
|
|
6
51
|
try {
|
|
7
|
-
|
|
52
|
+
const nodeIds = await index.searchGraphNodeIds(query, limit, agentId);
|
|
53
|
+
writeCached(key, {
|
|
54
|
+
createdAt: Date.now(),
|
|
55
|
+
indexSignature,
|
|
56
|
+
nodeIds
|
|
57
|
+
});
|
|
58
|
+
return nodeIds;
|
|
8
59
|
}
|
|
9
60
|
finally {
|
|
10
61
|
index.close();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getBrokenLinksReport, getOrphansReport, getStats, validateVault } from '../analyze-vault.js';
|
|
2
2
|
import { buildContextPackage } from '../build-context.js';
|
|
3
3
|
import { getGraph } from '../get-graph.js';
|
|
4
|
+
import { getGraphContexts } from '../get-graph-contexts.js';
|
|
4
5
|
import { getGraphNode } from '../get-graph-node.js';
|
|
5
6
|
import { getGraphLayout } from '../get-graph-layout.js';
|
|
6
7
|
import { getGraphView } from '../get-graph-view.js';
|
|
@@ -55,6 +56,10 @@ const sameEntityTag = (candidate, signature) => {
|
|
|
55
56
|
return decodeEntityTag(candidate) === signature;
|
|
56
57
|
};
|
|
57
58
|
const readAgentQuery = (url) => url.searchParams.get('agent') ?? undefined;
|
|
59
|
+
const readContextQuery = (url) => {
|
|
60
|
+
const value = url.searchParams.get('context')?.trim() ?? '';
|
|
61
|
+
return value.length > 0 ? value : undefined;
|
|
62
|
+
};
|
|
58
63
|
const parseNumber = (value, fallback) => {
|
|
59
64
|
const parsed = Number(value);
|
|
60
65
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
@@ -218,7 +223,10 @@ export const route = async (request, url, vaultPath) => {
|
|
|
218
223
|
return createResponse(createJsonResponse(await getGraph(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
|
|
219
224
|
}
|
|
220
225
|
if (isReadMethod(request) && url.pathname === '/api/graph-layout') {
|
|
221
|
-
const { signature, layout } = await getGraphLayout(vaultPath,
|
|
226
|
+
const { signature, layout } = await getGraphLayout(vaultPath, {
|
|
227
|
+
agentId: readAgentQuery(url),
|
|
228
|
+
context: readContextQuery(url)
|
|
229
|
+
});
|
|
222
230
|
const requestEtags = request.headers['if-none-match'];
|
|
223
231
|
const notModified = sameEntityTag(requestEtags, signature);
|
|
224
232
|
const etag = encodeEntityTag(signature);
|
|
@@ -253,7 +261,8 @@ export const route = async (request, url, vaultPath) => {
|
|
|
253
261
|
width: parseNumber(url.searchParams.get('w'), 2000),
|
|
254
262
|
height: parseNumber(url.searchParams.get('h'), 2000),
|
|
255
263
|
scale: parseNumber(url.searchParams.get('scale'), 1),
|
|
256
|
-
agentId: readAgentQuery(url)
|
|
264
|
+
agentId: readAgentQuery(url),
|
|
265
|
+
context: readContextQuery(url)
|
|
257
266
|
})), 200, contentTypes['.json']);
|
|
258
267
|
}
|
|
259
268
|
if (isReadMethod(request) && url.pathname === '/api/graph-stream') {
|
|
@@ -272,7 +281,8 @@ export const route = async (request, url, vaultPath) => {
|
|
|
272
281
|
scale,
|
|
273
282
|
nodeBudget,
|
|
274
283
|
edgeBudget,
|
|
275
|
-
agentId: readAgentQuery(url)
|
|
284
|
+
agentId: readAgentQuery(url),
|
|
285
|
+
context: readContextQuery(url)
|
|
276
286
|
})), 200, contentTypes['.json']);
|
|
277
287
|
}
|
|
278
288
|
if (isReadMethod(request) && url.pathname === '/api/graph-node') {
|
|
@@ -298,6 +308,9 @@ export const route = async (request, url, vaultPath) => {
|
|
|
298
308
|
if (isReadMethod(request) && url.pathname === '/api/agents') {
|
|
299
309
|
return createResponse(createJsonResponse({ agents: await listAgents(vaultPath) }), 200, contentTypes['.json']);
|
|
300
310
|
}
|
|
311
|
+
if (isReadMethod(request) && url.pathname === '/api/graph-contexts') {
|
|
312
|
+
return createResponse(createJsonResponse({ contexts: await getGraphContexts(vaultPath, readAgentQuery(url)) }), 200, contentTypes['.json']);
|
|
313
|
+
}
|
|
301
314
|
if (isReadMethod(request) && url.pathname === '/api/search') {
|
|
302
315
|
const query = url.searchParams.get('q') ?? '';
|
|
303
316
|
const limit = parsePositiveInteger(url.searchParams.get('limit'), 10);
|
|
@@ -8,6 +8,7 @@ import { buildContextPackage } from '../../application/build-context.js';
|
|
|
8
8
|
import { resolveDuplicateNotes, scanDuplicateNotes } from '../../application/dedupe-notes.js';
|
|
9
9
|
import { importLegacySqliteDatabase } from '../../application/import-legacy-sqlite.js';
|
|
10
10
|
import { indexVault, indexVaultWithOptions } from '../../application/index-vault.js';
|
|
11
|
+
import { migrateContextLinks } from '../../application/migrate-context-links.js';
|
|
11
12
|
import { migrateVaultContent, planVaultMigration, previewVaultMigration, shouldMigrateDefaultVault } from '../../application/migrate-vault.js';
|
|
12
13
|
import { createOfflinePackBackup } from '../../application/offline-pack-backup.js';
|
|
13
14
|
import { startServer } from '../../application/start-server.js';
|
|
@@ -699,6 +700,37 @@ export const registerWriteCommands = (program) => {
|
|
|
699
700
|
return `${summary}${indexMessage}${reportMessage}`;
|
|
700
701
|
});
|
|
701
702
|
});
|
|
703
|
+
program
|
|
704
|
+
.command('migrate-context-links')
|
|
705
|
+
.option('-v, --vault <vault>', 'vault directory')
|
|
706
|
+
.option('-a, --agent <agent>', 'agent memory namespace')
|
|
707
|
+
.option('-l, --limit <limit>', 'maximum context links to add per note', '5')
|
|
708
|
+
.option('--dry-run', 'preview context-link migration without writing files')
|
|
709
|
+
.option('--no-index', 'skip reindexing after migration')
|
|
710
|
+
.option('--json', 'print machine-readable JSON')
|
|
711
|
+
.description('add concise Context Links sections from existing wiki-link mentions')
|
|
712
|
+
.action(async (options) => {
|
|
713
|
+
const resolved = await resolveOptions(options);
|
|
714
|
+
const result = await migrateContextLinks(resolved.vault, {
|
|
715
|
+
dryRun: options.dryRun === true,
|
|
716
|
+
limit: parsePositiveInteger(options.limit ?? '5', 5),
|
|
717
|
+
agentId: resolved.agent
|
|
718
|
+
});
|
|
719
|
+
const shouldIndex = options.index !== false && !result.dryRun && result.changed > 0;
|
|
720
|
+
const index = shouldIndex ? await indexVault(resolved.vault, { full: true }) : undefined;
|
|
721
|
+
print(options.json, {
|
|
722
|
+
vault: resolved.vault,
|
|
723
|
+
agent: resolved.agent ?? 'shared',
|
|
724
|
+
...result,
|
|
725
|
+
...(index ? { index } : {})
|
|
726
|
+
}, () => {
|
|
727
|
+
const mode = result.dryRun ? 'Previewed' : 'Migrated';
|
|
728
|
+
const indexMessage = index
|
|
729
|
+
? ` Fully reindexed ${index.documentCount} documents, ${index.chunkCount} chunks and ${index.linkCount} context links.`
|
|
730
|
+
: '';
|
|
731
|
+
return `${mode} ${result.scanned} notes: changed=${result.changed}, skipped=${result.skipped}, limit=${result.limit}.${indexMessage}`;
|
|
732
|
+
});
|
|
733
|
+
});
|
|
702
734
|
program
|
|
703
735
|
.command('db-import')
|
|
704
736
|
.option('-v, --vault <vault>', 'vault directory')
|
|
@@ -792,7 +824,7 @@ export const registerWriteCommands = (program) => {
|
|
|
792
824
|
writeConnectivity: {
|
|
793
825
|
autoLinked: added.autoLinked,
|
|
794
826
|
linkTarget: added.linkTarget,
|
|
795
|
-
guaranteedEdge:
|
|
827
|
+
guaranteedEdge: false
|
|
796
828
|
},
|
|
797
829
|
possibleDuplicates,
|
|
798
830
|
...(index ? { index } : {})
|
package/dist/domain/markdown.js
CHANGED
|
@@ -6,6 +6,7 @@ const frontmatterPattern = /^---\n([\s\S]*?)\n---\n?/;
|
|
|
6
6
|
const wikiLinkPattern = /\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]/g;
|
|
7
7
|
const tagPattern = /(^|\s)#([A-Za-z0-9][A-Za-z0-9_-]*)/g;
|
|
8
8
|
const headingPattern = /^#\s+(.+)$/m;
|
|
9
|
+
const contextLinksHeadingPattern = /^(#{1,6})\s+(?:context\s+links?|links?\s+de\s+contexto)\b/i;
|
|
9
10
|
const priorityRanks = {
|
|
10
11
|
low: 0,
|
|
11
12
|
normal: 1,
|
|
@@ -18,7 +19,7 @@ const priorityBoosts = {
|
|
|
18
19
|
high: 3,
|
|
19
20
|
critical: 6
|
|
20
21
|
};
|
|
21
|
-
export const graphLinkModelVersion =
|
|
22
|
+
export const graphLinkModelVersion = 4;
|
|
22
23
|
const priorityPatterns = [
|
|
23
24
|
['critical', /\b(?:priority|prioridade|importance|importancia|importância)\s*[:=]\s*(?:critical|critica|crítica|urgent|urgente|p0)\b/i],
|
|
24
25
|
['critical', /#(?:critical|critica|crítica|urgent|urgente|p0)\b/i],
|
|
@@ -113,6 +114,35 @@ const compareGraphLinks = (left, right) => {
|
|
|
113
114
|
export const selectGraphWikiLinkWeights = (links) => {
|
|
114
115
|
return [...links].sort(compareGraphLinks);
|
|
115
116
|
};
|
|
117
|
+
const contextLinkLines = (content) => {
|
|
118
|
+
const lines = visibleMarkdownLines(content);
|
|
119
|
+
const selected = [];
|
|
120
|
+
let insideContextLinks = false;
|
|
121
|
+
let headingDepth = 0;
|
|
122
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
123
|
+
const line = lines[index];
|
|
124
|
+
const trimmed = line.content.trim();
|
|
125
|
+
if (line.fenced) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const heading = trimmed.match(/^(#{1,6})\s+/);
|
|
129
|
+
const contextHeading = trimmed.match(contextLinksHeadingPattern);
|
|
130
|
+
if (contextHeading) {
|
|
131
|
+
insideContextLinks = true;
|
|
132
|
+
headingDepth = contextHeading[1].length;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (insideContextLinks && heading && heading[1].length <= headingDepth) {
|
|
136
|
+
insideContextLinks = false;
|
|
137
|
+
}
|
|
138
|
+
if (insideContextLinks) {
|
|
139
|
+
selected.push(line.content);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return selected;
|
|
143
|
+
};
|
|
144
|
+
export const hasContextLinksSection = (content) => visibleMarkdownLines(content).some((line) => !line.fenced && contextLinksHeadingPattern.test(line.content.trim()));
|
|
145
|
+
export const extractContextLinkWeights = (content) => selectGraphWikiLinkWeights(extractWikiLinkWeights(contextLinkLines(content).join('\n')));
|
|
116
146
|
const extractTitle = (filePath, content, frontmatter) => {
|
|
117
147
|
if (frontmatter.title) {
|
|
118
148
|
return normalizeTitle(frontmatter.title);
|
|
@@ -197,6 +227,7 @@ export const parseMarkdownDocument = (input) => {
|
|
|
197
227
|
content: input.content,
|
|
198
228
|
tags: extractTags(input.content),
|
|
199
229
|
links: extractWikiLinks(input.content),
|
|
230
|
+
contextLinks: extractContextLinkWeights(input.content).map((link) => link.title),
|
|
200
231
|
frontmatter,
|
|
201
232
|
createdAt: input.createdAt.toISOString(),
|
|
202
233
|
updatedAt: input.updatedAt.toISOString()
|
|
@@ -204,7 +235,7 @@ export const parseMarkdownDocument = (input) => {
|
|
|
204
235
|
};
|
|
205
236
|
export const createIndexedDocument = (document, titleToDocumentId, maxChunkCharacters = 1200) => {
|
|
206
237
|
const chunks = splitIntoChunks(document.id, document.content, maxChunkCharacters);
|
|
207
|
-
const graphLinkWeights = selectGraphWikiLinkWeights(
|
|
238
|
+
const graphLinkWeights = selectGraphWikiLinkWeights(extractContextLinkWeights(document.content).filter((link) => link.title.toLowerCase() !== document.title.toLowerCase()));
|
|
208
239
|
const linkWeights = new Map(graphLinkWeights.map((link) => [link.title.toLowerCase(), link]));
|
|
209
240
|
const links = graphLinkWeights
|
|
210
241
|
.map((link) => ({
|
|
@@ -266,7 +266,8 @@ export const openFileIndex = (vaultPath) => {
|
|
|
266
266
|
title: document.title,
|
|
267
267
|
path: document.path,
|
|
268
268
|
content: document.content,
|
|
269
|
-
tags: document.tags
|
|
269
|
+
tags: document.tags,
|
|
270
|
+
contextLinks: document.contextLinks ?? []
|
|
270
271
|
})),
|
|
271
272
|
edges
|
|
272
273
|
};
|
|
@@ -292,7 +293,8 @@ export const openFileIndex = (vaultPath) => {
|
|
|
292
293
|
title: document.title,
|
|
293
294
|
path: document.path,
|
|
294
295
|
content: '',
|
|
295
|
-
tags: document.tags
|
|
296
|
+
tags: document.tags,
|
|
297
|
+
contextLinks: document.contextLinks ?? []
|
|
296
298
|
})),
|
|
297
299
|
edges
|
|
298
300
|
};
|
|
@@ -309,7 +311,8 @@ export const openFileIndex = (vaultPath) => {
|
|
|
309
311
|
title: document.title,
|
|
310
312
|
path: document.path,
|
|
311
313
|
content: document.content,
|
|
312
|
-
tags: document.tags
|
|
314
|
+
tags: document.tags,
|
|
315
|
+
contextLinks: document.contextLinks ?? []
|
|
313
316
|
}
|
|
314
317
|
: undefined;
|
|
315
318
|
},
|
package/dist/mcp/tools.js
CHANGED
|
@@ -420,7 +420,7 @@ export const addNoteTool = async (input) => {
|
|
|
420
420
|
writeConnectivity: {
|
|
421
421
|
autoLinked: added.autoLinked,
|
|
422
422
|
linkTarget: added.linkTarget,
|
|
423
|
-
guaranteedEdge:
|
|
423
|
+
guaranteedEdge: false
|
|
424
424
|
},
|
|
425
425
|
possibleDuplicates,
|
|
426
426
|
...(index ? { index } : {})
|
|
@@ -467,7 +467,7 @@ export const addFileTool = async (input) => {
|
|
|
467
467
|
writeConnectivity: {
|
|
468
468
|
autoLinked: added.autoLinked,
|
|
469
469
|
linkTarget: added.linkTarget,
|
|
470
|
-
guaranteedEdge:
|
|
470
|
+
guaranteedEdge: false
|
|
471
471
|
},
|
|
472
472
|
...(index ? { index } : {})
|
|
473
473
|
});
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -154,8 +154,10 @@ server command
|
|
|
154
154
|
-> optional index rebuild
|
|
155
155
|
-> HTTP server
|
|
156
156
|
-> /api/agents lists indexed namespaces
|
|
157
|
+
-> /api/graph-contexts lists visual graph contexts
|
|
157
158
|
-> /api/graph reads indexed documents and links
|
|
158
159
|
-> /api/graph-layout derives a star layout from indexed graph data
|
|
160
|
+
-> optional context query narrows the layout to a segment-scoped star subgraph
|
|
159
161
|
-> browser renders graph canvas
|
|
160
162
|
```
|
|
161
163
|
|
package/package.json
CHANGED