@andespindola/brainlink 0.1.0-beta.99 → 1.0.0

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.
Files changed (46) hide show
  1. package/AGENTS.md +6 -6
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +186 -38
  4. package/dist/application/add-note.js +13 -44
  5. package/dist/application/analyze-vault.js +1 -1
  6. package/dist/application/auto-migrate-configured-vault.js +37 -0
  7. package/dist/application/build-context.js +119 -20
  8. package/dist/application/canonical-context-links.js +209 -0
  9. package/dist/application/frontend/client-css.js +212 -42
  10. package/dist/application/frontend/client-html.js +42 -28
  11. package/dist/application/frontend/client-js.js +1294 -3222
  12. package/dist/application/frontend/client-render-worker-js.js +676 -0
  13. package/dist/application/get-graph-contexts.js +33 -0
  14. package/dist/application/get-graph-layout.js +62 -8
  15. package/dist/application/get-graph-stream-chunk.js +326 -0
  16. package/dist/application/get-graph-view.js +246 -0
  17. package/dist/application/graph-view-state.js +66 -0
  18. package/dist/application/import-legacy-sqlite.js +3 -33
  19. package/dist/application/index-vault.js +35 -22
  20. package/dist/application/migrate-context-links.js +79 -0
  21. package/dist/application/search-graph-node-ids.js +63 -3
  22. package/dist/application/server/routes.js +197 -12
  23. package/dist/cli/commands/read-commands.js +39 -3
  24. package/dist/cli/commands/vault-commands.js +182 -0
  25. package/dist/cli/commands/write-commands.js +147 -12
  26. package/dist/cli/main.js +2 -0
  27. package/dist/cli/runtime.js +10 -2
  28. package/dist/domain/context.js +1 -0
  29. package/dist/domain/graph-contexts.js +180 -0
  30. package/dist/domain/graph-layout.js +347 -21
  31. package/dist/domain/markdown.js +53 -9
  32. package/dist/infrastructure/config.js +105 -6
  33. package/dist/infrastructure/context-packs.js +122 -0
  34. package/dist/infrastructure/file-index.js +6 -3
  35. package/dist/infrastructure/index-state.js +2 -0
  36. package/dist/infrastructure/vault-migration-state.js +69 -0
  37. package/dist/infrastructure/volatile-memory.js +100 -0
  38. package/dist/mcp/http-server.js +97 -0
  39. package/dist/mcp/runtime.js +20 -0
  40. package/dist/mcp/server.js +36 -13
  41. package/dist/mcp/tools.js +203 -14
  42. package/docs/AGENT_USAGE.md +50 -5
  43. package/docs/ARCHITECTURE.md +11 -0
  44. package/docs/QUICKSTART.md +3 -1
  45. package/docs/RELEASE.md +4 -3
  46. package/package.json +3 -1
