@andespindola/brainlink 0.1.0-alpha.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.
- package/AGENTS.md +142 -0
- package/CHANGELOG.md +13 -0
- package/CONTRIBUTING.md +28 -0
- package/LICENSE +23 -0
- package/README.md +715 -0
- package/SECURITY.md +35 -0
- package/dist/application/add-note.js +30 -0
- package/dist/application/analyze-vault.js +28 -0
- package/dist/application/build-context.js +15 -0
- package/dist/application/frontend/client-css.js +294 -0
- package/dist/application/frontend/client-html.js +66 -0
- package/dist/application/frontend/client-js.js +416 -0
- package/dist/application/get-graph-layout.js +3 -0
- package/dist/application/get-graph.js +12 -0
- package/dist/application/index-vault.js +67 -0
- package/dist/application/list-agents.js +12 -0
- package/dist/application/list-links.js +22 -0
- package/dist/application/search-knowledge.js +19 -0
- package/dist/application/server/host-security.js +6 -0
- package/dist/application/server/http.js +13 -0
- package/dist/application/server/routes.js +88 -0
- package/dist/application/server/types.js +1 -0
- package/dist/application/start-server.js +54 -0
- package/dist/application/watch-vault.js +36 -0
- package/dist/benchmarks/large-vault.js +88 -0
- package/dist/cli/commands/read-commands.js +149 -0
- package/dist/cli/commands/write-commands.js +107 -0
- package/dist/cli/main.js +21 -0
- package/dist/cli/runtime.js +18 -0
- package/dist/cli/types.js +1 -0
- package/dist/domain/agents.js +11 -0
- package/dist/domain/context.js +44 -0
- package/dist/domain/embeddings.js +117 -0
- package/dist/domain/graph-analysis.js +48 -0
- package/dist/domain/graph-layout.js +187 -0
- package/dist/domain/ids.js +2 -0
- package/dist/domain/markdown.js +100 -0
- package/dist/domain/note-safety.js +54 -0
- package/dist/domain/tokens.js +1 -0
- package/dist/domain/types.js +1 -0
- package/dist/infrastructure/config.js +60 -0
- package/dist/infrastructure/file-system-vault.js +62 -0
- package/dist/infrastructure/sqlite/document-writer.js +50 -0
- package/dist/infrastructure/sqlite/graph-reader.js +108 -0
- package/dist/infrastructure/sqlite/schema.js +87 -0
- package/dist/infrastructure/sqlite/search-reader.js +156 -0
- package/dist/infrastructure/sqlite/types.js +1 -0
- package/dist/infrastructure/sqlite-index.js +20 -0
- package/docs/AGENT_USAGE.md +477 -0
- package/docs/ARCHITECTURE.md +286 -0
- package/docs/RELEASE.md +67 -0
- package/package.json +67 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { indexVault } from './index-vault.js';
|
|
3
|
+
import { startVaultWatcher } from './watch-vault.js';
|
|
4
|
+
import { assertPublicBindAllowed } from './server/host-security.js';
|
|
5
|
+
import { contentTypes, createJsonResponse, isHttpError } from './server/http.js';
|
|
6
|
+
import { route } from './server/routes.js';
|
|
7
|
+
export const startServer = async (input) => {
|
|
8
|
+
assertPublicBindAllowed(input.host, input.allowPublic);
|
|
9
|
+
if (input.shouldIndex) {
|
|
10
|
+
await indexVault(input.vaultPath);
|
|
11
|
+
}
|
|
12
|
+
const watcher = input.shouldWatch
|
|
13
|
+
? startVaultWatcher({
|
|
14
|
+
vaultPath: input.vaultPath,
|
|
15
|
+
onError: (error) => console.error(error)
|
|
16
|
+
})
|
|
17
|
+
: null;
|
|
18
|
+
const server = createServer((request, response) => {
|
|
19
|
+
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? input.host}`);
|
|
20
|
+
route(request, url, input.vaultPath)
|
|
21
|
+
.then((result) => {
|
|
22
|
+
response.writeHead(result.statusCode, result.headers);
|
|
23
|
+
response.end(result.body);
|
|
24
|
+
})
|
|
25
|
+
.catch((error) => {
|
|
26
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
27
|
+
const statusCode = isHttpError(error) ? error.statusCode : 500;
|
|
28
|
+
response.writeHead(statusCode, { 'content-type': contentTypes['.json'] });
|
|
29
|
+
response.end(createJsonResponse({ error: message }));
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
await new Promise((resolve, reject) => {
|
|
33
|
+
server.once('error', reject);
|
|
34
|
+
server.listen(input.port, input.host, () => {
|
|
35
|
+
server.off('error', reject);
|
|
36
|
+
resolve();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
const address = server.address();
|
|
40
|
+
const port = typeof address === 'object' && address ? address.port : input.port;
|
|
41
|
+
return {
|
|
42
|
+
url: `http://${input.host}:${port}`,
|
|
43
|
+
close: () => new Promise((resolve, reject) => {
|
|
44
|
+
watcher?.close();
|
|
45
|
+
server.close((error) => {
|
|
46
|
+
if (error) {
|
|
47
|
+
reject(error);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
resolve();
|
|
51
|
+
});
|
|
52
|
+
})
|
|
53
|
+
};
|
|
54
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { watch } from 'node:fs';
|
|
2
|
+
import { indexVault } from './index-vault.js';
|
|
3
|
+
import { resolveVaultPath } from '../infrastructure/file-system-vault.js';
|
|
4
|
+
const shouldIgnore = (filename) => {
|
|
5
|
+
if (!filename) {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
return filename.includes('.brainlink') || !filename.endsWith('.md');
|
|
9
|
+
};
|
|
10
|
+
export const startVaultWatcher = (input) => {
|
|
11
|
+
const absoluteVaultPath = resolveVaultPath(input.vaultPath);
|
|
12
|
+
const debounceMs = input.debounceMs ?? 350;
|
|
13
|
+
let timeout = null;
|
|
14
|
+
const schedule = (filename) => {
|
|
15
|
+
if (shouldIgnore(filename)) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (timeout) {
|
|
19
|
+
clearTimeout(timeout);
|
|
20
|
+
}
|
|
21
|
+
timeout = setTimeout(() => {
|
|
22
|
+
indexVault(absoluteVaultPath).then(input.onIndex).catch(input.onError);
|
|
23
|
+
}, debounceMs);
|
|
24
|
+
};
|
|
25
|
+
const watcher = watch(absoluteVaultPath, { recursive: true }, (_eventType, filename) => {
|
|
26
|
+
schedule(filename?.toString() ?? null);
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
close: () => {
|
|
30
|
+
if (timeout) {
|
|
31
|
+
clearTimeout(timeout);
|
|
32
|
+
}
|
|
33
|
+
watcher.close();
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { performance } from 'node:perf_hooks';
|
|
5
|
+
import { indexVault } from '../application/index-vault.js';
|
|
6
|
+
import { searchKnowledge } from '../application/search-knowledge.js';
|
|
7
|
+
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
8
|
+
const parsePositiveInteger = (value, fallback) => {
|
|
9
|
+
const parsed = Number.parseInt(value ?? '', 10);
|
|
10
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
11
|
+
};
|
|
12
|
+
const readStringOption = (args, name) => {
|
|
13
|
+
const index = args.indexOf(name);
|
|
14
|
+
const value = index >= 0 ? args[index + 1] : undefined;
|
|
15
|
+
return value && !value.startsWith('--') ? value : undefined;
|
|
16
|
+
};
|
|
17
|
+
const readOptions = (args) => ({
|
|
18
|
+
notes: parsePositiveInteger(readStringOption(args, '--notes'), 2000),
|
|
19
|
+
agent: readStringOption(args, '--agent') ?? 'benchmark-agent',
|
|
20
|
+
keep: args.includes('--keep')
|
|
21
|
+
});
|
|
22
|
+
const topics = [
|
|
23
|
+
'authentication jwt token refresh policy',
|
|
24
|
+
'sqlite graph backlinks markdown vault indexing',
|
|
25
|
+
'frontend canvas layout graph interaction',
|
|
26
|
+
'agent memory context retrieval summarization',
|
|
27
|
+
'security local server vault path allowlist',
|
|
28
|
+
'operations release npm github actions smoke test',
|
|
29
|
+
'architecture functional core imperative shell boundaries'
|
|
30
|
+
];
|
|
31
|
+
const formatNote = (index) => {
|
|
32
|
+
const topic = topics[index % topics.length];
|
|
33
|
+
const previous = index > 0 ? `[[Benchmark Note ${index - 1}]]` : '';
|
|
34
|
+
const related = index > 7 ? `[[Benchmark Note ${index - 7}]]` : '';
|
|
35
|
+
return [
|
|
36
|
+
`# Benchmark Note ${index}`,
|
|
37
|
+
'',
|
|
38
|
+
`This note captures ${topic}.`,
|
|
39
|
+
`The implementation detail number ${index} repeats ${topic} for semantic retrieval pressure.`,
|
|
40
|
+
`Related: ${[previous, related].filter(Boolean).join(' ')}`,
|
|
41
|
+
'',
|
|
42
|
+
`#benchmark #topic-${index % topics.length}`
|
|
43
|
+
].join('\n');
|
|
44
|
+
};
|
|
45
|
+
const writeBenchmarkVault = async (vaultPath, options) => {
|
|
46
|
+
const agentPath = join(vaultPath, 'agents', options.agent);
|
|
47
|
+
await mkdir(agentPath, { recursive: true, mode: 0o700 });
|
|
48
|
+
await Promise.all(Array.from({ length: options.notes }, (_, index) => writeFile(join(agentPath, `benchmark-note-${index}.md`), formatNote(index), { encoding: 'utf8', mode: 0o600 })));
|
|
49
|
+
};
|
|
50
|
+
const timed = async (name, operation) => {
|
|
51
|
+
const start = performance.now();
|
|
52
|
+
const result = await operation();
|
|
53
|
+
return [name, performance.now() - start, result];
|
|
54
|
+
};
|
|
55
|
+
const printMetric = (name, durationMs) => {
|
|
56
|
+
console.log(`${name}: ${durationMs.toFixed(1)}ms`);
|
|
57
|
+
};
|
|
58
|
+
const main = async () => {
|
|
59
|
+
const options = readOptions(process.argv.slice(2));
|
|
60
|
+
const vaultPath = await mkdtemp(join(tmpdir(), 'brainlink-benchmark-'));
|
|
61
|
+
try {
|
|
62
|
+
await ensureVault(vaultPath);
|
|
63
|
+
const [, writeMs] = await timed('write', () => writeBenchmarkVault(vaultPath, options));
|
|
64
|
+
const [, indexMs, indexResult] = await timed('index', () => indexVault(vaultPath));
|
|
65
|
+
const [, semanticMs, semanticResults] = await timed('semantic search', () => searchKnowledge(vaultPath, 'authentication token policy', 10, options.agent, 'semantic'));
|
|
66
|
+
const [, hybridMs, hybridResults] = await timed('hybrid search', () => searchKnowledge(vaultPath, 'graph retrieval indexing', 10, options.agent, 'hybrid'));
|
|
67
|
+
console.log(`vault: ${vaultPath}`);
|
|
68
|
+
console.log(`notes: ${options.notes}`);
|
|
69
|
+
console.log(`documents: ${indexResult.documentCount}`);
|
|
70
|
+
console.log(`chunks: ${indexResult.chunkCount}`);
|
|
71
|
+
printMetric('write', writeMs);
|
|
72
|
+
printMetric('index', indexMs);
|
|
73
|
+
printMetric('semantic search', semanticMs);
|
|
74
|
+
printMetric('hybrid search', hybridMs);
|
|
75
|
+
console.log(`semantic top: ${semanticResults[0]?.title ?? 'none'}`);
|
|
76
|
+
console.log(`hybrid top: ${hybridResults[0]?.title ?? 'none'}`);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
if (!options.keep) {
|
|
80
|
+
await rm(vaultPath, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
main().catch((error) => {
|
|
85
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
86
|
+
console.error(message);
|
|
87
|
+
process.exitCode = 1;
|
|
88
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { getBrokenLinksReport, getOrphansReport, getStats, validateVault } from '../../application/analyze-vault.js';
|
|
2
|
+
import { buildContextPackage } from '../../application/build-context.js';
|
|
3
|
+
import { getGraph } from '../../application/get-graph.js';
|
|
4
|
+
import { listAgents } from '../../application/list-agents.js';
|
|
5
|
+
import { listBacklinks, listLinks } from '../../application/list-links.js';
|
|
6
|
+
import { searchKnowledge } from '../../application/search-knowledge.js';
|
|
7
|
+
import { sanitizeSearchMode } from '../../infrastructure/config.js';
|
|
8
|
+
import { parsePositiveInteger, print, resolveOptions } from '../runtime.js';
|
|
9
|
+
export const registerReadCommands = (program) => {
|
|
10
|
+
program
|
|
11
|
+
.command('search')
|
|
12
|
+
.argument('<query>', 'search query')
|
|
13
|
+
.option('-v, --vault <vault>', 'vault directory', '.')
|
|
14
|
+
.option('-a, --agent <agent>', 'filter by agent memory namespace')
|
|
15
|
+
.option('-l, --limit <limit>', 'maximum results', '10')
|
|
16
|
+
.option('-m, --mode <mode>', 'search mode: fts, semantic or hybrid')
|
|
17
|
+
.option('--json', 'print machine-readable JSON')
|
|
18
|
+
.description('search indexed knowledge')
|
|
19
|
+
.action(async (query, options) => {
|
|
20
|
+
const resolved = await resolveOptions(options);
|
|
21
|
+
const limit = parsePositiveInteger(options.limit ?? String(resolved.config.defaultSearchLimit), resolved.config.defaultSearchLimit);
|
|
22
|
+
const mode = sanitizeSearchMode(options.mode, resolved.config.defaultSearchMode);
|
|
23
|
+
const results = await searchKnowledge(resolved.vault, query, limit, options.agent, mode);
|
|
24
|
+
print(options.json, { query, agent: options.agent, limit, mode, results }, () => results
|
|
25
|
+
.map((result, index) => [`${index + 1}. ${result.title} (${result.path}) score=${result.score.toFixed(3)} mode=${result.searchMode}`, result.content].join('\n'))
|
|
26
|
+
.join('\n\n'));
|
|
27
|
+
});
|
|
28
|
+
program
|
|
29
|
+
.command('links')
|
|
30
|
+
.option('-v, --vault <vault>', 'vault directory', '.')
|
|
31
|
+
.option('-a, --agent <agent>', 'filter by agent memory namespace')
|
|
32
|
+
.option('--json', 'print machine-readable JSON')
|
|
33
|
+
.description('list indexed wiki links')
|
|
34
|
+
.action(async (options) => {
|
|
35
|
+
const resolved = await resolveOptions(options);
|
|
36
|
+
const links = await listLinks(resolved.vault, options.agent);
|
|
37
|
+
print(options.json, { links }, () => links
|
|
38
|
+
.map((link) => {
|
|
39
|
+
const target = link.toPath ? `${link.toTitle} (${link.toPath})` : `${link.toTitle} (unresolved)`;
|
|
40
|
+
return `${link.fromTitle} (${link.fromPath}) -> ${target}`;
|
|
41
|
+
})
|
|
42
|
+
.join('\n'));
|
|
43
|
+
});
|
|
44
|
+
program
|
|
45
|
+
.command('backlinks')
|
|
46
|
+
.argument('<title>', 'target note title')
|
|
47
|
+
.option('-v, --vault <vault>', 'vault directory', '.')
|
|
48
|
+
.option('-a, --agent <agent>', 'filter by agent memory namespace')
|
|
49
|
+
.option('--json', 'print machine-readable JSON')
|
|
50
|
+
.description('list notes linking to a target note')
|
|
51
|
+
.action(async (title, options) => {
|
|
52
|
+
const resolved = await resolveOptions(options);
|
|
53
|
+
const backlinks = await listBacklinks(resolved.vault, title, options.agent);
|
|
54
|
+
print(options.json, { title, backlinks }, () => backlinks.map((link) => `${link.fromTitle} (${link.fromPath}) -> ${link.toTitle}`).join('\n'));
|
|
55
|
+
});
|
|
56
|
+
program
|
|
57
|
+
.command('context')
|
|
58
|
+
.argument('<query>', 'context query')
|
|
59
|
+
.option('-v, --vault <vault>', 'vault directory', '.')
|
|
60
|
+
.option('-a, --agent <agent>', 'filter by agent memory namespace')
|
|
61
|
+
.option('-l, --limit <limit>', 'maximum search results before context selection', '12')
|
|
62
|
+
.option('-t, --tokens <tokens>', 'maximum estimated context tokens', '2000')
|
|
63
|
+
.option('-m, --mode <mode>', 'search mode: fts, semantic or hybrid')
|
|
64
|
+
.option('--json', 'print machine-readable JSON')
|
|
65
|
+
.description('build a compact context package for an agent')
|
|
66
|
+
.action(async (query, options) => {
|
|
67
|
+
const resolved = await resolveOptions(options);
|
|
68
|
+
const mode = sanitizeSearchMode(options.mode, resolved.config.defaultSearchMode);
|
|
69
|
+
const contextPackage = await buildContextPackage(resolved.vault, query, parsePositiveInteger(options.limit ?? '12', 12), parsePositiveInteger(options.tokens ?? String(resolved.config.defaultContextTokens), resolved.config.defaultContextTokens), options.agent, mode);
|
|
70
|
+
print(options.json, contextPackage, () => contextPackage.content);
|
|
71
|
+
});
|
|
72
|
+
program
|
|
73
|
+
.command('graph')
|
|
74
|
+
.option('-v, --vault <vault>', 'vault directory', '.')
|
|
75
|
+
.option('-a, --agent <agent>', 'filter by agent memory namespace')
|
|
76
|
+
.option('--json', 'print machine-readable JSON')
|
|
77
|
+
.description('print indexed graph data')
|
|
78
|
+
.action(async (options) => {
|
|
79
|
+
const resolved = await resolveOptions(options);
|
|
80
|
+
const graph = await getGraph(resolved.vault, options.agent);
|
|
81
|
+
print(options.json, graph, () => JSON.stringify(graph, null, 2));
|
|
82
|
+
});
|
|
83
|
+
program
|
|
84
|
+
.command('agents')
|
|
85
|
+
.option('-v, --vault <vault>', 'vault directory', '.')
|
|
86
|
+
.option('--json', 'print machine-readable JSON')
|
|
87
|
+
.description('list indexed agent memory namespaces')
|
|
88
|
+
.action(async (options) => {
|
|
89
|
+
const resolved = await resolveOptions(options);
|
|
90
|
+
const agents = await listAgents(resolved.vault);
|
|
91
|
+
print(options.json, { agents }, () => agents.map((agent) => `${agent.id}: ${agent.documentCount} documents`).join('\n'));
|
|
92
|
+
});
|
|
93
|
+
program
|
|
94
|
+
.command('stats')
|
|
95
|
+
.option('-v, --vault <vault>', 'vault directory', '.')
|
|
96
|
+
.option('-a, --agent <agent>', 'filter by agent memory namespace')
|
|
97
|
+
.option('--json', 'print machine-readable JSON')
|
|
98
|
+
.description('print indexed vault statistics')
|
|
99
|
+
.action(async (options) => {
|
|
100
|
+
const resolved = await resolveOptions(options);
|
|
101
|
+
const stats = await getStats(resolved.vault, options.agent);
|
|
102
|
+
print(options.json, stats, () => [
|
|
103
|
+
`Documents: ${stats.documentCount}`,
|
|
104
|
+
`Links: ${stats.linkCount}`,
|
|
105
|
+
`Resolved links: ${stats.resolvedLinkCount}`,
|
|
106
|
+
`Broken links: ${stats.brokenLinkCount}`,
|
|
107
|
+
`Orphans: ${stats.orphanCount}`,
|
|
108
|
+
`Tags: ${stats.tagCount}`
|
|
109
|
+
].join('\n'));
|
|
110
|
+
});
|
|
111
|
+
program
|
|
112
|
+
.command('broken-links')
|
|
113
|
+
.option('-v, --vault <vault>', 'vault directory', '.')
|
|
114
|
+
.option('-a, --agent <agent>', 'filter by agent memory namespace')
|
|
115
|
+
.option('--json', 'print machine-readable JSON')
|
|
116
|
+
.description('list unresolved wiki links')
|
|
117
|
+
.action(async (options) => {
|
|
118
|
+
const resolved = await resolveOptions(options);
|
|
119
|
+
const brokenLinks = await getBrokenLinksReport(resolved.vault, options.agent);
|
|
120
|
+
print(options.json, { brokenLinks }, () => brokenLinks.length === 0
|
|
121
|
+
? 'No broken links found'
|
|
122
|
+
: brokenLinks.map((link) => `${link.fromTitle} (${link.fromPath}) -> ${link.toTitle}`).join('\n'));
|
|
123
|
+
});
|
|
124
|
+
program
|
|
125
|
+
.command('orphans')
|
|
126
|
+
.option('-v, --vault <vault>', 'vault directory', '.')
|
|
127
|
+
.option('-a, --agent <agent>', 'filter by agent memory namespace')
|
|
128
|
+
.option('--json', 'print machine-readable JSON')
|
|
129
|
+
.description('list indexed notes without incoming or outgoing links')
|
|
130
|
+
.action(async (options) => {
|
|
131
|
+
const resolved = await resolveOptions(options);
|
|
132
|
+
const orphans = await getOrphansReport(resolved.vault, options.agent);
|
|
133
|
+
print(options.json, { orphans }, () => orphans.length === 0 ? 'No orphan notes found' : orphans.map((node) => `${node.title} (${node.path})`).join('\n'));
|
|
134
|
+
});
|
|
135
|
+
program
|
|
136
|
+
.command('validate')
|
|
137
|
+
.option('-v, --vault <vault>', 'vault directory', '.')
|
|
138
|
+
.option('-a, --agent <agent>', 'filter by agent memory namespace')
|
|
139
|
+
.option('--json', 'print machine-readable JSON')
|
|
140
|
+
.description('validate indexed vault graph health')
|
|
141
|
+
.action(async (options) => {
|
|
142
|
+
const resolved = await resolveOptions(options);
|
|
143
|
+
const validation = await validateVault(resolved.vault, options.agent);
|
|
144
|
+
print(options.json, validation, () => validation.ok
|
|
145
|
+
? 'Vault validation passed'
|
|
146
|
+
: `Vault validation failed: ${validation.brokenLinks.length} broken links, ${validation.orphans.length} orphan notes`);
|
|
147
|
+
process.exitCode = validation.ok ? 0 : 1;
|
|
148
|
+
});
|
|
149
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { addNote } from '../../application/add-note.js';
|
|
2
|
+
import { indexVault } from '../../application/index-vault.js';
|
|
3
|
+
import { startServer } from '../../application/start-server.js';
|
|
4
|
+
import { startVaultWatcher } from '../../application/watch-vault.js';
|
|
5
|
+
import { doctorVault } from '../../application/analyze-vault.js';
|
|
6
|
+
import { loadBrainlinkConfig } from '../../infrastructure/config.js';
|
|
7
|
+
import { assertVaultAllowed, ensureVault } from '../../infrastructure/file-system-vault.js';
|
|
8
|
+
import { parsePositiveInteger, print, resolveOptions } from '../runtime.js';
|
|
9
|
+
export const registerWriteCommands = (program) => {
|
|
10
|
+
program
|
|
11
|
+
.command('init')
|
|
12
|
+
.argument('[vault]', 'vault directory', '.')
|
|
13
|
+
.option('--json', 'print machine-readable JSON')
|
|
14
|
+
.description('initialize a Brainlink vault')
|
|
15
|
+
.action(async (vault, options) => {
|
|
16
|
+
const config = await loadBrainlinkConfig();
|
|
17
|
+
const path = await ensureVault(assertVaultAllowed(vault, config.allowedVaults));
|
|
18
|
+
print(options.json, { path }, () => `Initialized Brainlink vault at ${path}`);
|
|
19
|
+
});
|
|
20
|
+
program
|
|
21
|
+
.command('add')
|
|
22
|
+
.argument('<title>', 'note title')
|
|
23
|
+
.requiredOption('-c, --content <content>', 'markdown content')
|
|
24
|
+
.option('-v, --vault <vault>', 'vault directory', '.')
|
|
25
|
+
.option('-a, --agent <agent>', 'agent memory namespace', 'shared')
|
|
26
|
+
.option('--allow-sensitive', 'allow writing content that looks like a secret')
|
|
27
|
+
.option('--json', 'print machine-readable JSON')
|
|
28
|
+
.description('add a markdown note to the vault')
|
|
29
|
+
.action(async (title, options) => {
|
|
30
|
+
const resolved = await resolveOptions(options);
|
|
31
|
+
const path = await addNote(resolved.vault, title, options.content, options.agent, {
|
|
32
|
+
allowSensitive: Boolean(options.allowSensitive)
|
|
33
|
+
});
|
|
34
|
+
print(options.json, { title, agent: options.agent ?? 'shared', path }, () => `Created note at ${path}`);
|
|
35
|
+
});
|
|
36
|
+
program
|
|
37
|
+
.command('index')
|
|
38
|
+
.option('-v, --vault <vault>', 'vault directory', '.')
|
|
39
|
+
.option('--json', 'print machine-readable JSON')
|
|
40
|
+
.description('index markdown notes, links, tags and chunks')
|
|
41
|
+
.action(async (options) => {
|
|
42
|
+
const resolved = await resolveOptions(options);
|
|
43
|
+
const result = await indexVault(resolved.vault);
|
|
44
|
+
print(options.json, result, () => `Indexed ${result.documentCount} documents, ${result.chunkCount} chunks and ${result.linkCount} links`);
|
|
45
|
+
});
|
|
46
|
+
program
|
|
47
|
+
.command('doctor')
|
|
48
|
+
.option('-v, --vault <vault>', 'vault directory', '.')
|
|
49
|
+
.option('--json', 'print machine-readable JSON')
|
|
50
|
+
.description('run Brainlink environment and vault checks')
|
|
51
|
+
.action(async (options) => {
|
|
52
|
+
const resolved = await resolveOptions(options);
|
|
53
|
+
const report = await doctorVault(resolved.vault);
|
|
54
|
+
print(options.json, report, () => report.checks.map((check) => `${check.ok ? 'OK' : 'FAIL'} ${check.name}: ${check.message}`).join('\n'));
|
|
55
|
+
process.exitCode = report.ok ? 0 : 1;
|
|
56
|
+
});
|
|
57
|
+
program
|
|
58
|
+
.command('watch')
|
|
59
|
+
.option('-v, --vault <vault>', 'vault directory', '.')
|
|
60
|
+
.option('--json', 'print machine-readable JSON events')
|
|
61
|
+
.description('watch markdown files and reindex on changes')
|
|
62
|
+
.action(async (options) => {
|
|
63
|
+
const resolved = await resolveOptions(options);
|
|
64
|
+
const initial = await indexVault(resolved.vault);
|
|
65
|
+
const watcher = startVaultWatcher({
|
|
66
|
+
vaultPath: resolved.vault,
|
|
67
|
+
onIndex: (result) => {
|
|
68
|
+
print(options.json, { event: 'indexed', result }, () => `Indexed ${result.documentCount} documents, ${result.chunkCount} chunks and ${result.linkCount} links`);
|
|
69
|
+
},
|
|
70
|
+
onError: (error) => {
|
|
71
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
72
|
+
print(options.json, { event: 'error', message }, () => message);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
print(options.json, { event: 'watching', vault: resolved.vault, initial }, () => `Watching ${resolved.vault}`);
|
|
76
|
+
process.once('SIGINT', () => {
|
|
77
|
+
watcher.close();
|
|
78
|
+
process.exit(0);
|
|
79
|
+
});
|
|
80
|
+
process.once('SIGTERM', () => {
|
|
81
|
+
watcher.close();
|
|
82
|
+
process.exit(0);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
program
|
|
86
|
+
.command('server')
|
|
87
|
+
.option('-v, --vault <vault>', 'vault directory', '.')
|
|
88
|
+
.option('-h, --host <host>', 'server host', '127.0.0.1')
|
|
89
|
+
.option('-p, --port <port>', 'server port', '4321')
|
|
90
|
+
.option('--no-index', 'skip indexing before starting the server')
|
|
91
|
+
.option('-w, --watch', 'watch markdown files and reindex on changes')
|
|
92
|
+
.option('--allow-public', 'allow binding the server to a non-loopback host')
|
|
93
|
+
.option('--json', 'print machine-readable JSON')
|
|
94
|
+
.description('start a local web UI for the knowledge graph')
|
|
95
|
+
.action(async (options) => {
|
|
96
|
+
const resolved = await resolveOptions(options);
|
|
97
|
+
const server = await startServer({
|
|
98
|
+
vaultPath: resolved.vault,
|
|
99
|
+
host: options.host ?? resolved.config.host,
|
|
100
|
+
port: parsePositiveInteger(options.port ?? String(resolved.config.port), resolved.config.port),
|
|
101
|
+
shouldIndex: options.index,
|
|
102
|
+
shouldWatch: Boolean(options.watch),
|
|
103
|
+
allowPublic: Boolean(options.allowPublic)
|
|
104
|
+
});
|
|
105
|
+
print(options.json, { url: server.url, watch: Boolean(options.watch), readonly: true }, () => `Brainlink graph server running at ${server.url}`);
|
|
106
|
+
});
|
|
107
|
+
};
|
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { registerReadCommands } from './commands/read-commands.js';
|
|
5
|
+
import { registerWriteCommands } from './commands/write-commands.js';
|
|
6
|
+
const program = new Command();
|
|
7
|
+
const cliName = basename(process.argv[1] ?? 'brainlink');
|
|
8
|
+
const displayName = cliName === 'blink' ? 'blink' : 'brainlink';
|
|
9
|
+
const aliasName = displayName === 'blink' ? 'brainlink' : 'blink';
|
|
10
|
+
program
|
|
11
|
+
.name(displayName)
|
|
12
|
+
.alias(aliasName)
|
|
13
|
+
.description('Local-first knowledge memory for agents')
|
|
14
|
+
.version('0.1.0');
|
|
15
|
+
registerWriteCommands(program);
|
|
16
|
+
registerReadCommands(program);
|
|
17
|
+
program.parseAsync().catch((error) => {
|
|
18
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
19
|
+
console.error(message);
|
|
20
|
+
process.exitCode = 1;
|
|
21
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { loadBrainlinkConfig } from '../infrastructure/config.js';
|
|
2
|
+
import { assertVaultAllowed } from '../infrastructure/file-system-vault.js';
|
|
3
|
+
export const parsePositiveInteger = (value, fallback) => {
|
|
4
|
+
const parsed = Number.parseInt(value, 10);
|
|
5
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
6
|
+
};
|
|
7
|
+
export const resolveOptions = async (options) => {
|
|
8
|
+
const config = await loadBrainlinkConfig();
|
|
9
|
+
const vault = options.vault ?? config.vault;
|
|
10
|
+
const allowedVault = assertVaultAllowed(vault, config.allowedVaults);
|
|
11
|
+
return {
|
|
12
|
+
config,
|
|
13
|
+
vault: allowedVault
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
export const print = (json, value, human) => {
|
|
17
|
+
console.log(json ? JSON.stringify(value, null, 2) : human());
|
|
18
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const sharedAgentId = 'shared';
|
|
2
|
+
export const sanitizeAgentId = (agentId) => agentId
|
|
3
|
+
.normalize('NFKD')
|
|
4
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
5
|
+
.toLowerCase()
|
|
6
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
7
|
+
.replace(/^-+|-+$/g, '') || sharedAgentId;
|
|
8
|
+
export const resolveAgentIdFromPath = (path) => {
|
|
9
|
+
const [scope, agentId] = path.split('/');
|
|
10
|
+
return scope === 'agents' && agentId ? sanitizeAgentId(agentId) : sharedAgentId;
|
|
11
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const selectContextSections = (results, maxTokens) => {
|
|
2
|
+
const selected = results.reduce((state, result) => {
|
|
3
|
+
const tokenCost = Math.ceil(result.content.length / 4);
|
|
4
|
+
if (state.usedTokens + tokenCost > maxTokens || state.seenDocuments.has(result.documentId)) {
|
|
5
|
+
return state;
|
|
6
|
+
}
|
|
7
|
+
return {
|
|
8
|
+
usedTokens: state.usedTokens + tokenCost,
|
|
9
|
+
sections: [
|
|
10
|
+
...state.sections,
|
|
11
|
+
{
|
|
12
|
+
title: result.title,
|
|
13
|
+
path: result.path,
|
|
14
|
+
content: result.content,
|
|
15
|
+
score: result.score,
|
|
16
|
+
searchMode: result.searchMode,
|
|
17
|
+
tags: result.tags
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
seenDocuments: new Set([...state.seenDocuments, result.documentId])
|
|
21
|
+
};
|
|
22
|
+
}, {
|
|
23
|
+
usedTokens: 0,
|
|
24
|
+
sections: [],
|
|
25
|
+
seenDocuments: new Set()
|
|
26
|
+
});
|
|
27
|
+
return selected.sections;
|
|
28
|
+
};
|
|
29
|
+
export const formatContextPackage = (query, sections) => {
|
|
30
|
+
const body = sections
|
|
31
|
+
.map((section, index) => [
|
|
32
|
+
`## ${index + 1}. ${section.title}`,
|
|
33
|
+
`Source: ${section.path}`,
|
|
34
|
+
section.tags.length > 0 ? `Tags: ${section.tags.map((tag) => `#${tag}`).join(' ')}` : null,
|
|
35
|
+
`Score: ${section.score.toFixed(3)}`,
|
|
36
|
+
`Mode: ${section.searchMode}`,
|
|
37
|
+
'',
|
|
38
|
+
section.content
|
|
39
|
+
]
|
|
40
|
+
.filter((value) => value !== null)
|
|
41
|
+
.join('\n'))
|
|
42
|
+
.join('\n\n');
|
|
43
|
+
return [`# Brainlink Context`, `Query: ${query}`, '', body || 'No relevant context found.'].join('\n');
|
|
44
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const localDimensions = 192;
|
|
2
|
+
const defaultEmbeddingBucketCount = 24;
|
|
3
|
+
const tokenPattern = /[\p{L}\p{N}_-]+/gu;
|
|
4
|
+
const stopWords = new Set([
|
|
5
|
+
'a',
|
|
6
|
+
'as',
|
|
7
|
+
'and',
|
|
8
|
+
'ao',
|
|
9
|
+
'aos',
|
|
10
|
+
'com',
|
|
11
|
+
'da',
|
|
12
|
+
'das',
|
|
13
|
+
'de',
|
|
14
|
+
'do',
|
|
15
|
+
'dos',
|
|
16
|
+
'e',
|
|
17
|
+
'em',
|
|
18
|
+
'for',
|
|
19
|
+
'is',
|
|
20
|
+
'o',
|
|
21
|
+
'os',
|
|
22
|
+
'para',
|
|
23
|
+
'por',
|
|
24
|
+
'que',
|
|
25
|
+
'the',
|
|
26
|
+
'to',
|
|
27
|
+
'um',
|
|
28
|
+
'uma',
|
|
29
|
+
'use',
|
|
30
|
+
'uses',
|
|
31
|
+
'using'
|
|
32
|
+
]);
|
|
33
|
+
const aliases = {
|
|
34
|
+
ai: ['agent', 'model'],
|
|
35
|
+
api: ['interface', 'client'],
|
|
36
|
+
auth: ['authentication', 'authorization', 'identity'],
|
|
37
|
+
authentication: ['auth', 'identity'],
|
|
38
|
+
backend: ['server', 'api'],
|
|
39
|
+
cli: ['terminal', 'command'],
|
|
40
|
+
context: ['memory', 'knowledge'],
|
|
41
|
+
db: ['database', 'storage'],
|
|
42
|
+
frontend: ['ui', 'browser'],
|
|
43
|
+
jwt: ['token', 'auth', 'authentication'],
|
|
44
|
+
llm: ['ai', 'agent', 'model'],
|
|
45
|
+
mcp: ['agent', 'tool', 'integration'],
|
|
46
|
+
memory: ['context', 'knowledge'],
|
|
47
|
+
nodejs: ['node', 'runtime'],
|
|
48
|
+
test: ['tests', 'testing', 'validation'],
|
|
49
|
+
tests: ['test', 'testing', 'validation'],
|
|
50
|
+
token: ['jwt', 'auth'],
|
|
51
|
+
ui: ['frontend', 'browser']
|
|
52
|
+
};
|
|
53
|
+
const normalizeToken = (token) => token
|
|
54
|
+
.normalize('NFKD')
|
|
55
|
+
.replace(/\p{Diacritic}/gu, '')
|
|
56
|
+
.toLowerCase();
|
|
57
|
+
const tokenize = (input) => input
|
|
58
|
+
.match(tokenPattern)
|
|
59
|
+
?.map(normalizeToken)
|
|
60
|
+
.filter((token) => token.length > 1 && !stopWords.has(token)) ?? [];
|
|
61
|
+
const expandTokens = (tokens) => tokens.flatMap((token) => [token, ...(aliases[token] ?? [])]);
|
|
62
|
+
const hash = (value) => Array.from(value).reduce((state, char) => Math.imul(state ^ char.charCodeAt(0), 16777619), 2166136261) >>> 0;
|
|
63
|
+
const featureHash = (feature) => {
|
|
64
|
+
const value = hash(feature);
|
|
65
|
+
const index = value % localDimensions;
|
|
66
|
+
const sign = value & 1 ? 1 : -1;
|
|
67
|
+
return [index, sign];
|
|
68
|
+
};
|
|
69
|
+
const normalizeVector = (vector) => {
|
|
70
|
+
const magnitude = Math.hypot(...vector);
|
|
71
|
+
return magnitude === 0 ? vector : vector.map((value) => value / magnitude);
|
|
72
|
+
};
|
|
73
|
+
const applyFeature = (vector, feature, weight) => {
|
|
74
|
+
const [index, sign] = featureHash(feature);
|
|
75
|
+
vector[index] = (vector[index] ?? 0) + sign * weight;
|
|
76
|
+
return vector;
|
|
77
|
+
};
|
|
78
|
+
const tokenFeatures = (tokens) => [
|
|
79
|
+
...tokens.map((token) => `t:${token}`),
|
|
80
|
+
...tokens.slice(0, -1).map((token, index) => `b:${token}:${tokens[index + 1]}`)
|
|
81
|
+
];
|
|
82
|
+
export const createLocalEmbedding = (input) => {
|
|
83
|
+
const tokens = expandTokens(tokenize(input));
|
|
84
|
+
const initial = Array.from({ length: localDimensions }, () => 0);
|
|
85
|
+
const weighted = tokenFeatures(tokens).reduce((vector, feature) => applyFeature(vector, feature, feature.startsWith('b:') ? 0.65 : 1), initial);
|
|
86
|
+
return normalizeVector(weighted);
|
|
87
|
+
};
|
|
88
|
+
export const cosineSimilarity = (left, right) => {
|
|
89
|
+
const length = Math.min(left.length, right.length);
|
|
90
|
+
if (length === 0) {
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
const dot = left.slice(0, length).reduce((total, value, index) => total + value * (right[index] ?? 0), 0);
|
|
94
|
+
const leftMagnitude = Math.hypot(...left.slice(0, length));
|
|
95
|
+
const rightMagnitude = Math.hypot(...right.slice(0, length));
|
|
96
|
+
return leftMagnitude === 0 || rightMagnitude === 0 ? 0 : dot / (leftMagnitude * rightMagnitude);
|
|
97
|
+
};
|
|
98
|
+
const bucketKey = (index, value) => `${value >= 0 ? 'p' : 'n'}:${index}`;
|
|
99
|
+
export const createEmbeddingBuckets = (vector, bucketCount = defaultEmbeddingBucketCount) => vector
|
|
100
|
+
.map((value, index) => ({
|
|
101
|
+
index,
|
|
102
|
+
value,
|
|
103
|
+
weight: Math.abs(value)
|
|
104
|
+
}))
|
|
105
|
+
.filter((item) => item.weight > 0)
|
|
106
|
+
.sort((left, right) => right.weight - left.weight || left.index - right.index)
|
|
107
|
+
.slice(0, bucketCount)
|
|
108
|
+
.map((item) => bucketKey(item.index, item.value));
|
|
109
|
+
export const createDisabledEmbeddingProvider = () => ({
|
|
110
|
+
name: 'none',
|
|
111
|
+
embed: async (input) => input.map(() => [])
|
|
112
|
+
});
|
|
113
|
+
export const createLocalEmbeddingProvider = () => ({
|
|
114
|
+
name: 'local',
|
|
115
|
+
embed: async (input) => input.map(createLocalEmbedding)
|
|
116
|
+
});
|
|
117
|
+
export const createEmbeddingProvider = (name) => name === 'local' ? createLocalEmbeddingProvider() : createDisabledEmbeddingProvider();
|