@andespindola/brainlink 0.1.0-beta.14 → 0.1.0-beta.140
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 +8 -5
- package/CHANGELOG.md +26 -2
- package/CONTRIBUTING.md +2 -2
- package/COPYRIGHT.md +5 -0
- package/README.md +144 -22
- package/SECURITY.md +1 -1
- package/dist/application/analyze-vault.js +1 -15
- package/dist/application/build-context.js +64 -3
- package/dist/application/dedupe-notes.js +226 -0
- package/dist/application/frontend/client-css.js +110 -45
- package/dist/application/frontend/client-html.js +35 -26
- package/dist/application/frontend/client-js.js +2987 -153
- package/dist/application/frontend/client-worker-js.js +66 -0
- package/dist/application/get-graph-layout.js +39 -6
- package/dist/application/get-graph-node.js +3 -3
- package/dist/application/get-graph-summary.js +3 -3
- package/dist/application/get-graph-view.js +243 -0
- package/dist/application/get-graph.js +3 -3
- package/dist/application/import-legacy-sqlite.js +296 -0
- package/dist/application/index-vault.js +253 -25
- package/dist/application/list-agents.js +3 -3
- package/dist/application/list-links.js +5 -5
- package/dist/application/offline-pack-backup.js +44 -0
- package/dist/application/search-graph-node-ids.js +3 -3
- package/dist/application/search-knowledge.js +4 -5
- package/dist/application/server/routes.js +156 -5
- package/dist/application/start-server.js +75 -4
- package/dist/application/watch-vault.js +23 -2
- package/dist/benchmarks/large-vault.js +1 -1
- package/dist/cli/commands/agent-commands.js +7 -0
- package/dist/cli/commands/write-commands.js +842 -8
- package/dist/domain/context.js +54 -11
- package/dist/domain/graph-layout.js +181 -3
- package/dist/domain/markdown.js +29 -9
- package/dist/domain/middle-out.js +18 -0
- package/dist/infrastructure/config.js +38 -0
- package/dist/infrastructure/file-index.js +358 -0
- package/dist/infrastructure/file-system-vault.js +15 -0
- package/dist/infrastructure/index-state.js +58 -0
- package/dist/infrastructure/private-pack-codec.js +71 -10
- package/dist/infrastructure/search-packs.js +276 -87
- package/dist/infrastructure/volatile-memory.js +100 -0
- package/dist/mcp/server.js +21 -1
- package/dist/mcp/tools.js +96 -0
- package/docs/AGENT_USAGE.md +101 -19
- package/docs/ARCHITECTURE.md +23 -28
- package/docs/QUICKSTART.md +7 -0
- package/package.json +6 -4
- package/dist/infrastructure/sqlite/document-writer.js +0 -51
- package/dist/infrastructure/sqlite/graph-reader.js +0 -267
- package/dist/infrastructure/sqlite/recovery.js +0 -163
- package/dist/infrastructure/sqlite/schema.js +0 -114
- package/dist/infrastructure/sqlite/search-reader.js +0 -188
- package/dist/infrastructure/sqlite/types.js +0 -1
- package/dist/infrastructure/sqlite-index.js +0 -38
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createEmbeddingBuckets, createLocalEmbedding, cosineSimilarity } from '../domain/embeddings.js';
|
|
3
|
+
import { parseMarkdownDocument } from '../domain/markdown.js';
|
|
4
|
+
import { writeMarkdownFile, ensureVault, readMarkdownFiles } from '../infrastructure/file-system-vault.js';
|
|
5
|
+
import { indexVault } from './index-vault.js';
|
|
6
|
+
const tokenPattern = /[\p{L}\p{N}_-]+/gu;
|
|
7
|
+
const frontmatterPattern = /^---\n[\s\S]*?\n---\n?/m;
|
|
8
|
+
const rootHeadingPattern = /^#\s+.+\n+/m;
|
|
9
|
+
const maxCandidatesPerBucket = 240;
|
|
10
|
+
const normalizePath = (path) => path.replaceAll('\\', '/').replace(/^\.\//, '');
|
|
11
|
+
const toComparableBody = (content) => content
|
|
12
|
+
.replace(frontmatterPattern, '')
|
|
13
|
+
.replace(rootHeadingPattern, '')
|
|
14
|
+
.replaceAll('\r\n', '\n')
|
|
15
|
+
.trim();
|
|
16
|
+
const normalizeStrictContent = (content) => toComparableBody(content);
|
|
17
|
+
const normalizeSemanticContent = (content) => toComparableBody(content)
|
|
18
|
+
.replace(/\s+/g, ' ')
|
|
19
|
+
.trim();
|
|
20
|
+
const toHash = (value) => createHash('sha256').update(value, 'utf8').digest('hex');
|
|
21
|
+
const toCandidateId = (leftPath, rightPath) => [normalizePath(leftPath), normalizePath(rightPath)].sort((left, right) => left.localeCompare(right)).join('|');
|
|
22
|
+
const hasSharedTokens = (left, right) => {
|
|
23
|
+
const leftTokens = new Set((left.match(tokenPattern) ?? []).map((token) => token.toLowerCase()).filter((token) => token.length > 2));
|
|
24
|
+
const rightTokens = new Set((right.match(tokenPattern) ?? []).map((token) => token.toLowerCase()).filter((token) => token.length > 2));
|
|
25
|
+
if (leftTokens.size === 0 || rightTokens.size === 0) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
for (const token of leftTokens) {
|
|
29
|
+
if (rightTokens.has(token)) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
};
|
|
35
|
+
const relatedMarker = (targetTitle) => `Related: [[${targetTitle}]] priority: low #related-to`;
|
|
36
|
+
const ensureRelatedEdgeLine = (content, targetTitle) => {
|
|
37
|
+
const linkPattern = new RegExp(`\\[\\[\\s*${targetTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*(?:[\\]|#])?`, 'i');
|
|
38
|
+
if (linkPattern.test(content)) {
|
|
39
|
+
return content;
|
|
40
|
+
}
|
|
41
|
+
const trimmed = content.trimEnd();
|
|
42
|
+
return `${trimmed}\n\n${relatedMarker(targetTitle)}\n`;
|
|
43
|
+
};
|
|
44
|
+
const ensureMergedMarker = (content, targetTitle) => {
|
|
45
|
+
const marker = `Merged into [[${targetTitle}]]`;
|
|
46
|
+
if (content.includes(marker)) {
|
|
47
|
+
return content;
|
|
48
|
+
}
|
|
49
|
+
return `${content.trimEnd()}\n\n${marker} priority: low #related-to\n`;
|
|
50
|
+
};
|
|
51
|
+
const appendMergedContent = (baseContent, mergedTitle, mergedContent) => {
|
|
52
|
+
const marker = `## Merged Memory From [[${mergedTitle}]]`;
|
|
53
|
+
if (baseContent.includes(marker)) {
|
|
54
|
+
return baseContent;
|
|
55
|
+
}
|
|
56
|
+
const mergedBody = normalizeSemanticContent(mergedContent);
|
|
57
|
+
return `${baseContent.trimEnd()}\n\n${marker}\n\n${mergedBody}\n`;
|
|
58
|
+
};
|
|
59
|
+
const loadNoteRecords = async (vaultPath, agentId) => {
|
|
60
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
61
|
+
const files = await readMarkdownFiles(vaultPath);
|
|
62
|
+
return files
|
|
63
|
+
.map((file) => {
|
|
64
|
+
const parsed = parseMarkdownDocument({
|
|
65
|
+
absolutePath: file.absolutePath,
|
|
66
|
+
vaultPath: absoluteVaultPath,
|
|
67
|
+
content: file.content,
|
|
68
|
+
createdAt: file.createdAt,
|
|
69
|
+
updatedAt: file.updatedAt
|
|
70
|
+
});
|
|
71
|
+
const strict = normalizeStrictContent(parsed.content);
|
|
72
|
+
const semantic = normalizeSemanticContent(parsed.content);
|
|
73
|
+
const embedding = createLocalEmbedding(`${parsed.title}\n${semantic}`);
|
|
74
|
+
return {
|
|
75
|
+
title: parsed.title,
|
|
76
|
+
path: normalizePath(parsed.path),
|
|
77
|
+
agentId: parsed.agentId,
|
|
78
|
+
content: parsed.content,
|
|
79
|
+
normalizedStrictContent: strict,
|
|
80
|
+
semanticContent: semantic,
|
|
81
|
+
embedding,
|
|
82
|
+
buckets: createEmbeddingBuckets(embedding, 20)
|
|
83
|
+
};
|
|
84
|
+
})
|
|
85
|
+
.filter((record) => (agentId ? record.agentId === agentId : true));
|
|
86
|
+
};
|
|
87
|
+
const pairToCandidate = (left, right, kind, score, reason) => ({
|
|
88
|
+
id: toCandidateId(left.path, right.path),
|
|
89
|
+
possibleDuplicate: true,
|
|
90
|
+
kind,
|
|
91
|
+
score: Number(score.toFixed(4)),
|
|
92
|
+
left: {
|
|
93
|
+
title: left.title,
|
|
94
|
+
path: left.path,
|
|
95
|
+
agentId: left.agentId
|
|
96
|
+
},
|
|
97
|
+
right: {
|
|
98
|
+
title: right.title,
|
|
99
|
+
path: right.path,
|
|
100
|
+
agentId: right.agentId
|
|
101
|
+
},
|
|
102
|
+
reason
|
|
103
|
+
});
|
|
104
|
+
const indexCandidatePairs = (notes) => {
|
|
105
|
+
const bucketMap = new Map();
|
|
106
|
+
notes.forEach((note, index) => {
|
|
107
|
+
note.buckets.forEach((bucket) => {
|
|
108
|
+
const current = bucketMap.get(bucket) ?? [];
|
|
109
|
+
if (current.length < maxCandidatesPerBucket) {
|
|
110
|
+
current.push(index);
|
|
111
|
+
bucketMap.set(bucket, current);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
const pairKeys = new Set();
|
|
116
|
+
const pairs = [];
|
|
117
|
+
bucketMap.forEach((indexes) => {
|
|
118
|
+
for (let leftIndex = 0; leftIndex < indexes.length; leftIndex += 1) {
|
|
119
|
+
for (let rightIndex = leftIndex + 1; rightIndex < indexes.length; rightIndex += 1) {
|
|
120
|
+
const left = Math.min(indexes[leftIndex] ?? 0, indexes[rightIndex] ?? 0);
|
|
121
|
+
const right = Math.max(indexes[leftIndex] ?? 0, indexes[rightIndex] ?? 0);
|
|
122
|
+
const key = `${left}|${right}`;
|
|
123
|
+
if (!pairKeys.has(key)) {
|
|
124
|
+
pairKeys.add(key);
|
|
125
|
+
pairs.push([left, right]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return pairs;
|
|
131
|
+
};
|
|
132
|
+
export const scanDuplicateNotes = async (vaultPath, options = {}) => {
|
|
133
|
+
const notes = await loadNoteRecords(vaultPath, options.agentId);
|
|
134
|
+
if (notes.length < 2) {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
const minSemanticScore = options.minSemanticScore ?? 0.92;
|
|
138
|
+
const includeSemantic = options.includeSemantic !== false;
|
|
139
|
+
const seen = new Map();
|
|
140
|
+
const byHash = notes.reduce((state, note) => {
|
|
141
|
+
const key = toHash(note.normalizedStrictContent);
|
|
142
|
+
const current = state.get(key) ?? [];
|
|
143
|
+
current.push(note);
|
|
144
|
+
state.set(key, current);
|
|
145
|
+
return state;
|
|
146
|
+
}, new Map());
|
|
147
|
+
byHash.forEach((group) => {
|
|
148
|
+
if (group.length < 2) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const [base, ...rest] = group.sort((left, right) => left.path.localeCompare(right.path));
|
|
152
|
+
rest.forEach((note) => {
|
|
153
|
+
const candidate = pairToCandidate(base, note, 'exact', 1, 'Exact content hash match');
|
|
154
|
+
seen.set(candidate.id, candidate);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
if (includeSemantic) {
|
|
158
|
+
const pairs = indexCandidatePairs(notes);
|
|
159
|
+
pairs.forEach(([leftIndex, rightIndex]) => {
|
|
160
|
+
const left = notes[leftIndex];
|
|
161
|
+
const right = notes[rightIndex];
|
|
162
|
+
if (!left || !right || left.path === right.path) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const id = toCandidateId(left.path, right.path);
|
|
166
|
+
if (seen.has(id)) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const score = cosineSimilarity(left.embedding, right.embedding);
|
|
170
|
+
const titleShared = hasSharedTokens(left.title, right.title);
|
|
171
|
+
const contentShared = hasSharedTokens(left.semanticContent, right.semanticContent);
|
|
172
|
+
if (score >= minSemanticScore && (titleShared || contentShared || score >= 0.975)) {
|
|
173
|
+
const candidate = pairToCandidate(left, right, 'semantic', score, 'High semantic similarity');
|
|
174
|
+
seen.set(id, candidate);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
const focusPath = options.focusPath ? normalizePath(options.focusPath) : undefined;
|
|
179
|
+
const limited = Array.from(seen.values())
|
|
180
|
+
.filter((item) => (focusPath ? item.left.path === focusPath || item.right.path === focusPath : true))
|
|
181
|
+
.sort((left, right) => right.score - left.score || left.left.path.localeCompare(right.left.path))
|
|
182
|
+
.slice(0, Math.max(1, options.limit ?? 25));
|
|
183
|
+
return limited;
|
|
184
|
+
};
|
|
185
|
+
export const resolveDuplicateNotes = async (vaultPath, options) => {
|
|
186
|
+
const leftPath = normalizePath(options.leftPath);
|
|
187
|
+
const rightPath = normalizePath(options.rightPath);
|
|
188
|
+
if (leftPath === rightPath) {
|
|
189
|
+
throw new Error('leftPath and rightPath must be different notes.');
|
|
190
|
+
}
|
|
191
|
+
const notes = await loadNoteRecords(vaultPath);
|
|
192
|
+
const byPath = new Map(notes.map((note) => [note.path, note]));
|
|
193
|
+
const left = byPath.get(leftPath);
|
|
194
|
+
const right = byPath.get(rightPath);
|
|
195
|
+
if (!left || !right) {
|
|
196
|
+
throw new Error(`Duplicate resolution paths were not found in vault index source: ${leftPath}, ${rightPath}`);
|
|
197
|
+
}
|
|
198
|
+
const updates = new Map();
|
|
199
|
+
const leftRelated = ensureRelatedEdgeLine(left.content, right.title);
|
|
200
|
+
const rightRelated = ensureRelatedEdgeLine(right.content, left.title);
|
|
201
|
+
if (options.action === 'link') {
|
|
202
|
+
updates.set(left.path, leftRelated);
|
|
203
|
+
updates.set(right.path, rightRelated);
|
|
204
|
+
}
|
|
205
|
+
else if (options.action === 'ignore') {
|
|
206
|
+
updates.set(left.path, leftRelated);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
const mergedLeft = appendMergedContent(leftRelated, right.title, right.content);
|
|
210
|
+
const mergedRight = ensureMergedMarker(rightRelated, left.title);
|
|
211
|
+
updates.set(left.path, mergedLeft);
|
|
212
|
+
updates.set(right.path, mergedRight);
|
|
213
|
+
}
|
|
214
|
+
for (const [path, content] of updates) {
|
|
215
|
+
await writeMarkdownFile(vaultPath, path, content);
|
|
216
|
+
}
|
|
217
|
+
const shouldIndex = options.autoIndex !== false;
|
|
218
|
+
const index = shouldIndex ? await indexVault(vaultPath) : undefined;
|
|
219
|
+
return {
|
|
220
|
+
action: options.action,
|
|
221
|
+
leftPath,
|
|
222
|
+
rightPath,
|
|
223
|
+
updatedPaths: Array.from(updates.keys()).sort((leftValue, rightValue) => leftValue.localeCompare(rightValue)),
|
|
224
|
+
...(index ? { index } : {})
|
|
225
|
+
};
|
|
226
|
+
};
|
|
@@ -25,6 +25,13 @@ body {
|
|
|
25
25
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
body {
|
|
29
|
+
display: flex;
|
|
30
|
+
flex-direction: column;
|
|
31
|
+
min-height: 100vh;
|
|
32
|
+
min-height: 100dvh;
|
|
33
|
+
}
|
|
34
|
+
|
|
28
35
|
button,
|
|
29
36
|
input,
|
|
30
37
|
select {
|
|
@@ -32,50 +39,73 @@ select {
|
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
.shell {
|
|
42
|
+
flex: 1 1 auto;
|
|
35
43
|
width: 100%;
|
|
36
|
-
height:
|
|
44
|
+
min-height: 0;
|
|
37
45
|
overflow: hidden;
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
.workspace {
|
|
49
|
+
display: grid;
|
|
50
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
41
51
|
position: relative;
|
|
52
|
+
width: 100%;
|
|
53
|
+
height: 100%;
|
|
42
54
|
min-width: 0;
|
|
43
55
|
min-height: 0;
|
|
44
56
|
}
|
|
45
57
|
|
|
46
|
-
|
|
47
|
-
|
|
58
|
+
.graph-header {
|
|
59
|
+
z-index: 5;
|
|
60
|
+
display: flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
gap: 12px;
|
|
63
|
+
min-height: 72px;
|
|
64
|
+
padding: 10px 16px;
|
|
65
|
+
border-bottom: 1px solid var(--line);
|
|
66
|
+
background: linear-gradient(180deg, rgba(17, 21, 27, 0.96) 0%, rgba(17, 21, 27, 0.86) 100%);
|
|
67
|
+
backdrop-filter: blur(8px);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.brand-block {
|
|
71
|
+
display: grid;
|
|
72
|
+
gap: 2px;
|
|
73
|
+
min-width: max-content;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.brand-block strong {
|
|
77
|
+
font-size: 18px;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.graph-stage {
|
|
81
|
+
position: relative;
|
|
48
82
|
width: 100%;
|
|
49
83
|
height: 100%;
|
|
50
84
|
background:
|
|
51
85
|
radial-gradient(circle at 18% 20%, rgba(53, 208, 162, 0.12), transparent 28rem),
|
|
52
86
|
linear-gradient(135deg, #0d0f12 0%, #12161c 55%, #0a0d10 100%);
|
|
53
|
-
|
|
87
|
+
overflow: hidden;
|
|
54
88
|
}
|
|
55
89
|
|
|
56
|
-
#graph
|
|
57
|
-
|
|
90
|
+
#graph,
|
|
91
|
+
#graphGl {
|
|
92
|
+
display: block;
|
|
93
|
+
position: absolute;
|
|
94
|
+
inset: 0;
|
|
95
|
+
width: 100%;
|
|
96
|
+
height: 100%;
|
|
58
97
|
}
|
|
59
98
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
top: 18px;
|
|
63
|
-
left: 18px;
|
|
64
|
-
right: 18px;
|
|
65
|
-
display: flex;
|
|
66
|
-
align-items: center;
|
|
67
|
-
justify-content: space-between;
|
|
68
|
-
gap: 18px;
|
|
69
|
-
pointer-events: none;
|
|
99
|
+
#graph {
|
|
100
|
+
cursor: grab;
|
|
70
101
|
}
|
|
71
102
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
align-items: center;
|
|
103
|
+
#graphGl {
|
|
104
|
+
pointer-events: none;
|
|
75
105
|
}
|
|
76
106
|
|
|
77
|
-
|
|
78
|
-
|
|
107
|
+
#graph:active {
|
|
108
|
+
cursor: grabbing;
|
|
79
109
|
}
|
|
80
110
|
|
|
81
111
|
.eyebrow {
|
|
@@ -84,13 +114,19 @@ select {
|
|
|
84
114
|
}
|
|
85
115
|
|
|
86
116
|
.search {
|
|
87
|
-
|
|
88
|
-
|
|
117
|
+
flex: 1 1 320px;
|
|
118
|
+
min-width: 220px;
|
|
89
119
|
}
|
|
90
120
|
|
|
91
121
|
.agent-filter {
|
|
92
122
|
width: min(220px, 28vw);
|
|
93
|
-
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.header-actions {
|
|
126
|
+
display: flex;
|
|
127
|
+
align-items: center;
|
|
128
|
+
gap: 10px;
|
|
129
|
+
margin-left: auto;
|
|
94
130
|
}
|
|
95
131
|
|
|
96
132
|
.search input,
|
|
@@ -111,9 +147,6 @@ select {
|
|
|
111
147
|
}
|
|
112
148
|
|
|
113
149
|
.toolbar {
|
|
114
|
-
position: absolute;
|
|
115
|
-
left: 18px;
|
|
116
|
-
bottom: 18px;
|
|
117
150
|
display: flex;
|
|
118
151
|
gap: 8px;
|
|
119
152
|
}
|
|
@@ -134,12 +167,9 @@ select {
|
|
|
134
167
|
}
|
|
135
168
|
|
|
136
169
|
.floating-metrics {
|
|
137
|
-
position: absolute;
|
|
138
|
-
top: 66px;
|
|
139
|
-
left: 18px;
|
|
140
170
|
display: flex;
|
|
141
171
|
gap: 10px;
|
|
142
|
-
|
|
172
|
+
flex-wrap: wrap;
|
|
143
173
|
}
|
|
144
174
|
|
|
145
175
|
.metric-chip {
|
|
@@ -232,14 +262,20 @@ li small {
|
|
|
232
262
|
}
|
|
233
263
|
|
|
234
264
|
.content-dialog {
|
|
235
|
-
|
|
236
|
-
|
|
265
|
+
position: fixed;
|
|
266
|
+
top: 84px;
|
|
267
|
+
right: 12px;
|
|
268
|
+
margin: 0;
|
|
269
|
+
width: min(440px, calc(100vw - 24px));
|
|
270
|
+
height: min(calc(100svh - 124px), 820px);
|
|
271
|
+
max-height: calc(100svh - 124px);
|
|
237
272
|
padding: 0;
|
|
238
273
|
border: 1px solid var(--line);
|
|
239
274
|
border-radius: 8px;
|
|
240
275
|
background: var(--panel);
|
|
241
276
|
color: var(--text);
|
|
242
277
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.48);
|
|
278
|
+
overflow: hidden;
|
|
243
279
|
}
|
|
244
280
|
|
|
245
281
|
.content-dialog::backdrop {
|
|
@@ -250,7 +286,8 @@ li small {
|
|
|
250
286
|
.content-dialog article {
|
|
251
287
|
display: grid;
|
|
252
288
|
grid-template-rows: auto auto minmax(0, 1fr);
|
|
253
|
-
|
|
289
|
+
height: 100%;
|
|
290
|
+
max-height: 100%;
|
|
254
291
|
}
|
|
255
292
|
|
|
256
293
|
.content-dialog header {
|
|
@@ -269,7 +306,7 @@ li small {
|
|
|
269
306
|
|
|
270
307
|
.content-dialog h2 {
|
|
271
308
|
margin-top: 6px;
|
|
272
|
-
font-size:
|
|
309
|
+
font-size: 19px;
|
|
273
310
|
line-height: 1.15;
|
|
274
311
|
overflow-wrap: anywhere;
|
|
275
312
|
}
|
|
@@ -326,7 +363,7 @@ li small {
|
|
|
326
363
|
|
|
327
364
|
.content-meta-section ul,
|
|
328
365
|
.content-meta-section .tags {
|
|
329
|
-
max-height:
|
|
366
|
+
max-height: 220px;
|
|
330
367
|
overflow: auto;
|
|
331
368
|
align-content: flex-start;
|
|
332
369
|
padding-right: 4px;
|
|
@@ -337,34 +374,62 @@ li small {
|
|
|
337
374
|
min-height: 0;
|
|
338
375
|
border: 0;
|
|
339
376
|
border-radius: 0;
|
|
340
|
-
padding:
|
|
377
|
+
padding: 14px;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.app-footer {
|
|
381
|
+
flex: 0 0 28px;
|
|
382
|
+
height: 28px;
|
|
383
|
+
display: flex;
|
|
384
|
+
align-items: center;
|
|
385
|
+
justify-content: center;
|
|
386
|
+
background: transparent;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.app-footer small {
|
|
390
|
+
color: var(--muted);
|
|
391
|
+
font-size: 11px;
|
|
392
|
+
letter-spacing: 0.02em;
|
|
341
393
|
}
|
|
342
394
|
|
|
343
395
|
@media (max-width: 860px) {
|
|
344
|
-
.
|
|
396
|
+
.graph-header {
|
|
345
397
|
align-items: stretch;
|
|
346
|
-
flex-
|
|
398
|
+
flex-wrap: wrap;
|
|
399
|
+
padding: 10px 12px;
|
|
400
|
+
min-height: 0;
|
|
347
401
|
}
|
|
348
402
|
|
|
349
403
|
.search {
|
|
350
404
|
width: 100%;
|
|
405
|
+
flex-basis: 100%;
|
|
406
|
+
order: 3;
|
|
351
407
|
}
|
|
352
408
|
|
|
353
409
|
.agent-filter {
|
|
354
410
|
width: 100%;
|
|
355
411
|
}
|
|
356
412
|
|
|
413
|
+
.header-actions {
|
|
414
|
+
width: 100%;
|
|
415
|
+
margin-left: 0;
|
|
416
|
+
justify-content: space-between;
|
|
417
|
+
order: 4;
|
|
418
|
+
}
|
|
419
|
+
|
|
357
420
|
.content-dialog header {
|
|
358
421
|
align-items: stretch;
|
|
359
422
|
flex-direction: column;
|
|
360
423
|
}
|
|
361
424
|
|
|
362
|
-
.
|
|
363
|
-
top:
|
|
364
|
-
right:
|
|
365
|
-
left:
|
|
366
|
-
|
|
367
|
-
|
|
425
|
+
.content-dialog {
|
|
426
|
+
top: auto;
|
|
427
|
+
right: 12px;
|
|
428
|
+
left: 12px;
|
|
429
|
+
bottom: 38px;
|
|
430
|
+
width: auto;
|
|
431
|
+
height: min(calc(100svh - 170px), 640px);
|
|
432
|
+
max-height: calc(100svh - 170px);
|
|
368
433
|
}
|
|
369
434
|
|
|
370
435
|
.metric-chip {
|
|
@@ -9,45 +9,54 @@ export const createClientHtml = () => `<!doctype html>
|
|
|
9
9
|
<body>
|
|
10
10
|
<main class="shell">
|
|
11
11
|
<section class="workspace" aria-label="Knowledge graph">
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
<div>
|
|
12
|
+
<header class="graph-header" aria-label="Graph actions">
|
|
13
|
+
<div class="brand-block">
|
|
15
14
|
<strong>Brainlink</strong>
|
|
15
|
+
<span class="eyebrow">Knowledge Graph</span>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="floating-metrics" aria-label="Graph totals">
|
|
18
|
+
<div class="metric-chip">
|
|
19
|
+
<strong id="nodeCount">0</strong>
|
|
20
|
+
<small>Notes</small>
|
|
21
|
+
</div>
|
|
22
|
+
<div class="metric-chip">
|
|
23
|
+
<strong id="edgeCount">0</strong>
|
|
24
|
+
<small>Links</small>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="metric-chip">
|
|
27
|
+
<strong id="tagCount">0</strong>
|
|
28
|
+
<small>Tags</small>
|
|
29
|
+
</div>
|
|
16
30
|
</div>
|
|
17
31
|
<label class="search">
|
|
18
32
|
<input id="search" type="search" placeholder="Filter notes, tags or paths" autocomplete="off" />
|
|
19
33
|
</label>
|
|
20
|
-
<
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
34
|
+
<div class="header-actions">
|
|
35
|
+
<label class="agent-filter">
|
|
36
|
+
<select id="agent"></select>
|
|
37
|
+
</label>
|
|
38
|
+
<div class="toolbar" aria-label="Graph controls">
|
|
39
|
+
<button id="zoomIn" type="button" title="Zoom in">+</button>
|
|
40
|
+
<button id="zoomOut" type="button" title="Zoom out">-</button>
|
|
41
|
+
<button id="fit" type="button" title="Focus central hub">◎</button>
|
|
42
|
+
<button id="reset" type="button" title="Reset view">⌂</button>
|
|
43
|
+
</div>
|
|
28
44
|
</div>
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
<div class="metric-chip">
|
|
34
|
-
<strong id="tagCount">0</strong>
|
|
35
|
-
<small>Tags</small>
|
|
36
|
-
</div>
|
|
37
|
-
</div>
|
|
38
|
-
<div class="toolbar" aria-label="Graph controls">
|
|
39
|
-
<button id="zoomIn" type="button" title="Zoom in">+</button>
|
|
40
|
-
<button id="zoomOut" type="button" title="Zoom out">-</button>
|
|
41
|
-
<button id="fit" type="button" title="Fit visible nodes">◎</button>
|
|
42
|
-
<button id="reset" type="button" title="Reset view">⌂</button>
|
|
45
|
+
</header>
|
|
46
|
+
<div class="graph-stage">
|
|
47
|
+
<canvas id="graphGl" aria-hidden="true"></canvas>
|
|
48
|
+
<canvas id="graph" aria-label="Brainlink knowledge graph"></canvas>
|
|
43
49
|
</div>
|
|
44
50
|
</section>
|
|
45
51
|
</main>
|
|
52
|
+
<footer class="app-footer" aria-label="Copyright notice">
|
|
53
|
+
<small>Copyright © 2026 Substructa</small>
|
|
54
|
+
</footer>
|
|
46
55
|
<dialog id="contentDialog" class="content-dialog" aria-labelledby="contentTitle">
|
|
47
56
|
<article>
|
|
48
57
|
<header>
|
|
49
58
|
<div>
|
|
50
|
-
<span class="eyebrow">
|
|
59
|
+
<span class="eyebrow">Node details</span>
|
|
51
60
|
<h2 id="contentTitle">Selected note</h2>
|
|
52
61
|
<p id="contentPath"></p>
|
|
53
62
|
</div>
|