@@ -0,0 +1,66 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ const stateVersion = 1;
4
+ const graphViewStatePath = (vaultPath) => join(vaultPath, '.brainlink', 'graph-view-state.json');
5
+ const stateKey = (input) => [input.signature, input.agentId ?? 'all-agents', input.context ?? 'all-contexts'].join(':');
6
+ const emptyPersistedState = () => ({
7
+ version: stateVersion,
8
+ states: {}
9
+ });
10
+ const readPersistedState = async (vaultPath) => {
11
+ try {
12
+ const parsed = JSON.parse(await readFile(graphViewStatePath(vaultPath), 'utf8'));
13
+ return parsed.version === stateVersion && parsed.states && typeof parsed.states === 'object' ? parsed : emptyPersistedState();
14
+ }
15
+ catch {
16
+ return emptyPersistedState();
17
+ }
18
+ };
19
+ const writePersistedState = async (vaultPath, state) => {
20
+ const target = graphViewStatePath(vaultPath);
21
+ const temp = `${target}.tmp`;
22
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
23
+ await writeFile(temp, `${JSON.stringify(state)}\n`, { encoding: 'utf8', mode: 0o600 });
24
+ await rename(temp, target);
25
+ };
26
+ const normalizePositions = (positions) => positions.flatMap((position) => {
27
+ const id = typeof position.id === 'string' ? position.id.trim() : '';
28
+ const x = Number(position.x);
29
+ const y = Number(position.y);
30
+ return id && Number.isFinite(x) && Number.isFinite(y) ? [{ id, x, y }] : [];
31
+ });
32
+ export const getGraphViewState = async (vaultPath, input) => {
33
+ const persisted = await readPersistedState(vaultPath);
34
+ const state = persisted.states[stateKey(input)];
35
+ return state ?? {
36
+ ...input,
37
+ positions: []
38
+ };
39
+ };
40
+ export const saveGraphViewState = async (vaultPath, input) => {
41
+ const persisted = await readPersistedState(vaultPath);
42
+ const nextState = {
43
+ ...input,
44
+ positions: normalizePositions(input.positions)
45
+ };
46
+ await writePersistedState(vaultPath, {
47
+ version: stateVersion,
48
+ states: {
49
+ ...persisted.states,
50
+ [stateKey(input)]: nextState
51
+ }
52
+ });
53
+ return nextState;
54
+ };
55
+ export const deleteGraphViewState = async (vaultPath, input) => {
56
+ const persisted = await readPersistedState(vaultPath);
57
+ const { [stateKey(input)]: _removed, ...states } = persisted.states;
58
+ await writePersistedState(vaultPath, {
59
+ version: stateVersion,
60
+ states
61
+ });
62
+ return {
63
+ ...input,
64
+ positions: []
65
+ };
66
+ };
@@ -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, extractWikiLinks } from '../domain/markdown.js';
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 connectedContent = await applyConnectivityRule(vaultPath, reserved, createdSystemNotes, row.title, mergedContent, agentId, options.dryRun === true);
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: createdSystemNotes.size,
241
+ createdSystemNotes: 0,
272
242
  importedFiles
273
243
  };
274
244
  };
@@ -1,5 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { createIndexedDocument, parseMarkdownDocument } from '../domain/markdown.js';
2
+ import { createIndexedDocument, graphLinkModelVersion, parseMarkdownDocument } from '../domain/markdown.js';
3
3
  import { sharedAgentId } from '../domain/agents.js';
4
4
  import { createEmbeddingProvider } from '../domain/embeddings.js';
5
5
  import { loadBrainlinkConfig } from '../infrastructure/config.js';
@@ -85,8 +85,8 @@ const readChangedDocuments = async (absoluteVaultPath, changedSummaries) => {
85
85
  })));
86
86
  return new Map(parsed.map((document) => [document.path, document]));
87
87
  };
88
- export const indexVault = async (vaultPath) => {
89
- return indexVaultWithOptions(vaultPath, {});
88
+ export const indexVault = async (vaultPath, options = {}) => {
89
+ return indexVaultWithOptions(vaultPath, options);
90
90
  };
91
91
  export const indexVaultWithOptions = async (vaultPath, options) => {
92
92
  const startedAt = process.hrtime.bigint();
@@ -113,6 +113,7 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
113
113
  markdownFiles: summaries.length,
114
114
  hasPreviousState: previousState != null
115
115
  });
116
+ const fullReindex = options.full === true;
116
117
  const index = openFileIndex(absoluteVaultPath);
