@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.
package/README.md CHANGED
@@ -534,9 +534,14 @@ Available tools:
534
534
  - `brainlink_context`: read indexed context for a task or question; pass `strategy: "rag"` for fresh retrieval assembly, `strategy: "cag"` for persisted context packs or `strategy: "auto"` for CAG hits with RAG fallback.
535
535
  - `brainlink_context_packs`: list or clear persisted CAG context packs.
536
536
  - `brainlink_search`: search indexed notes.
537
+ - `brainlink_explain`: explain why indexed notes matched a query.
537
538
  - `brainlink_dedupe`: detect duplicate candidates using exact hash + semantic similarity scores.
538
539
  - `brainlink_resolve_duplicate`: resolve duplicate pairs (`merge`, `link`, `ignore`) with connectivity-safe fallback edges.
539
540
  - `brainlink_add_note`: write durable Markdown memory and reindex.
541
+ - `brainlink_remember`: capture durable memory with inferred title, tags and Context Links; supports dry-run.
542
+ - `brainlink_inbox_add`: capture a quick untriaged memory item.
543
+ - `brainlink_inbox_list`: list untriaged inbox memory items.
544
+ - `brainlink_inbox_process`: suggest titles, tags and links for inbox items.
540
545
  - `brainlink_delete_note`: delete a durable Markdown note by title or path after explicit confirmation and reindex.
541
546
  - `brainlink_add_file`: ingest a local file as a note and reindex.
542
547
  - `brainlink_canonicalize_context_links`: ensure existing notes link to inferred context hubs.
@@ -544,14 +549,20 @@ Available tools:
544
549
  - `brainlink_volatile_clear`: clear temporary memory for the current vault/agent namespace.
545
550
  - `brainlink_index`: rebuild the vault index. Pass `full=true` for a complete source reindex.
546
551
  - `brainlink_stats`: read indexed vault statistics.
552
+ - `brainlink_doctor_actions`: return vault checks plus prioritized executable next actions.
547
553
  - `brainlink_validate`: validate broken links and orphan notes.
548
554
  - `brainlink_sync`: run index, stats, validation, broken-link and orphan checks in one call.
549
555
  - `brainlink_graph`: read indexed graph nodes and weighted links.
550
556
  - `brainlink_graph_contexts`: list the visual graph contexts used by the local server.
551
557
  - `brainlink_broken_links`: list unresolved wiki links.
558
+ - `brainlink_suggest_links`: suggest Context Links for content or fixes for unresolved wiki links.
559
+ - `brainlink_repair_links`: repair unresolved wiki links by retargeting safe matches or creating placeholder target notes.
552
560
  - `brainlink_orphans`: list disconnected notes.
561
+ - `brainlink_session_close`: write or preview a session handoff note.
562
+ - `brainlink_project_init`: seed project memory from local project docs.
553
563
 
554
564
  For the most automatic workflow, start MCP sessions with `brainlink_bootstrap` (optionally with `query`) and then continue with `brainlink_context`/`brainlink_add_note`.
565
+ MCP is kept in parity with practical Brainlink CLI workflows when a feature is safe and meaningful for tool clients.
555
566
  By default, Brainlink enforces context-first for MCP reads (`enforceContextFirst=true`): non-context read tools return preflight until `brainlink_context` is called for the vault/agent session.
556
567
  By default, MCP startup already runs bootstrap on the configured default vault/agent (`autoBootstrapOnStartup=true`), so sessions begin warm.
557
568
  By default, Brainlink enforces bootstrap and auto-runs it for read tools when session state is missing or stale (`autoBootstrapOnRead=true`).
@@ -701,6 +712,27 @@ blink quickstart --vault ./team-vault --mcp-only --json
701
712
  Runs index + doctor + stats + validation, refreshes bootstrap session readiness, optionally returns context for a query, and (by default) upgrades local agent integration for plug-and-play MCP usage.
702
713
  When `--mode`, `--limit` or `--tokens` are omitted, quickstart uses agent profile defaults when available.
703
714
 
