@andespindola/brainlink 1.0.3 → 1.0.4

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.
@@ -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';
@@ -456,6 +457,13 @@ export const route = async (request, url, vaultPath) => {
456
457
  const title = url.searchParams.get('title') ?? '';
457
458
  return createResponse(createJsonResponse({ title, backlinks: await listBacklinks(vaultPath, title, readAgentQuery(url)) }), 200, contentTypes['.json']);
458
459
  }
460
+ if (isReadMethod(request) && url.pathname === '/api/suggest-links') {
461
+ const content = url.searchParams.get('content') ?? '';
462
+ const limit = parsePositiveInteger(url.searchParams.get('limit'), 5);
463
+ return createResponse(createJsonResponse({
464
+ suggestions: await suggestContextLinks(vaultPath, content, readAgentQuery(url), limit)
465
+ }), 200, contentTypes['.json']);
466
+ }
459
467
  if (isReadMethod(request) && url.pathname === '/api/stats') {
460
468
  return createResponse(createJsonResponse(await getStats(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
461
469
  }
@@ -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
+ };
@@ -3,6 +3,7 @@ import { buildContextPackage, readContextDataSignature } from '../../application
3
3
  import { getGraph } from '../../application/get-graph.js';
4
4
  import { listAgents } from '../../application/list-agents.js';
5
5
  import { listBacklinks, listLinks } from '../../application/list-links.js';
6
+ import { explainSearchResults } from '../../application/memory-suggestions.js';
6
7
  import { searchKnowledge } from '../../application/search-knowledge.js';
7
8
  import { sanitizeContextStrategy, sanitizeSearchMode } from '../../infrastructure/config.js';
8
9
  import { clearContextPacks, listContextPacks } from '../../infrastructure/context-packs.js';
@@ -15,12 +16,24 @@ export const registerReadCommands = (program) => {
15
16
  .option('-a, --agent <agent>', 'filter by agent memory namespace')
16
17
  .option('-l, --limit <limit>', 'maximum results')
17
18
  .option('-m, --mode <mode>', 'search mode: fts, semantic or hybrid')
19
+ .option('--explain', 'include match reasons and score components')
18
20
  .option('--json', 'print machine-readable JSON')
19
21
  .description('search indexed knowledge')
20
22
  .action(async (query, options) => {
21
23
  const resolved = await resolveOptions(options);
22
24
  const limit = parsePositiveInteger(options.limit ?? String(resolved.defaults.defaultSearchLimit), resolved.defaults.defaultSearchLimit);
23
25
  const mode = sanitizeSearchMode(options.mode, resolved.defaults.defaultSearchMode);
26
+ if (options.explain) {
27
+ const results = await explainSearchResults(resolved.vault, query, limit, resolved.agent, mode);
28
+ print(options.json, { query, agent: resolved.agent, limit, mode, results }, () => results
29
+ .map((result, index) => [
30
+ `${index + 1}. ${result.title} (${result.path}) score=${result.score.toFixed(3)} mode=${result.searchMode}`,
31
+ ...result.reasons.map((reason) => ` - ${reason}`),
32
+ result.content
33
+ ].join('\n'))
34
+ .join('\n\n'));
35
+ return;
36
+ }
24
37
  const results = await searchKnowledge(resolved.vault, query, limit, resolved.agent, mode);
25
38
  print(options.json, { query, agent: resolved.agent, limit, mode, results }, () => results
26
39
  .map((result, index) => [`${index + 1}. ${result.title} (${result.path}) score=${result.score.toFixed(3)} mode=${result.searchMode}`, result.content].join('\n'))
@@ -17,6 +17,7 @@ import { createOfflinePackBackup } from '../../application/offline-pack-backup.j
17
17
  import { startServer } from '../../application/start-server.js';
18
18
  import { startVaultWatcher } from '../../application/watch-vault.js';
19
19
  import { doctorVault, getStats, validateVault } from '../../application/analyze-vault.js';
20
+ import { buildActionableDoctor } from '../../application/operational-workflows.js';
20
21
  import { defaultBrainlinkConfig, sanitizeContextStrategy, sanitizeSearchMode } from '../../infrastructure/config.js';
21
22
  import { loadBrainlinkConfig } from '../../infrastructure/config.js';
22
23
  import { assertVaultAllowed, ensureVault, isBucketVaultPath } from '../../infrastructure/file-system-vault.js';
@@ -1060,10 +1061,22 @@ export const registerWriteCommands = (program) => {
1060
1061
  program
1061
1062
  .command('doctor')
1062
1063
  .option('-v, --vault <vault>', 'vault directory')
1064
+ .option('--actionable', 'include prioritized commands for fixing or improving vault health')
1063
1065
  .option('--json', 'print machine-readable JSON')
1064
1066
  .description('run Brainlink environment and vault checks')
1065
1067
  .action(async (options) => {
1066
1068
  const resolved = await resolveOptions(options);
1069
+ if (options.actionable) {
1070
+ const report = await buildActionableDoctor(resolved.vault);
1071
+ print(options.json, report, () => [
1072
+ ...report.doctor.checks.map((check) => `${check.ok ? 'OK' : 'FAIL'} ${check.name}: ${check.message}`),
1073
+ '',
1074
+ 'Actionable next steps:',
1075
+ ...report.actions.map((action) => `- [${action.severity}] ${action.command} (${action.reason})`)
1076
+ ].join('\n'));
1077
+ process.exitCode = report.doctor.ok ? 0 : 1;
1078
+ return;
1079
+ }
1067
1080
  const report = await doctorVault(resolved.vault);
1068
1081
  print(options.json, report, () => {
1069
1082
  const checks = report.checks.map((check) => `${check.ok ? 'OK' : 'FAIL'} ${check.name}: ${check.message}`).join('\n');
package/dist/cli/main.js CHANGED
@@ -5,6 +5,7 @@ import { basename, dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { registerAgentCommands } from './commands/agent-commands.js';
7
7
  import { registerConfigCommands } from './commands/config-commands.js';
8
+ import { registerPracticalCommands } from './commands/practical-commands.js';
8
9
  import { registerReadCommands } from './commands/read-commands.js';
9
10
  import { registerVaultCommands } from './commands/vault-commands.js';
10
11
  import { registerWriteCommands } from './commands/write-commands.js';
@@ -24,6 +25,7 @@ program
24
25
  .version(readPackageVersion());
25
26
  registerWriteCommands(program);
26
27
  registerReadCommands(program);
28
+ registerPracticalCommands(program);
27
29
  registerConfigCommands(program);
28
30
  registerVaultCommands(program);
29
31
  registerAgentCommands(program);
@@ -1,5 +1,5 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, deleteNoteInputSchema, deleteNoteTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, indexInputSchema, indexTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, recommendationsInputSchema, recommendationsTool, searchInputSchema, searchTool, statsInputSchema, statsTool, syncInputSchema, syncTool, validateInputSchema, validateTool, versionInputSchema, versionTool } from './tools.js';
2
+ import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, deleteNoteInputSchema, deleteNoteTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, doctorActionsInputSchema, doctorActionsTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, explainInputSchema, explainTool, indexInputSchema, indexTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, projectInitInputSchema, projectInitTool, recommendationsInputSchema, recommendationsTool, rememberInputSchema, rememberTool, repairLinksInputSchema, repairLinksTool, searchInputSchema, searchTool, sessionCloseInputSchema, sessionCloseTool, statsInputSchema, statsTool, suggestLinksInputSchema, suggestLinksTool, syncInputSchema, syncTool, validateInputSchema, validateTool, versionInputSchema, versionTool } from './tools.js';
3
3
  import { getRuntimeVersion } from './runtime.js';
4
4
  export const createBrainlinkMcpServer = () => {
5
5
  const server = new McpServer({
@@ -43,6 +43,11 @@ export const createBrainlinkMcpServer = () => {
43
43
  description: 'Search indexed Brainlink notes with FTS, semantic or hybrid retrieval.',
44
44
  inputSchema: searchInputSchema
45
45
  }, searchTool);
46
+ server.registerTool('brainlink_explain', {
47
+ title: 'Explain Brainlink Search Results',
48
+ description: 'Explain why indexed notes matched a query, including score components and match reasons.',
49
+ inputSchema: explainInputSchema
50
+ }, explainTool);
46
51
  server.registerTool('brainlink_dedupe', {
47
52
  title: 'Detect Duplicate Notes',
48
53
  description: 'Detect possible duplicate notes using exact content hash and semantic similarity scoring.',
@@ -58,6 +63,26 @@ export const createBrainlinkMcpServer = () => {
58
63
  description: 'Write durable Markdown memory, then reindex the vault. Include explicit [[wiki links]] for connected graph memory. Add priority markers near links, such as priority: high, #important or #critical, when a relationship should be weighted higher.',
59
64
  inputSchema: addNoteInputSchema
60
65
  }, addNoteTool);
66
+ server.registerTool('brainlink_remember', {
67
+ title: 'Capture Assisted Brainlink Memory',
68
+ description: 'Capture durable memory with inferred title, tags and Context Links. Supports dry-run preview before writing.',
69
+ inputSchema: rememberInputSchema
70
+ }, rememberTool);
71
+ server.registerTool('brainlink_inbox_add', {
72
+ title: 'Add Brainlink Inbox Item',
73
+ description: 'Capture a quick untriaged memory item tagged for later processing.',
74
+ inputSchema: inboxAddInputSchema
75
+ }, inboxAddTool);
76
+ server.registerTool('brainlink_inbox_list', {
77
+ title: 'List Brainlink Inbox Items',
78
+ description: 'List quick untriaged memory items from the vault.',
79
+ inputSchema: inboxListInputSchema
80
+ }, inboxListTool);
81
+ server.registerTool('brainlink_inbox_process', {
82
+ title: 'Process Brainlink Inbox Items',
83
+ description: 'Suggest titles, tags and Context Links for untriaged inbox items.',
84
+ inputSchema: inboxProcessInputSchema
85
+ }, inboxProcessTool);
61
86
  server.registerTool('brainlink_delete_note', {
62
87
  title: 'Delete Brainlink Note',
63
88
  description: 'Delete a durable Markdown note from the vault after explicit confirmation. Select by title or path; reindexes by default.',
@@ -93,6 +118,11 @@ export const createBrainlinkMcpServer = () => {
93
118
  description: 'Read indexed vault statistics, including node, edge and tag totals.',
94
119
  inputSchema: statsInputSchema
95
120
  }, statsTool);
121
+ server.registerTool('brainlink_doctor_actions', {
122
+ title: 'Get Actionable Brainlink Doctor Plan',
123
+ description: 'Run vault readiness checks and return prioritized executable next actions.',
124
+ inputSchema: doctorActionsInputSchema
125
+ }, doctorActionsTool);
96
126
  server.registerTool('brainlink_validate', {
97
127
  title: 'Validate Brainlink Vault',
98
128
  description: 'Validate indexed graph health, including broken links and orphan notes.',
@@ -118,10 +148,30 @@ export const createBrainlinkMcpServer = () => {
118
148
  description: 'List unresolved indexed wiki links.',
119
149
  inputSchema: brokenLinksInputSchema
120
150
  }, brokenLinksTool);
151
+ server.registerTool('brainlink_suggest_links', {
152
+ title: 'Suggest Brainlink Links',
153
+ description: 'Suggest Context Links for content or likely fixes for unresolved wiki links.',
154
+ inputSchema: suggestLinksInputSchema
155
+ }, suggestLinksTool);
156
+ server.registerTool('brainlink_repair_links', {
157
+ title: 'Repair Brainlink Broken Links',
158
+ description: 'Repair unresolved wiki links by retargeting safe high-confidence matches or creating placeholder target notes.',
159
+ inputSchema: repairLinksInputSchema
160
+ }, repairLinksTool);
121
161
  server.registerTool('brainlink_orphans', {
122
162
  title: 'List Brainlink Orphans',
123
163
  description: 'List indexed notes without incoming or outgoing graph links.',
124
164
  inputSchema: orphansInputSchema
125
165
  }, orphansTool);
166
+ server.registerTool('brainlink_session_close', {
167
+ title: 'Write Brainlink Session Handoff',
168
+ description: 'Write or preview a session handoff note with vault health and workspace git status.',
169
+ inputSchema: sessionCloseInputSchema
170
+ }, sessionCloseTool);
171
+ server.registerTool('brainlink_project_init', {
172
+ title: 'Initialize Project Memory',
173
+ description: 'Seed Brainlink memory from project documents such as AGENTS.md, README.md and architecture docs.',
174
+ inputSchema: projectInitInputSchema
175
+ }, projectInitTool);
126
176
  return server;
127
177
  };