117
118
  try {
118
119
  const existingIndexedDocuments = await index.getIndexedDocuments();
@@ -120,9 +121,13 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
120
121
  const currentSnapshot = toSnapshot(summaries);
121
122
  const currentSnapshotMap = createSnapshotMap(currentSnapshot);
122
123
  const previousSnapshotMap = createSnapshotMap(previousState?.files ?? []);
124
+ const graphLinkModelChanged = previousState != null &&
125
+ previousState.graphLinkModelVersion !== graphLinkModelVersion;
126
+ const fullSourceReindex = fullReindex || graphLinkModelChanged;
123
127
  const settingsChanged = previousState == null ||
124
128
  previousState.chunkSize !== config.chunkSize ||
125
- previousState.embeddingProvider !== config.embeddingProvider;
129
+ previousState.embeddingProvider !== config.embeddingProvider ||
130
+ graphLinkModelChanged;
126
131
  const packSettingsChanged = previousState == null ||
127
132
  previousState.searchPackRowChunkSize !== config.searchPack.rowChunkSize ||
128
133
  previousState.searchPackCompressionLevel !== config.searchPack.compressionLevel ||
@@ -131,7 +136,8 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
131
136
  for (let index = 0; index < summaries.length; index += 1) {
132
137
  const summary = summaries[index];
133
138
  const previous = previousSnapshotMap.get(summary.relativePath);
134
- const changed = settingsChanged ||
139
+ const changed = fullSourceReindex ||
140
+ settingsChanged ||
135
141
  previous == null ||
136
142
  previous.mtimeMs !== summary.updatedAt.getTime() ||
137
143
  previous.size !== summary.size ||
@@ -147,7 +153,8 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
147
153
  if (changedPaths.size === 0 &&
148
154
  !hasDeletes &&
149
155
  existingIndexedDocuments.length === summaries.length &&
150
- previousState != null) {
156
+ previousState != null &&
157
+ !fullReindex) {
151
158
  const result = {
152
159
  ...toIndexResult(existingIndexedDocuments),
153
160
  elapsedMs: elapsedMs(),
@@ -204,7 +211,6 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
204
211
  return needsRelink ? relinkIndexedDocument(existing, titleMaps) : existing;
205
212
  });
206
213
  emit('persist', 'start', 'Persisting index');
207
- await index.reset();
208
214
  await index.saveDocuments(indexedDocuments);
209
215
  emit('persist', 'finish', 'Index persisted', {
210
216
  indexedDocuments: indexedDocuments.length
@@ -216,6 +222,8 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
216
222
  const previousPendingPackChanges = previousState?.pendingPackChanges ?? 0;
217
223
  const pendingPackChanges = previousPendingPackChanges + changedCount;
218
224
  const shouldRebuildPacks = !existingPackManifest ||
225
+ fullReindex ||
226
+ graphLinkModelChanged ||
219
227
  settingsChanged ||
220
228
  packSettingsChanged ||
221
229
  hasDeletes ||
@@ -225,21 +233,25 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
225
233
  let packResult;
226
234
  const packReason = !existingPackManifest
227
235
  ? 'Missing pack manifest'
228
- : manifestRecovery.repaired
229
- ? 'Pack manifest repaired from existing packs'
230
- : settingsChanged
231
- ? 'Index settings changed'
232
- : packSettingsChanged
233
- ? 'Search pack settings changed'
234
- : hasDeletes
235
- ? 'Document deletions detected'
236
- : changedCount >= 400
237
- ? 'Changed file count threshold reached'
238
- : changeRatio >= 0.04
239
- ? 'Change ratio threshold reached'
240
- : pendingPackChanges >= 1200
241
- ? 'Pending pack changes threshold reached'
242
- : 'Pack rebuild skipped';
236
+ : fullReindex
237
+ ? 'Full reindex requested'
238
+ : graphLinkModelChanged
239
+ ? 'Graph link model changed'
240
+ : manifestRecovery.repaired
241
+ ? 'Pack manifest repaired from existing packs'
242
+ : settingsChanged
243
+ ? 'Index settings changed'
244
+ : packSettingsChanged
245
+ ? 'Search pack settings changed'
246
+ : hasDeletes
247
+ ? 'Document deletions detected'
248
+ : changedCount >= 400
249
+ ? 'Changed file count threshold reached'
250
+ : changeRatio >= 0.04
251
+ ? 'Change ratio threshold reached'
252
+ : pendingPackChanges >= 1200
253
+ ? 'Pending pack changes threshold reached'
254
+ : 'Pack rebuild skipped';
243
255
  if (shouldRebuildPacks) {
244
256
  emit('packs', 'start', 'Rebuilding compressed search packs', {
245
257
  reason: packReason
@@ -271,6 +283,7 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
271
283
  await writeIndexState(absoluteVaultPath, {
272
284
  chunkSize: config.chunkSize,
273
285
  embeddingProvider: config.embeddingProvider,
286
+ graphLinkModelVersion,
274
287
  searchPackRowChunkSize: config.searchPack.rowChunkSize,
275
288
  searchPackCompressionLevel: config.searchPack.compressionLevel,
276
289
  searchPackUseDictionary: config.searchPack.useDictionary,
@@ -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,70 @@
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
- export const searchGraphNodeIds = async (vaultPath, query, limit, agentId) => {
3
+ import { indexStoragePath, openFileIndex } from '../infrastructure/file-index.js';
4
+ import { getGraphLayout } from './get-graph-layout.js';
5
+ const graphSearchCacheTtlMs = 20_000;
6
+ const graphSearchCacheMaxEntries = 120;
7
+ const graphSearchCache = new Map();
8
+ const readIndexSignature = async (vaultPath) => {
9
+ try {
10
+ const info = await stat(indexStoragePath(vaultPath));
11
+ return `${Math.floor(info.mtimeMs)}:${info.size}`;
12
+ }
13
+ catch {
14
+ return '0:0';
15
+ }
16
+ };
17
+ const cacheKey = (vaultPath, query, limit, agentId, context) => JSON.stringify({
18
+ vaultPath,
19
+ query: query.trim().toLowerCase(),
20
+ limit,
21
+ agentId: agentId?.trim().toLowerCase() ?? '*',
22
+ context: context?.trim().toLowerCase() ?? '*'
23
+ });
24
+ const readCached = (key, indexSignature) => {
25
+ const entry = graphSearchCache.get(key);
26
+ if (!entry) {
27
+ return undefined;
28
+ }
29
+ const fresh = Date.now() - entry.createdAt <= graphSearchCacheTtlMs && entry.indexSignature === indexSignature;
30
+ if (!fresh) {
31
+ graphSearchCache.delete(key);
32
+ return undefined;
33
+ }
34
+ return entry.nodeIds;
35
+ };
36
+ const writeCached = (key, entry) => {
37
+ graphSearchCache.set(key, entry);
38
+ if (graphSearchCache.size <= graphSearchCacheMaxEntries) {
39
+ return;
40
+ }
41
+ const overflow = graphSearchCache.size - graphSearchCacheMaxEntries;
42
+ Array.from(graphSearchCache.keys()).slice(0, overflow).forEach((cacheKey) => graphSearchCache.delete(cacheKey));
43
+ };
44
+ export const searchGraphNodeIds = async (vaultPath, query, limit, agentId, context) => {
4
45
  const absoluteVaultPath = await ensureVault(vaultPath);
46
+ const indexSignature = await readIndexSignature(absoluteVaultPath);
47
+ const key = cacheKey(absoluteVaultPath, query, limit, agentId, context);
48
+ const cached = readCached(key, indexSignature);
49
+ if (cached) {
50
+ return cached;
51
+ }
52
+ const contextNodeIds = context
53
+ ? new Set((await getGraphLayout(absoluteVaultPath, { agentId, context })).layout.nodes.map((node) => node.id))
54
+ : new Set();
5
55
  const index = openFileIndex(absoluteVaultPath);
6
56
  try {
7
- return await index.searchGraphNodeIds(query, limit, agentId);
57
+ const searchLimit = context ? Math.max(limit, 5000) : limit;
58
+ const foundNodeIds = await index.searchGraphNodeIds(query, searchLimit, agentId);
59
+ const nodeIds = context
60
+ ? foundNodeIds.filter((nodeId) => contextNodeIds.has(nodeId)).slice(0, limit)
61
+ : foundNodeIds;
62
+ writeCached(key, {
63
+ createdAt: Date.now(),
64
+ indexSignature,
65
+ nodeIds
66
+ });
67
+ return nodeIds;
8
68
  }
9
69
  finally {
10
70
  index.close();