715
+ ### Practical Memory Workflows
716
+
717
+ ```bash
718
+ blink remember --content "Keep Markdown as the source of truth. #architecture"
719
+ blink remember --content-file ./handoff.md --dry-run
720
+ blink inbox add --content "Quick note to triage later"
721
+ blink inbox list
722
+ blink inbox process --json
723
+ blink session-close --content "Validated release flow"
724
+ blink daily --dry-run
725
+ blink suggest-links --content "Architecture and release flow"
726
+ blink suggest-links --broken
727
+ blink repair-links
728
+ blink search "architecture" --explain
729
+ blink explain "architecture"
730
+ blink doctor --actionable
731
+ blink project init --path .
732
+ ```
733
+
734
+ `remember` infers a title, tags and Context Links before writing a durable note. `inbox` captures quick untriaged memory and later suggests titles, tags and links. `session-close`/`daily` writes a handoff note with vault health and git status. `suggest-links` proposes Context Links or likely fixes for unresolved wiki links. `repair-links` retargets high-confidence broken links and creates `#triage` placeholder notes for unresolved targets that cannot be safely retargeted. `doctor --actionable` returns prioritized commands instead of only checks. `project init` seeds memory from project docs such as `AGENTS.md`, `README.md` and architecture docs.
735
+
704
736
  ### `config`
705
737
 
706
738
  ```bash
@@ -628,6 +628,40 @@ li small {
628
628
  grid-column: span 2;
629
629
  }
630
630
 
631
+ .content-actions {
632
+ grid-column: span 2;
633
+ }
634
+
635
+ .node-actions {
636
+ display: flex;
637
+ flex-wrap: wrap;
638
+ gap: 8px;
639
+ }
640
+
641
+ .node-actions button {
642
+ width: auto;
643
+ min-height: 34px;
644
+ padding: 0 10px;
645
+ border: 1px solid var(--line);
646
+ border-radius: 8px;
647
+ background: var(--panel-strong);
648
+ color: var(--text);
649
+ cursor: pointer;
650
+ }
651
+
652
+ .node-actions button:hover,
653
+ .node-actions button:focus {
654
+ border-color: var(--accent);
655
+ color: var(--accent);
656
+ }
657
+
658
+ #contentActionStatus {
659
+ min-height: 16px;
660
+ margin: 0;
661
+ color: var(--muted);
662
+ font-size: 12px;
663
+ }
664
+
631
665
  .content-dialog .note-content {
632
666
  max-height: none;
633
667
  min-height: 0;
@@ -107,6 +107,15 @@ export const createClientHtml = () => `<!doctype html>
107
107
  <h3>Backlinks</h3>
108
108
  <ul id="contentIncoming"></ul>
109
109
  </section>
110
+ <section class="content-meta-section content-actions">
111
+ <h3>Actions</h3>
112
+ <div class="node-actions">
113
+ <button id="copyWikiLink" type="button">Copy wiki link</button>
114
+ <button id="suggestNodeLinks" type="button">Suggest links</button>
115
+ </div>
116
+ <p id="contentActionStatus"></p>
117
+ <ul id="contentLinkSuggestions"></ul>
118
+ </section>
110
119
  </div>
111
120
  <pre id="contentBody" class="note-content"></pre>
112
121
  </article>
@@ -33,7 +33,11 @@ const elements = {
33
33
  contentOutgoing: byId('contentOutgoing'),
34
34
  contentIncoming: byId('contentIncoming'),
35
35
  contentBody: byId('contentBody'),
36
- contentClose: byId('contentClose')
36
+ contentClose: byId('contentClose'),
37
+ copyWikiLink: byId('copyWikiLink'),
38
+ suggestNodeLinks: byId('suggestNodeLinks'),
39
+ contentActionStatus: byId('contentActionStatus'),
40
+ contentLinkSuggestions: byId('contentLinkSuggestions')
37
41
  }
38
42
 
