@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.
@@ -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 readSearchMode = async (url) => {
27
+ const readRuntimeDefaults = async (url) => {
27
28
  const config = await loadBrainlinkConfig();
28
- const defaults = resolveAgentRuntimeDefaults(config, readAgentQuery(url));
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 config = await loadBrainlinkConfig();
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 config = await loadBrainlinkConfig();
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 limit = parsePositiveInteger(url.searchParams.get('limit'), 10);
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 limit = parsePositiveInteger(url.searchParams.get('limit'), 12);
440
- const tokens = parsePositiveInteger(url.searchParams.get('tokens'), 2000);
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
+ };