@andespindola/brainlink 1.0.3 → 1.0.5
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/README.md +41 -9
- package/dist/application/frontend/client-css.js +34 -0
- package/dist/application/frontend/client-html.js +9 -0
- package/dist/application/frontend/client-js.js +85 -1
- package/dist/application/inbox.js +54 -0
- package/dist/application/memory-suggestions.js +220 -0
- package/dist/application/operational-workflows.js +153 -0
- package/dist/application/repair-broken-links.js +157 -0
- package/dist/application/server/routes.js +20 -9
- package/dist/cli/commands/practical-commands.js +278 -0
- package/dist/cli/commands/read-commands.js +13 -0
- package/dist/cli/commands/write-commands.js +13 -0
- package/dist/cli/main.js +2 -0
- package/dist/infrastructure/config.js +4 -4
- package/dist/mcp/server.js +51 -1
- package/dist/mcp/tools.js +264 -1
- package/docs/AGENT_USAGE.md +15 -4
- package/docs/QUICKSTART.md +14 -1
- package/package.json +1 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { access, readFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { addNoteWithMetadata } from './add-note.js';
|
|
6
|
+
import { doctorVault, getStats, validateVault } from './analyze-vault.js';
|
|
7
|
+
import { indexVault } from './index-vault.js';
|
|
8
|
+
import { buildRememberSuggestion } from './memory-suggestions.js';
|
|
9
|
+
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const readIfExists = async (path) => {
|
|
12
|
+
try {
|
|
13
|
+
await access(path);
|
|
14
|
+
return readFile(path, 'utf8');
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const tryGitStatus = async (cwd) => {
|
|
21
|
+
try {
|
|
22
|
+
const { stdout } = await execFileAsync('git', ['status', '--short'], { cwd });
|
|
23
|
+
return stdout.trim();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
export const buildActionableDoctor = async (vaultPath) => {
|
|
30
|
+
const [doctor, validation] = await Promise.all([
|
|
31
|
+
doctorVault(vaultPath),
|
|
32
|
+
validateVault(vaultPath)
|
|
33
|
+
]);
|
|
34
|
+
const actions = [
|
|
35
|
+
...(doctor.recommendations ?? []).map((recommendation) => ({
|
|
36
|
+
severity: 'warning',
|
|
37
|
+
command: recommendation.startsWith('Vault is empty') ? recommendation.replace(/^Vault is empty\. Add your first note: /, '') : recommendation,
|
|
38
|
+
reason: 'doctor recommendation'
|
|
39
|
+
})),
|
|
40
|
+
...(validation.brokenLinks.length > 0
|
|
41
|
+
? [{
|
|
42
|
+
severity: 'warning',
|
|
43
|
+
command: `blink repair-links --vault "${vaultPath}"`,
|
|
44
|
+
reason: `${validation.brokenLinks.length} unresolved wiki links can be safely repaired or materialized as placeholder targets`
|
|
45
|
+
}]
|
|
46
|
+
: []),
|
|
47
|
+
...(validation.orphans.length > 0
|
|
48
|
+
? [{
|
|
49
|
+
severity: 'info',
|
|
50
|
+
command: `blink suggest-links --vault "${vaultPath}"`,
|
|
51
|
+
reason: `${validation.orphans.length} notes have no incoming or outgoing links`
|
|
52
|
+
}]
|
|
53
|
+
: []),
|
|
54
|
+
{
|
|
55
|
+
severity: doctor.ok ? 'info' : 'critical',
|
|
56
|
+
command: `blink index --vault "${vaultPath}"`,
|
|
57
|
+
reason: 'keep derived index synchronized with Markdown source'
|
|
58
|
+
}
|
|
59
|
+
];
|
|
60
|
+
return {
|
|
61
|
+
doctor,
|
|
62
|
+
validation,
|
|
63
|
+
actions
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
export const closeSession = async (input) => {
|
|
67
|
+
const [stats, validation, gitStatus] = await Promise.all([
|
|
68
|
+
getStats(input.vaultPath, input.agentId),
|
|
69
|
+
validateVault(input.vaultPath, input.agentId),
|
|
70
|
+
tryGitStatus(input.cwd)
|
|
71
|
+
]);
|
|
72
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
73
|
+
const title = `Session Close ${basename(input.cwd)} ${date}`;
|
|
74
|
+
const content = [
|
|
75
|
+
input.content?.trim() ? `## Notes\n\n${input.content.trim()}` : null,
|
|
76
|
+
'## Vault State',
|
|
77
|
+
`- Documents: ${stats.documentCount}`,
|
|
78
|
+
`- Links: ${stats.linkCount}`,
|
|
79
|
+
`- Broken links: ${stats.brokenLinkCount}`,
|
|
80
|
+
`- Orphans: ${stats.orphanCount}`,
|
|
81
|
+
gitStatus ? `\n## Git Status\n\n\`\`\`txt\n${gitStatus}\n\`\`\`` : null,
|
|
82
|
+
validation.brokenLinks.length > 0
|
|
83
|
+
? `\n## Follow Up\n\n- Review unresolved links with \`blink suggest-links --broken\`.`
|
|
84
|
+
: null,
|
|
85
|
+
'\n#session #handoff'
|
|
86
|
+
].filter((part) => Boolean(part)).join('\n');
|
|
87
|
+
const suggestion = await buildRememberSuggestion({
|
|
88
|
+
vaultPath: input.vaultPath,
|
|
89
|
+
content,
|
|
90
|
+
agentId: input.agentId,
|
|
91
|
+
title,
|
|
92
|
+
tags: ['session', 'handoff'],
|
|
93
|
+
linkLimit: 5
|
|
94
|
+
});
|
|
95
|
+
if (input.write === false) {
|
|
96
|
+
return {
|
|
97
|
+
title,
|
|
98
|
+
content: suggestion.content,
|
|
99
|
+
writtenPath: null,
|
|
100
|
+
index: null
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const note = await addNoteWithMetadata(input.vaultPath, title, suggestion.content, input.agentId, {
|
|
104
|
+
autoContextLinks: false
|
|
105
|
+
});
|
|
106
|
+
const index = input.autoIndex === false ? null : await indexVault(input.vaultPath);
|
|
107
|
+
return {
|
|
108
|
+
title,
|
|
109
|
+
content: suggestion.content,
|
|
110
|
+
writtenPath: note.path,
|
|
111
|
+
index
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
export const initializeProjectMemory = async (input) => {
|
|
115
|
+
const vault = await ensureVault(input.vaultPath);
|
|
116
|
+
const candidates = ['AGENTS.md', 'README.md', 'docs/ARCHITECTURE.md', 'docs/QUICKSTART.md'];
|
|
117
|
+
const contents = await Promise.all(candidates.map(async (relativePath) => ({
|
|
118
|
+
relativePath,
|
|
119
|
+
content: await readIfExists(join(input.projectPath, relativePath))
|
|
120
|
+
})));
|
|
121
|
+
const available = contents.filter((item) => item.content !== null);
|
|
122
|
+
const fallback = available.length > 0
|
|
123
|
+
? []
|
|
124
|
+
: [{
|
|
125
|
+
relativePath: 'Project Overview',
|
|
126
|
+
content: `Project path: ${input.projectPath}\n\n#project #memory`
|
|
127
|
+
}];
|
|
128
|
+
const notes = await Promise.all([...available, ...fallback].map(async (item) => {
|
|
129
|
+
const title = `Project ${basename(input.projectPath)} ${item.relativePath.replace(/\.md$/i, '').replace(/[\\/]/g, ' ')}`;
|
|
130
|
+
const suggestion = await buildRememberSuggestion({
|
|
131
|
+
vaultPath: input.vaultPath,
|
|
132
|
+
content: `${item.content.trim()}\n\n#project #memory`,
|
|
133
|
+
agentId: input.agentId,
|
|
134
|
+
title,
|
|
135
|
+
tags: ['project', 'memory'],
|
|
136
|
+
linkLimit: 5
|
|
137
|
+
});
|
|
138
|
+
const note = await addNoteWithMetadata(input.vaultPath, title, suggestion.content, input.agentId, {
|
|
139
|
+
autoContextLinks: false
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
title,
|
|
143
|
+
path: note.path
|
|
144
|
+
};
|
|
145
|
+
}));
|
|
146
|
+
const index = await indexVault(input.vaultPath);
|
|
147
|
+
return {
|
|
148
|
+
projectPath: input.projectPath,
|
|
149
|
+
vault,
|
|
150
|
+
notes,
|
|
151
|
+
index
|
|
152
|
+
};
|
|
153
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
|
|
4
|
+
import { parseMarkdownDocument } from '../domain/markdown.js';
|
|
5
|
+
import { ensureVault, readMarkdownFileSummaries, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
|
|
6
|
+
import { indexVault } from './index-vault.js';
|
|
7
|
+
import { suggestBrokenLinkFixes } from './memory-suggestions.js';
|
|
8
|
+
const slugify = (title) => title
|
|
9
|
+
.normalize('NFKD')
|
|
10
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
13
|
+
.replace(/^-+|-+$/g, '') || 'untitled';
|
|
14
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
15
|
+
const replaceWikiLinkTitle = (content, fromTitle, toTitle) => {
|
|
16
|
+
const pattern = new RegExp(`\\[\\[${escapeRegExp(fromTitle)}((?:#[^\\]|]+)?(?:\\|[^\\]]+)?)\\]\\]`, 'g');
|
|
17
|
+
return content.replace(pattern, `[[${toTitle}$1]]`);
|
|
18
|
+
};
|
|
19
|
+
const buildPlaceholderNote = (title, sources, agentId) => [
|
|
20
|
+
'---',
|
|
21
|
+
`title: "${title.replaceAll('"', '\\"')}"`,
|
|
22
|
+
`agent: "${sanitizeAgentId(agentId)}"`,
|
|
23
|
+
'---',
|
|
24
|
+
'',
|
|
25
|
+
`# ${title}`,
|
|
26
|
+
'',
|
|
27
|
+
'Auto-created target for unresolved Brainlink wiki links. Replace this placeholder with durable context when the concept is clarified. #triage',
|
|
28
|
+
'',
|
|
29
|
+
'## Referenced By',
|
|
30
|
+
'',
|
|
31
|
+
...sources.map((source) => `- [[${source}]]`),
|
|
32
|
+
''
|
|
33
|
+
].join('\n');
|
|
34
|
+
const readNotes = async (vaultPath) => {
|
|
35
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
36
|
+
const summaries = await readMarkdownFileSummaries(absoluteVaultPath);
|
|
37
|
+
return Promise.all(summaries.map(async (summary) => {
|
|
38
|
+
const content = await readFile(summary.absolutePath, 'utf8');
|
|
39
|
+
const document = parseMarkdownDocument({
|
|
40
|
+
absolutePath: summary.absolutePath,
|
|
41
|
+
vaultPath: absoluteVaultPath,
|
|
42
|
+
content,
|
|
43
|
+
createdAt: summary.createdAt,
|
|
44
|
+
updatedAt: summary.updatedAt
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
relativePath: summary.relativePath,
|
|
48
|
+
title: document.title,
|
|
49
|
+
agentId: document.agentId,
|
|
50
|
+
content
|
|
51
|
+
};
|
|
52
|
+
}));
|
|
53
|
+
};
|
|
54
|
+
const isConfident = (candidates, minScore, margin) => {
|
|
55
|
+
const [best, second] = candidates;
|
|
56
|
+
if (!best || best.score < minScore)
|
|
57
|
+
return false;
|
|
58
|
+
if (!second)
|
|
59
|
+
return true;
|
|
60
|
+
return best.score - second.score >= margin;
|
|
61
|
+
};
|
|
62
|
+
export const repairBrokenLinks = async (vaultPath, options = {}) => {
|
|
63
|
+
const minScore = options.minScore ?? 0.88;
|
|
64
|
+
const margin = options.margin ?? 0.12;
|
|
65
|
+
const createMissing = options.createMissing !== false;
|
|
66
|
+
const suggestions = await suggestBrokenLinkFixes(vaultPath, options.agentId, 5);
|
|
67
|
+
const notes = await readNotes(vaultPath);
|
|
68
|
+
const notesByPath = new Map(notes.map((note) => [note.relativePath, note]));
|
|
69
|
+
const nextContentByPath = new Map();
|
|
70
|
+
const placeholderSources = new Map();
|
|
71
|
+
const entries = [];
|
|
72
|
+
for (const brokenLink of suggestions) {
|
|
73
|
+
const candidates = brokenLink.candidates;
|
|
74
|
+
const best = candidates[0];
|
|
75
|
+
const source = notesByPath.get(brokenLink.fromPath);
|
|
76
|
+
if (best && isConfident(candidates, minScore, margin)) {
|
|
77
|
+
const currentContent = nextContentByPath.get(brokenLink.fromPath) ?? source?.content;
|
|
78
|
+
if (!source || currentContent == null) {
|
|
79
|
+
entries.push({
|
|
80
|
+
...brokenLink,
|
|
81
|
+
action: 'skipped',
|
|
82
|
+
targetTitle: null,
|
|
83
|
+
targetPath: null,
|
|
84
|
+
score: best.score,
|
|
85
|
+
reason: 'source note was not found'
|
|
86
|
+
});
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const nextContent = replaceWikiLinkTitle(currentContent, brokenLink.toTitle, best.title);
|
|
90
|
+
if (nextContent !== currentContent) {
|
|
91
|
+
nextContentByPath.set(brokenLink.fromPath, nextContent);
|
|
92
|
+
}
|
|
93
|
+
entries.push({
|
|
94
|
+
...brokenLink,
|
|
95
|
+
action: 'retargeted',
|
|
96
|
+
targetTitle: best.title,
|
|
97
|
+
targetPath: best.path,
|
|
98
|
+
score: best.score,
|
|
99
|
+
reason: 'single high-confidence existing title match'
|
|
100
|
+
});
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (createMissing) {
|
|
104
|
+
const sourceTitles = placeholderSources.get(brokenLink.toTitle) ?? new Set();
|
|
105
|
+
sourceTitles.add(brokenLink.fromTitle);
|
|
106
|
+
placeholderSources.set(brokenLink.toTitle, sourceTitles);
|
|
107
|
+
entries.push({
|
|
108
|
+
...brokenLink,
|
|
109
|
+
action: 'created-target',
|
|
110
|
+
targetTitle: brokenLink.toTitle,
|
|
111
|
+
targetPath: null,
|
|
112
|
+
score: best?.score ?? null,
|
|
113
|
+
reason: best ? 'no unambiguous existing target; placeholder target will be created' : 'no existing target candidate; placeholder target will be created'
|
|
114
|
+
});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
entries.push({
|
|
118
|
+
...brokenLink,
|
|
119
|
+
action: 'skipped',
|
|
120
|
+
targetTitle: null,
|
|
121
|
+
targetPath: null,
|
|
122
|
+
score: best?.score ?? null,
|
|
123
|
+
reason: best ? 'candidate score or margin was too low' : 'no candidate found'
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (!options.dryRun) {
|
|
127
|
+
await Promise.all(Array.from(nextContentByPath.entries()).map(([relativePath, content]) => writeMarkdownFile(vaultPath, relativePath, content)));
|
|
128
|
+
await Promise.all(Array.from(placeholderSources.entries()).map(([title, sources]) => {
|
|
129
|
+
const agentId = sanitizeAgentId(options.agentId ?? sharedAgentId);
|
|
130
|
+
const path = join('agents', agentId, 'unresolved-links', `${slugify(title)}.md`).replaceAll('\\', '/');
|
|
131
|
+
return writeMarkdownFile(vaultPath, path, buildPlaceholderNote(title, Array.from(sources), agentId));
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
const changed = nextContentByPath.size;
|
|
135
|
+
const created = placeholderSources.size;
|
|
136
|
+
const index = !options.dryRun && options.autoIndex !== false && (changed > 0 || created > 0)
|
|
137
|
+
? await indexVault(vaultPath)
|
|
138
|
+
: undefined;
|
|
139
|
+
return {
|
|
140
|
+
dryRun: options.dryRun === true,
|
|
141
|
+
scanned: suggestions.length,
|
|
142
|
+
changed,
|
|
143
|
+
created,
|
|
144
|
+
skipped: entries.filter((entry) => entry.action === 'skipped').length,
|
|
145
|
+
entries: entries.map((entry) => {
|
|
146
|
+
if (entry.action !== 'created-target' || entry.targetPath) {
|
|
147
|
+
return entry;
|
|
148
|
+
}
|
|
149
|
+
const agentId = sanitizeAgentId(options.agentId ?? sharedAgentId);
|
|
150
|
+
return {
|
|
151
|
+
...entry,
|
|
152
|
+
targetPath: join('agents', agentId, 'unresolved-links', `${slugify(entry.toTitle)}.md`).replaceAll('\\', '/')
|
|
153
|
+
};
|
|
154
|
+
}),
|
|
155
|
+
...(index ? { index } : {})
|
|
156
|
+
};
|
|
157
|
+
};
|
|
@@ -13,6 +13,7 @@ import { deleteGraphViewState, getGraphViewState, saveGraphViewState } from '../
|
|
|
13
13
|
import { importFile } from '../import-file.js';
|
|
14
14
|
import { listAgents } from '../list-agents.js';
|
|
15
15
|
import { listBacklinks, listLinks } from '../list-links.js';
|
|
16
|
+
import { suggestContextLinks } from '../memory-suggestions.js';
|
|
16
17
|
import { searchGraphNodeIds } from '../search-graph-node-ids.js';
|
|
17
18
|
import { searchKnowledge } from '../search-knowledge.js';
|
|
18
19
|
import { loadBrainlinkConfig, resolveAgentRuntimeDefaults, sanitizeContextStrategy, sanitizeSearchMode } from '../../infrastructure/config.js';
|
|
@@ -23,19 +24,20 @@ import { createClientWorkerJs } from '../frontend/client-worker-js.js';
|
|
|
23
24
|
import { createClientRenderWorkerJs } from '../frontend/client-render-worker-js.js';
|
|
24
25
|
import { contentTypes, createJsonResponse, isReadMethod, parsePositiveInteger } from './http.js';
|
|
25
26
|
import { parseMultipartForm } from './multipart.js';
|
|
26
|
-
const
|
|
27
|
+
const readRuntimeDefaults = async (url) => {
|
|
27
28
|
const config = await loadBrainlinkConfig();
|
|
28
|
-
|
|
29
|
+
return resolveAgentRuntimeDefaults(config, readAgentQuery(url));
|
|
30
|
+
};
|
|
31
|
+
const readSearchMode = async (url) => {
|
|
32
|
+
const defaults = await readRuntimeDefaults(url);
|
|
29
33
|
return sanitizeSearchMode(url.searchParams.get('mode'), defaults.defaultSearchMode);
|
|
30
34
|
};
|
|
31
35
|
const readContextStrategy = async (url) => {
|
|
32
|
-
const
|
|
33
|
-
const defaults = resolveAgentRuntimeDefaults(config, readAgentQuery(url));
|
|
36
|
+
const defaults = await readRuntimeDefaults(url);
|
|
34
37
|
return sanitizeContextStrategy(url.searchParams.get('strategy'), defaults.defaultContextStrategy);
|
|
35
38
|
};
|
|
36
39
|
const readContextCacheTtlMs = async (url) => {
|
|
37
|
-
const
|
|
38
|
-
const defaults = resolveAgentRuntimeDefaults(config, readAgentQuery(url));
|
|
40
|
+
const defaults = await readRuntimeDefaults(url);
|
|
39
41
|
return defaults.defaultContextCacheTtlMs;
|
|
40
42
|
};
|
|
41
43
|
const hasInvalidSearchMode = (url) => {
|
|
@@ -427,7 +429,8 @@ export const route = async (request, url, vaultPath) => {
|
|
|
427
429
|
}
|
|
428
430
|
if (isReadMethod(request) && url.pathname === '/api/search') {
|
|
429
431
|
const query = url.searchParams.get('q') ?? '';
|
|
430
|
-
const
|
|
432
|
+
const defaults = await readRuntimeDefaults(url);
|
|
433
|
+
const limit = parsePositiveInteger(url.searchParams.get('limit'), defaults.defaultSearchLimit);
|
|
431
434
|
const mode = await readSearchMode(url);
|
|
432
435
|
if (hasInvalidSearchMode(url)) {
|
|
433
436
|
return createResponse(createJsonResponse({ error: 'Invalid mode. Use fts, semantic or hybrid.' }), 400, contentTypes['.json']);
|
|
@@ -436,8 +439,9 @@ export const route = async (request, url, vaultPath) => {
|
|
|
436
439
|
}
|
|
437
440
|
if (isReadMethod(request) && url.pathname === '/api/context') {
|
|
438
441
|
const query = url.searchParams.get('q') ?? '';
|
|
439
|
-
const
|
|
440
|
-
const
|
|
442
|
+
const defaults = await readRuntimeDefaults(url);
|
|
443
|
+
const limit = parsePositiveInteger(url.searchParams.get('limit'), defaults.defaultSearchLimit);
|
|
444
|
+
const tokens = parsePositiveInteger(url.searchParams.get('tokens'), defaults.defaultContextTokens);
|
|
441
445
|
const mode = await readSearchMode(url);
|
|
442
446
|
const strategy = await readContextStrategy(url);
|
|
443
447
|
const contextCacheTtlMs = await readContextCacheTtlMs(url);
|
|
@@ -456,6 +460,13 @@ export const route = async (request, url, vaultPath) => {
|
|
|
456
460
|
const title = url.searchParams.get('title') ?? '';
|
|
457
461
|
return createResponse(createJsonResponse({ title, backlinks: await listBacklinks(vaultPath, title, readAgentQuery(url)) }), 200, contentTypes['.json']);
|
|
458
462
|
}
|
|
463
|
+
if (isReadMethod(request) && url.pathname === '/api/suggest-links') {
|
|
464
|
+
const content = url.searchParams.get('content') ?? '';
|
|
465
|
+
const limit = parsePositiveInteger(url.searchParams.get('limit'), 5);
|
|
466
|
+
return createResponse(createJsonResponse({
|
|
467
|
+
suggestions: await suggestContextLinks(vaultPath, content, readAgentQuery(url), limit)
|
|
468
|
+
}), 200, contentTypes['.json']);
|
|
469
|
+
}
|
|
459
470
|
if (isReadMethod(request) && url.pathname === '/api/stats') {
|
|
460
471
|
return createResponse(createJsonResponse(await getStats(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
|
|
461
472
|
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { addNoteWithMetadata } from '../../application/add-note.js';
|
|
3
|
+
import { indexVault } from '../../application/index-vault.js';
|
|
4
|
+
import { addInboxItem, listInboxItems, processInboxItems } from '../../application/inbox.js';
|
|
5
|
+
import { buildRememberSuggestion, explainSearchResults, readMemoryContentInput, suggestBrokenLinkFixes, suggestContextLinks } from '../../application/memory-suggestions.js';
|
|
6
|
+
import { buildActionableDoctor, closeSession, initializeProjectMemory } from '../../application/operational-workflows.js';
|
|
7
|
+
import { repairBrokenLinks } from '../../application/repair-broken-links.js';
|
|
8
|
+
import { sanitizeSearchMode } from '../../infrastructure/config.js';
|
|
9
|
+
import { parsePositiveInteger, print, resolveOptions } from '../runtime.js';
|
|
10
|
+
const parseBoundedScore = (value, fallback) => {
|
|
11
|
+
if (value == null)
|
|
12
|
+
return fallback;
|
|
13
|
+
const parsed = Number.parseFloat(value);
|
|
14
|
+
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
|
|
15
|
+
throw new Error(`Invalid score value: ${value}. Expected a number between 0 and 1.`);
|
|
16
|
+
}
|
|
17
|
+
return parsed;
|
|
18
|
+
};
|
|
19
|
+
const registerRememberCommand = (program) => {
|
|
20
|
+
program
|
|
21
|
+
.command('remember')
|
|
22
|
+
.option('-t, --title <title>', 'note title; inferred when omitted')
|
|
23
|
+
.option('-c, --content <content>', 'markdown content')
|
|
24
|
+
.option('-f, --content-file <contentFile>', 'read markdown content from a file')
|
|
25
|
+
.option('--tag <tag...>', 'extra tags to include')
|
|
26
|
+
.option('--link <title...>', 'explicit Context Links to include')
|
|
27
|
+
.option('--link-limit <limit>', 'maximum suggested Context Links', '5')
|
|
28
|
+
.option('-v, --vault <vault>', 'vault directory')
|
|
29
|
+
.option('-a, --agent <agent>', 'agent memory namespace')
|
|
30
|
+
.option('--allow-sensitive', 'allow writing content that looks like a secret')
|
|
31
|
+
.option('--dry-run', 'preview suggested note without writing it')
|
|
32
|
+
.option('--no-auto-index', 'skip reindexing after remember')
|
|
33
|
+
.option('--json', 'print machine-readable JSON')
|
|
34
|
+
.description('capture memory with inferred title, tags and Context Links')
|
|
35
|
+
.action(async (options) => {
|
|
36
|
+
const resolved = await resolveOptions(options);
|
|
37
|
+
const content = await readMemoryContentInput(options);
|
|
38
|
+
const suggestion = await buildRememberSuggestion({
|
|
39
|
+
vaultPath: resolved.vault,
|
|
40
|
+
content,
|
|
41
|
+
agentId: resolved.agent,
|
|
42
|
+
title: options.title,
|
|
43
|
+
tags: options.tag ?? [],
|
|
44
|
+
links: options.link ?? [],
|
|
45
|
+
linkLimit: parsePositiveInteger(options.linkLimit ?? '5', 5)
|
|
46
|
+
});
|
|
47
|
+
if (options.dryRun) {
|
|
48
|
+
print(options.json, { dryRun: true, vault: resolved.vault, agent: resolved.agent ?? 'shared', suggestion }, () => suggestion.content);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const added = await addNoteWithMetadata(resolved.vault, suggestion.title, suggestion.content, resolved.agent, {
|
|
52
|
+
allowSensitive: Boolean(options.allowSensitive),
|
|
53
|
+
autoContextLinks: false
|
|
54
|
+
});
|
|
55
|
+
const index = options.autoIndex !== false && resolved.config.autoIndexOnWrite ? await indexVault(resolved.vault) : undefined;
|
|
56
|
+
print(options.json, { dryRun: false, vault: resolved.vault, agent: resolved.agent ?? 'shared', suggestion, path: added.path, ...(index ? { index } : {}) }, () => `Remembered "${suggestion.title}" at ${added.path}.`);
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
const registerInboxCommands = (program) => {
|
|
60
|
+
const inboxCommand = program.command('inbox').description('capture and triage quick memory items');
|
|
61
|
+
inboxCommand
|
|
62
|
+
.command('add')
|
|
63
|
+
.option('-c, --content <content>', 'inbox content')
|
|
64
|
+
.option('-f, --content-file <contentFile>', 'read inbox content from a file')
|
|
65
|
+
.option('-v, --vault <vault>', 'vault directory')
|
|
66
|
+
.option('-a, --agent <agent>', 'agent memory namespace')
|
|
67
|
+
.option('--no-auto-index', 'skip reindexing after add')
|
|
68
|
+
.option('--json', 'print machine-readable JSON')
|
|
69
|
+
.description('add a quick untriaged memory item')
|
|
70
|
+
.action(async (options) => {
|
|
71
|
+
const resolved = await resolveOptions(options);
|
|
72
|
+
const content = await readMemoryContentInput(options);
|
|
73
|
+
const result = await addInboxItem({
|
|
74
|
+
vaultPath: resolved.vault,
|
|
75
|
+
content,
|
|
76
|
+
agentId: resolved.agent,
|
|
77
|
+
autoIndex: options.autoIndex !== false && resolved.config.autoIndexOnWrite
|
|
78
|
+
});
|
|
79
|
+
print(options.json, { vault: resolved.vault, agent: resolved.agent ?? 'shared', ...result }, () => `Added inbox item at ${result.item.path}.`);
|
|
80
|
+
});
|
|
81
|
+
inboxCommand
|
|
82
|
+
.command('list')
|
|
83
|
+
.option('-v, --vault <vault>', 'vault directory')
|
|
84
|
+
.option('-a, --agent <agent>', 'accepted for consistency; inbox scans vault Markdown')
|
|
85
|
+
.option('-l, --limit <limit>', 'maximum inbox items', '20')
|
|
86
|
+
.option('--json', 'print machine-readable JSON')
|
|
87
|
+
.description('list untriaged inbox memory items')
|
|
88
|
+
.action(async (options) => {
|
|
89
|
+
const resolved = await resolveOptions(options);
|
|
90
|
+
const items = await listInboxItems(resolved.vault, parsePositiveInteger(options.limit ?? '20', 20));
|
|
91
|
+
print(options.json, { vault: resolved.vault, items }, () => items.length === 0 ? 'Inbox is empty.' : items.map((item) => `${item.updatedAt} ${item.path}: ${item.preview}`).join('\n'));
|
|
92
|
+
});
|
|
93
|
+
inboxCommand
|
|
94
|
+
.command('process')
|
|
95
|
+
.option('-v, --vault <vault>', 'vault directory')
|
|
96
|
+
.option('-a, --agent <agent>', 'agent memory namespace')
|
|
97
|
+
.option('-l, --limit <limit>', 'maximum inbox items to inspect', '10')
|
|
98
|
+
.option('--json', 'print machine-readable JSON')
|
|
99
|
+
.description('suggest titles, tags and links for inbox items')
|
|
100
|
+
.action(async (options) => {
|
|
101
|
+
const resolved = await resolveOptions(options);
|
|
102
|
+
const items = await processInboxItems({
|
|
103
|
+
vaultPath: resolved.vault,
|
|
104
|
+
agentId: resolved.agent,
|
|
105
|
+
limit: parsePositiveInteger(options.limit ?? '10', 10)
|
|
106
|
+
});
|
|
107
|
+
print(options.json, { vault: resolved.vault, agent: resolved.agent ?? 'shared', items }, () => items.length === 0
|
|
108
|
+
? 'Inbox is empty.'
|
|
109
|
+
: items
|
|
110
|
+
.map((item) => [
|
|
111
|
+
`${item.path}`,
|
|
112
|
+
` title: ${item.suggestedTitle}`,
|
|
113
|
+
` tags: ${item.suggestedTags.map((tag) => `#${tag}`).join(' ') || 'none'}`,
|
|
114
|
+
` links: ${item.suggestedLinks.map((link) => `[[${link}]]`).join(' ') || 'none'}`
|
|
115
|
+
].join('\n'))
|
|
116
|
+
.join('\n\n'));
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
const registerProjectCommands = (program) => {
|
|
120
|
+
const projectCommand = program.command('project').description('manage project-scoped Brainlink memory');
|
|
121
|
+
projectCommand
|
|
122
|
+
.command('init')
|
|
123
|
+
.option('-p, --path <path>', 'project path to inspect', process.cwd())
|
|
124
|
+
.option('-v, --vault <vault>', 'vault directory')
|
|
125
|
+
.option('-a, --agent <agent>', 'agent memory namespace')
|
|
126
|
+
.option('--json', 'print machine-readable JSON')
|
|
127
|
+
.description('seed memory from project docs and agent instructions')
|
|
128
|
+
.action(async (options) => {
|
|
129
|
+
const resolved = await resolveOptions(options);
|
|
130
|
+
const result = await initializeProjectMemory({
|
|
131
|
+
vaultPath: resolved.vault,
|
|
132
|
+
projectPath: resolve(options.path ?? process.cwd()),
|
|
133
|
+
agentId: resolved.agent
|
|
134
|
+
});
|
|
135
|
+
print(options.json, { agent: resolved.agent ?? 'shared', ...result }, () => [
|
|
136
|
+
`Initialized project memory for ${result.projectPath}`,
|
|
137
|
+
`vault=${result.vault}`,
|
|
138
|
+
`notes=${result.notes.length}`,
|
|
139
|
+
`indexedDocuments=${result.index.documentCount}`
|
|
140
|
+
].join('\n'));
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
const registerSuggestionCommands = (program) => {
|
|
144
|
+
program
|
|
145
|
+
.command('suggest-links')
|
|
146
|
+
.option('-v, --vault <vault>', 'vault directory')
|
|
147
|
+
.option('-a, --agent <agent>', 'agent memory namespace')
|
|
148
|
+
.option('-c, --content <content>', 'content to inspect for link suggestions')
|
|
149
|
+
.option('-f, --content-file <contentFile>', 'read content to inspect from a file')
|
|
150
|
+
.option('-l, --limit <limit>', 'maximum suggestions', '5')
|
|
151
|
+
.option('--broken', 'suggest fixes for unresolved wiki links')
|
|
152
|
+
.option('--json', 'print machine-readable JSON')
|
|
153
|
+
.description('suggest Context Links or fixes for broken wiki links')
|
|
154
|
+
.action(async (options) => {
|
|
155
|
+
const resolved = await resolveOptions(options);
|
|
156
|
+
const limit = parsePositiveInteger(options.limit ?? '5', 5);
|
|
157
|
+
if (options.broken) {
|
|
158
|
+
const suggestions = await suggestBrokenLinkFixes(resolved.vault, resolved.agent, limit);
|
|
159
|
+
print(options.json, { vault: resolved.vault, agent: resolved.agent ?? 'shared', suggestions }, () => suggestions.length === 0
|
|
160
|
+
? 'No broken links found.'
|
|
161
|
+
: suggestions.map((item) => {
|
|
162
|
+
const candidates = item.candidates.length > 0
|
|
163
|
+
? item.candidates.map((candidate) => ` - [[${candidate.title}]] (${candidate.path}) score=${candidate.score}`).join('\n')
|
|
164
|
+
: ' - no close candidate found';
|
|
165
|
+
return `${item.fromTitle} (${item.fromPath}) -> [[${item.toTitle}]]\n${candidates}`;
|
|
166
|
+
}).join('\n\n'));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const content = await readMemoryContentInput(options);
|
|
170
|
+
const suggestions = await suggestContextLinks(resolved.vault, content, resolved.agent, limit);
|
|
171
|
+
print(options.json, { vault: resolved.vault, agent: resolved.agent ?? 'shared', suggestions }, () => suggestions.map((item) => `[[${item.title}]] (${item.path}) score=${item.score} - ${item.reason}`).join('\n'));
|
|
172
|
+
});
|
|
173
|
+
program
|
|
174
|
+
.command('repair-links')
|
|
175
|
+
.option('-v, --vault <vault>', 'vault directory')
|
|
176
|
+
.option('-a, --agent <agent>', 'agent memory namespace')
|
|
177
|
+
.option('--dry-run', 'preview repairs without writing files')
|
|
178
|
+
.option('--no-create-missing', 'do not create placeholder notes for unresolved targets')
|
|
179
|
+
.option('--no-auto-index', 'skip reindexing after repairs')
|
|
180
|
+
.option('--min-score <score>', 'minimum similarity score for automatic retargeting', '0.88')
|
|
181
|
+
.option('--margin <score>', 'minimum score gap between first and second candidate', '0.12')
|
|
182
|
+
.option('--json', 'print machine-readable JSON')
|
|
183
|
+
.description('repair broken wiki links by retargeting safe matches or creating placeholder targets')
|
|
184
|
+
.action(async (options) => {
|
|
185
|
+
const resolved = await resolveOptions(options);
|
|
186
|
+
const result = await repairBrokenLinks(resolved.vault, {
|
|
187
|
+
agentId: resolved.agent,
|
|
188
|
+
dryRun: options.dryRun === true,
|
|
189
|
+
autoIndex: options.autoIndex !== false && resolved.config.autoIndexOnWrite,
|
|
190
|
+
createMissing: options.createMissing !== false,
|
|
191
|
+
minScore: parseBoundedScore(options.minScore, 0.88),
|
|
192
|
+
margin: parseBoundedScore(options.margin, 0.12)
|
|
193
|
+
});
|
|
194
|
+
print(options.json, { vault: resolved.vault, agent: resolved.agent ?? 'shared', ...result }, () => [
|
|
195
|
+
`${result.dryRun ? 'Previewed' : 'Repaired'} broken links: scanned=${result.scanned}, changed=${result.changed}, created=${result.created}, skipped=${result.skipped}`,
|
|
196
|
+
...result.entries.map((entry) => `- ${entry.action}: ${entry.fromTitle} -> [[${entry.toTitle}]]${entry.targetTitle ? ` => [[${entry.targetTitle}]]` : ''} (${entry.reason})`)
|
|
197
|
+
].join('\n'));
|
|
198
|
+
});
|
|
199
|
+
program
|
|
200
|
+
.command('explain')
|
|
201
|
+
.argument('<query>', 'search query to explain')
|
|
202
|
+
.option('-v, --vault <vault>', 'vault directory')
|
|
203
|
+
.option('-a, --agent <agent>', 'agent memory namespace')
|
|
204
|
+
.option('-l, --limit <limit>', 'maximum results', '10')
|
|
205
|
+
.option('-m, --mode <mode>', 'search mode: fts, semantic or hybrid')
|
|
206
|
+
.option('--json', 'print machine-readable JSON')
|
|
207
|
+
.description('explain why search results matched a query')
|
|
208
|
+
.action(async (query, options) => {
|
|
209
|
+
const resolved = await resolveOptions(options);
|
|
210
|
+
const mode = sanitizeSearchMode(options.mode, resolved.defaults.defaultSearchMode);
|
|
211
|
+
const results = await explainSearchResults(resolved.vault, query, parsePositiveInteger(options.limit ?? String(resolved.defaults.defaultSearchLimit), resolved.defaults.defaultSearchLimit), resolved.agent, mode);
|
|
212
|
+
print(options.json, { query, mode, agent: resolved.agent ?? 'shared', results }, () => results.map((result, index) => [
|
|
213
|
+
`${index + 1}. ${result.title} (${result.path}) score=${result.score.toFixed(3)}`,
|
|
214
|
+
...result.reasons.map((reason) => ` - ${reason}`)
|
|
215
|
+
].join('\n')).join('\n\n'));
|
|
216
|
+
});
|
|
217
|
+
};
|
|
218
|
+
const registerSessionCommands = (program) => {
|
|
219
|
+
const runSessionClose = async (options) => {
|
|
220
|
+
const resolved = await resolveOptions(options);
|
|
221
|
+
const content = options.content || options.contentFile ? await readMemoryContentInput(options) : undefined;
|
|
222
|
+
const result = await closeSession({
|
|
223
|
+
vaultPath: resolved.vault,
|
|
224
|
+
agentId: resolved.agent,
|
|
225
|
+
cwd: process.cwd(),
|
|
226
|
+
content,
|
|
227
|
+
write: options.dryRun !== true,
|
|
228
|
+
autoIndex: options.autoIndex !== false && resolved.config.autoIndexOnWrite
|
|
229
|
+
});
|
|
230
|
+
print(options.json, { vault: resolved.vault, agent: resolved.agent ?? 'shared', dryRun: options.dryRun === true, ...result }, () => options.dryRun ? result.content : `Wrote session handoff "${result.title}" at ${result.writtenPath}.`);
|
|
231
|
+
};
|
|
232
|
+
program
|
|
233
|
+
.command('session-close')
|
|
234
|
+
.option('-v, --vault <vault>', 'vault directory')
|
|
235
|
+
.option('-a, --agent <agent>', 'agent memory namespace')
|
|
236
|
+
.option('-c, --content <content>', 'extra session notes')
|
|
237
|
+
.option('-f, --content-file <contentFile>', 'read extra session notes from a file')
|
|
238
|
+
.option('--dry-run', 'preview session handoff without writing it')
|
|
239
|
+
.option('--no-auto-index', 'skip reindexing after writing handoff')
|
|
240
|
+
.option('--json', 'print machine-readable JSON')
|
|
241
|
+
.description('write a session handoff note with vault and git state')
|
|
242
|
+
.action(runSessionClose);
|
|
243
|
+
program
|
|
244
|
+
.command('daily')
|
|
245
|
+
.option('-v, --vault <vault>', 'vault directory')
|
|
246
|
+
.option('-a, --agent <agent>', 'agent memory namespace')
|
|
247
|
+
.option('-c, --content <content>', 'extra daily notes')
|
|
248
|
+
.option('-f, --content-file <contentFile>', 'read extra daily notes from a file')
|
|
249
|
+
.option('--dry-run', 'preview daily handoff without writing it')
|
|
250
|
+
.option('--no-auto-index', 'skip reindexing after writing handoff')
|
|
251
|
+
.option('--json', 'print machine-readable JSON')
|
|
252
|
+
.description('alias for session-close')
|
|
253
|
+
.action(runSessionClose);
|
|
254
|
+
};
|
|
255
|
+
export const registerPracticalCommands = (program) => {
|
|
256
|
+
registerRememberCommand(program);
|
|
257
|
+
registerInboxCommands(program);
|
|
258
|
+
registerProjectCommands(program);
|
|
259
|
+
registerSuggestionCommands(program);
|
|
260
|
+
registerSessionCommands(program);
|
|
261
|
+
program
|
|
262
|
+
.command('doctor-actions')
|
|
263
|
+
.option('-v, --vault <vault>', 'vault directory')
|
|
264
|
+
.option('-a, --agent <agent>', 'accepted for consistency')
|
|
265
|
+
.option('--json', 'print machine-readable JSON')
|
|
266
|
+
.description('print actionable vault health commands')
|
|
267
|
+
.action(async (options) => {
|
|
268
|
+
const resolved = await resolveOptions(options);
|
|
269
|
+
const report = await buildActionableDoctor(resolved.vault);
|
|
270
|
+
print(options.json, report, () => [
|
|
271
|
+
...report.doctor.checks.map((check) => `${check.ok ? 'OK' : 'FAIL'} ${check.name}: ${check.message}`),
|
|
272
|
+
'',
|
|
273
|
+
'Actionable next steps:',
|
|
274
|
+
...report.actions.map((action) => `- [${action.severity}] ${action.command} (${action.reason})`)
|
|
275
|
+
].join('\n'));
|
|
276
|
+
process.exitCode = report.doctor.ok ? 0 : 1;
|
|
277
|
+
});
|
|
278
|
+
};
|