39
43
  const state = {
@@ -926,6 +930,59 @@ const closeContentDialog = () => {
926
930
  elements.contentDialog.hidden = true
927
931
  }
928
932
 
933
+ const selectedNode = () => {
934
+ if (!state.selectedNodeId) {
935
+ return null
936
+ }
937
+
938
+ const packed = nodeByIdFromChunk().get(state.selectedNodeId)
939
+ if (!packed) {
940
+ return null
941
+ }
942
+
943
+ return {
944
+ id: packed[0],
945
+ title: packed[1],
946
+ path: packed[4] || ''
947
+ }
948
+ }
949
+
950
+ const copySelectedWikiLink = async () => {
951
+ const node = selectedNode()
952
+ if (!node) {
953
+ elements.contentActionStatus.textContent = 'Select a note first.'
954
+ return
955
+ }
956
+
957
+ const value = '[[' + node.title + ']]'
958
+ try {
959
+ await navigator.clipboard.writeText(value)
960
+ elements.contentActionStatus.textContent = 'Copied ' + value
961
+ } catch {
962
+ elements.contentActionStatus.textContent = value
963
+ }
964
+ }
965
+
966
+ const loadSelectedLinkSuggestions = async () => {
967
+ const content = elements.contentBody.textContent || ''
968
+ if (!content.trim()) {
969
+ elements.contentActionStatus.textContent = 'Selected note has no content.'
970
+ return
971
+ }
972
+
973
+ elements.contentActionStatus.textContent = 'Loading suggestions...'
974
+ const response = await fetch('/api/suggest-links?limit=5&content=' + encodeURIComponent(content.slice(0, 2000)) + scopeQuery('&'))
975
+ if (!response.ok) {
976
+ throw new Error('Failed to load link suggestions')
977
+ }
978
+ const payload = await response.json()
979
+ const suggestions = Array.isArray(payload.suggestions) ? payload.suggestions : []
980
+ elements.contentLinkSuggestions.innerHTML = suggestions.length > 0
981
+ ? suggestions.map((item) => '<li><button type="button" data-title="' + escapeHtml(item.title) + '">[[' + escapeHtml(item.title) + ']]</button></li>').join('')
982
+ : '<li>No strong suggestions</li>'
983
+ elements.contentActionStatus.textContent = suggestions.length > 0 ? 'Suggested Context Links' : 'No strong suggestions found.'
984
+ }
985
+
929
986
  const loadNodeDetails = async (nodeId) => {
930
987
  if (!nodeId) {
931
988
  return
@@ -965,6 +1022,8 @@ const loadNodeDetails = async (nodeId) => {
965
1022
  elements.contentOutgoing.innerHTML = list(related.outgoing)
966
1023
  elements.contentIncoming.innerHTML = list(related.incoming)
967
1024
  elements.contentBody.textContent = typeof node.content === 'string' ? node.content : ''
1025
+ elements.contentActionStatus.textContent = ''
1026
+ elements.contentLinkSuggestions.innerHTML = ''
968
1027
 
969
1028
  openContentDialog()
970
1029
  }
@@ -1518,6 +1577,31 @@ const setupControls = () => {
1518
1577
  closeContentDialog()
1519
1578
  })
1520
1579
 
1580
+ elements.copyWikiLink.addEventListener('click', () => {
1581
+ copySelectedWikiLink().catch((error) => {
1582
+ elements.contentActionStatus.textContent = error instanceof Error ? error.message : String(error)
1583
+ })
1584
+ })
1585
+
1586
+ elements.suggestNodeLinks.addEventListener('click', () => {
1587
+ loadSelectedLinkSuggestions().catch((error) => {
1588
+ elements.contentActionStatus.textContent = error instanceof Error ? error.message : String(error)
1589
+ })
1590
+ })
1591
+
1592
+ elements.contentLinkSuggestions.addEventListener('click', (event) => {
1593
+ const button = event.target.closest('button[data-title]')
1594
+ if (!button) {
1595
+ return
1596
+ }
1597
+ const value = '[[' + button.dataset.title + ']]'
1598
+ navigator.clipboard.writeText(value).then(() => {
1599
+ elements.contentActionStatus.textContent = 'Copied ' + value
1600
+ }).catch(() => {
1601
+ elements.contentActionStatus.textContent = value
1602
+ })
1603
+ })
1604
+
1521
1605
  elements.contentDialog.addEventListener('click', (event) => {
1522
1606
  if (event.target === elements.contentDialog) {
1523
1607
  closeContentDialog()
@@ -0,0 +1,54 @@
1
+ import { relative } from 'node:path';
2
+ import { addNoteWithMetadata } from './add-note.js';
3
+ import { buildRememberSuggestion } from './memory-suggestions.js';
4
+ import { indexVault } from './index-vault.js';
5
+ import { ensureVault, readMarkdownFiles } from '../infrastructure/file-system-vault.js';
6
+ const compactPreview = (content) => content
7
+ .replace(/^---[\s\S]*?\n---\n?/, '')
8
+ .replace(/\s+/g, ' ')
9
+ .trim()
10
+ .slice(0, 180);
11
+ const extractTitle = (content, fallback) => content.match(/^#\s+(.+)$/m)?.[1]?.trim() || fallback;
12
+ export const addInboxItem = async (input) => {
13
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
14
+ const result = await addNoteWithMetadata(input.vaultPath, `Inbox ${timestamp}`, `${input.content.trim()}\n\n#inbox #triage`, input.agentId, {
15
+ autoContextLinks: false
16
+ });
17
+ const index = input.autoIndex === false ? null : await indexVault(input.vaultPath);
18
+ return {
19
+ item: result,
20
+ index
21
+ };
22
+ };
23
+ export const listInboxItems = async (vaultPath, limit = 20) => {
24
+ const absoluteVaultPath = await ensureVault(vaultPath);
25
+ const files = await readMarkdownFiles(absoluteVaultPath);
26
+ return files
27
+ .filter((file) => /(^|\s)#inbox\b/.test(file.content))
28
+ .sort((left, right) => right.updatedAt.getTime() - left.updatedAt.getTime())
29
+ .slice(0, Math.max(0, limit))
30
+ .map((file) => ({
31
+ title: extractTitle(file.content, 'Inbox item'),
32
+ path: relative(absoluteVaultPath, file.absolutePath),
33
+ updatedAt: file.updatedAt.toISOString(),
34
+ preview: compactPreview(file.content)
35
+ }));
36
+ };
37
+ export const processInboxItems = async (input) => {
38
+ const items = await listInboxItems(input.vaultPath, input.limit ?? 10);
39
+ const suggestions = await Promise.all(items.map(async (item) => {
40
+ const suggestion = await buildRememberSuggestion({
41
+ vaultPath: input.vaultPath,
42
+ content: item.preview,
43
+ agentId: input.agentId,
44
+ linkLimit: 5
45
+ });
46
+ return {
47
+ ...item,
48
+ suggestedTitle: suggestion.title,
49
+ suggestedTags: suggestion.tags,
50
+ suggestedLinks: suggestion.links.map((link) => link.title)
51
+ };
52
+ }));
53
+ return suggestions;
54
+ };
@@ -0,0 +1,220 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { basename, extname } from 'node:path';
3
+ import { ensureVault } from '../infrastructure/file-system-vault.js';
4
+ import { searchKnowledge } from './search-knowledge.js';
5
+ import { getGraphSummary } from './get-graph-summary.js';
6
+ import { getBrokenLinksReport } from './analyze-vault.js';
7
+ const stopwords = new Set([
8
+ 'about',
9
+ 'after',
10
+ 'agent',
11
+ 'agents',
12
+ 'antes',
13
+ 'brainlink',
14
+ 'com',
15
+ 'como',
16
+ 'das',
17
+ 'dos',
18
+ 'for',
19
+ 'from',
20
+ 'para',
21
+ 'por',
22
+ 'que',
23
+ 'the',
24
+ 'this',
25
+ 'uma',
26
+ 'with'
27
+ ]);
28
+ const wordPattern = /[\p{L}\p{N}_-]+/gu;
29
+ const tagPattern = /(^|\s)#([A-Za-z0-9][A-Za-z0-9_-]*)/g;
30
+ const unique = (values) => Array.from(new Set(values));
31
+ const normalizeText = (value) => value
32
+ .normalize('NFKD')
33
+ .replace(/\p{Diacritic}/gu, '')
34
+ .toLowerCase();
35
+ const words = (value) => normalizeText(value)
36
+ .match(wordPattern)
37
+ ?.filter((word) => word.length > 2 && !stopwords.has(word)) ?? [];
38
+ const titleCase = (value) => value
39
+ .split(/\s+/)
40
+ .filter(Boolean)
41
+ .map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`)
42
+ .join(' ');
43
+ const stripMarkdownNoise = (value) => value
44
+ .replace(/^---[\s\S]*?\n---\n?/, '')
45
+ .replace(/```[\s\S]*?```/g, ' ')
46
+ .replace(/[#>*_\-[\]()`]/g, ' ')
47
+ .replace(/\s+/g, ' ')
48
+ .trim();
49
+ export const inferMemoryTitle = (content, fallback = 'Memory Note') => {
50
+ const heading = content.match(/^#{1,6}\s+(.+)$/m)?.[1]?.trim();
51
+ if (heading)
52
+ return heading.replace(/\s+#\w[\w-]*/g, '').trim();
53
+ const firstSentence = stripMarkdownNoise(content).split(/[.!?\n]/)[0]?.trim();
54
+ if (!firstSentence)
55
+ return fallback;
56
+ return titleCase(firstSentence.split(/\s+/).slice(0, 8).join(' '));
57
+ };
58
+ export const inferMemoryTags = (content, extraTags = []) => {
59
+ const explicit = Array.from(content.matchAll(tagPattern), (match) => match[2]);
60
+ const weighted = words(content).reduce((state, word) => {
61
+ state.set(word, (state.get(word) ?? 0) + 1);
62
+ return state;
63
+ }, new Map());
64
+ const inferred = Array.from(weighted.entries())
65
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
66
+ .map(([word]) => word.replace(/[^a-z0-9_-]/g, '-'))
67
+ .filter((word) => word.length >= 3)
68
+ .slice(0, 4);
69
+ return unique([...extraTags, ...explicit, ...inferred])
70
+ .map((tag) => tag.replace(/^#/, '').trim())
71
+ .filter(Boolean)
72
+ .slice(0, 8);
73
+ };
74
+ const scoreTitleAgainstText = (title, content) => {
75
+ const textWords = new Set(words(content));
76
+ const titleWords = words(title);
77
+ if (titleWords.length === 0)
78
+ return 0;
79
+ const hits = titleWords.filter((word) => textWords.has(word)).length;
80
+ return Number((hits / titleWords.length).toFixed(4));
81
+ };
82
+ const editDistance = (left, right) => {
83
+ const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
84
+ const current = Array.from({ length: right.length + 1 }, () => 0);
85
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
86
+ current[0] = leftIndex;
87
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
88
+ const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
89
+ current[rightIndex] = Math.min(previous[rightIndex] + 1, current[rightIndex - 1] + 1, previous[rightIndex - 1] + cost);
90
+ }
91
+ for (let index = 0; index < previous.length; index += 1) {
92
+ previous[index] = current[index];
93
+ }
94
+ }
95
+ return previous[right.length] ?? 0;
96
+ };
97
+ const titleSimilarity = (left, right) => {
98
+ const normalizedLeft = normalizeText(left).replace(/\s+/g, ' ').trim();
99
+ const normalizedRight = normalizeText(right).replace(/\s+/g, ' ').trim();
100
+ if (!normalizedLeft || !normalizedRight)
101
+ return 0;
102
+ if (normalizedLeft === normalizedRight)
103
+ return 1;
104
+ const distance = editDistance(normalizedLeft, normalizedRight);
105
+ const maxLength = Math.max(normalizedLeft.length, normalizedRight.length);
106
+ return Number(Math.max(0, 1 - distance / maxLength).toFixed(4));
107
+ };
108
+ export const suggestContextLinks = async (vaultPath, content, agentId, limit) => {
109
+ await ensureVault(vaultPath);
110
+ const graph = await getGraphSummary(vaultPath, agentId);
111
+ const byTitle = new Map();
112
+ for (const node of graph.nodes) {
113
+ const score = scoreTitleAgainstText(node.title, content);
114
+ if (score > 0) {
115
+ byTitle.set(node.title.toLowerCase(), {
116
+ title: node.title,
117
+ path: node.path,
118
+ score,
119
+ reason: 'title words appear in the new memory'
120
+ });
121
+ }
122
+ }
123
+ const query = stripMarkdownNoise(content).slice(0, 500);
124
+ const searchResults = query.length > 0
125
+ ? await searchKnowledge(vaultPath, query, Math.max(limit * 2, 6), agentId, 'hybrid')
126
+ : [];
127
+ for (const result of searchResults) {
128
+ const key = result.title.toLowerCase();
129
+ const current = byTitle.get(key);
130
+ const score = Number(Math.min(1, Math.max(result.score / 20, result.semanticScore)).toFixed(4));
131
+ if (!current || score > current.score) {
132
+ byTitle.set(key, {
133
+ title: result.title,
134
+ path: result.path,
135
+ score,
136
+ reason: result.textScore > 0 ? 'search matched title, tags or content' : 'semantic search found related content'
137
+ });
138
+ }
139
+ }
140
+ return Array.from(byTitle.values())
141
+ .sort((left, right) => right.score - left.score || left.title.localeCompare(right.title))
142
+ .slice(0, Math.max(0, limit));
143
+ };
144
+ export const buildRememberSuggestion = async (input) => {
145
+ const title = input.title?.trim() || inferMemoryTitle(input.content);
146
+ const tags = inferMemoryTags(input.content, input.tags ?? []);
147
+ const suggestedLinks = await suggestContextLinks(input.vaultPath, input.content, input.agentId, input.linkLimit ?? 5);
148
+ const explicitLinks = (input.links ?? []).map((link) => ({
149
+ title: link,
150
+ path: '',
151
+ score: 1,
152
+ reason: 'provided explicitly'
153
+ }));
154
+ const links = unique([...explicitLinks, ...suggestedLinks].map((link) => link.title))
155
+ .map((linkTitle) => explicitLinks.find((link) => link.title === linkTitle) ?? suggestedLinks.find((link) => link.title === linkTitle))
156
+ .filter((link) => Boolean(link));
157
+ const tagLine = tags.length > 0 ? `\n\n${tags.map((tag) => `#${tag}`).join(' ')}` : '';
158
+ const linkSection = links.length > 0
159
+ ? `\n\n## Context Links\n\n${links.map((link) => `- [[${link.title}]]`).join('\n')}`
160
+ : '';
161
+ return {
162
+ title,
163
+ tags,
164
+ links,
165
+ content: `${input.content.trim()}${tagLine}${linkSection}`.trim()
166
+ };
167
+ };
168
+ export const suggestBrokenLinkFixes = async (vaultPath, agentId, limit) => {
169
+ const [brokenLinks, graph] = await Promise.all([
170
+ getBrokenLinksReport(vaultPath, agentId),
171
+ getGraphSummary(vaultPath, agentId)
172
+ ]);
173
+ return brokenLinks.map((link) => ({
174
+ ...link,
175
+ candidates: graph.nodes
176
+ .map((node) => ({
177
+ title: node.title,
178
+ path: node.path,
179
+ score: Math.max(scoreTitleAgainstText(link.toTitle, `${node.title} ${node.path}`), titleSimilarity(link.toTitle, node.title)),
180
+ reason: 'similar title/path'
181
+ }))
182
+ .filter((candidate) => candidate.score > 0)
183
+ .sort((left, right) => right.score - left.score || left.title.localeCompare(right.title))
184
+ .slice(0, Math.max(0, limit))
185
+ }));
186
+ };
187
+ export const explainSearchResults = async (vaultPath, query, limit, agentId, mode) => {
188
+ const queryWords = new Set(words(query));
189
+ const results = await searchKnowledge(vaultPath, query, limit, agentId, mode);
190
+ return results.map((result) => {
191
+ const titleWords = words(result.title).filter((word) => queryWords.has(word));
192
+ const tagWords = result.tags.filter((tag) => queryWords.has(normalizeText(tag)));
193
+ const contentWords = words(result.content).filter((word) => queryWords.has(word));
194
+ const reasons = [
195
+ ...(titleWords.length > 0 ? [`title matched: ${unique(titleWords).join(', ')}`] : []),
196
+ ...(tagWords.length > 0 ? [`tags matched: ${unique(tagWords).join(', ')}`] : []),
197
+ ...(contentWords.length > 0 ? [`content matched: ${unique(contentWords).slice(0, 8).join(', ')}`] : []),
198
+ ...(result.semanticScore > 0 ? [`semantic score: ${result.semanticScore.toFixed(3)}`] : []),
199
+ `text score: ${result.textScore.toFixed(3)}`,
200
+ `final score: ${result.score.toFixed(3)}`
201
+ ];
202
+ return {
203
+ ...result,
204
+ reasons
205
+ };
206
+ });
207
+ };
208
+ export const readMemoryContentInput = async (input) => {
209
+ if (input.content != null && input.content.trim().length > 0) {
210
+ return input.content;
211
+ }
212
+ if (input.contentFile != null && input.contentFile.trim().length > 0) {
213
+ return readFile(input.contentFile, 'utf8');
214
+ }
215
+ throw new Error('Use --content or --content-file to provide content.');
216
+ };
217
+ export const inferTitleFromFilePath = (filePath) => basename(filePath, extname(filePath))
218
+ .replace(/[-_]+/g, ' ')
219
+ .replace(/\s+/g, ' ')
220
+ .trim();
@@ -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
+ };