@andespindola/brainlink 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,21 +549,27 @@ 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`).
558
569
  If you disable `autoBootstrapOnRead` through `brainlink_policy`, read tools return a preflight instruction with suggested `brainlink_bootstrap` arguments.
559
570
  `brainlink_bootstrap`, `brainlink_policy` and preflight responses include structured `nextActions` so MCP clients can continue automatically without custom parsing.
560
571
  For one-call planning, use `brainlink_recommendations` to get the recommended tool sequence for the current vault/agent/query.
561
- The MCP context tools are plug-and-play by default: omit `strategy` to use the configured default (`rag` unless changed), pass `strategy: "cag"` for repeated/stable task context, or pass `strategy: "auto"` so Brainlink chooses CAG on fresh pack hits and RAG otherwise. `brainlink_recommendations`, preflight responses and policy next actions include executable context arguments so clients can continue without custom parsing.
572
+ The MCP context tools are plug-and-play by default: omit `strategy` to use the configured token-efficient default (`auto` unless changed), pass `strategy: "cag"` for repeated/stable task context, or pass `strategy: "rag"` when fresh retrieval is required. `brainlink_recommendations`, preflight responses and policy next actions include executable context arguments so clients can continue without custom parsing.
562
573
 
563
574
  The same linking rule applies through MCP: `brainlink_context` is read-only, and real graph links require Markdown notes with explicit `## Context Links` sections. `brainlink_add_note` and `brainlink_add_file` reindex by default and include index + `writeConnectivity` metadata. Brainlink does not auto-link new notes to fallback hubs.
564
575
 
@@ -646,8 +657,8 @@ Routes:
646
657
  - `GET /api/graph-view?x=<x>&y=<y>&w=<width>&h=<height>&scale=<scale>`
647
658
  - `GET /api/graph-stream?x=<x>&y=<y>&w=<width>&h=<height>&scale=<scale>&nodeBudget=<n>&edgeBudget=<n>`
648
659
  - `GET /api/graph-node?id=<node-id>`
649
- - `GET /api/search?q=<query>&limit=10&mode=hybrid`
650
- - `GET /api/context?q=<query>&limit=12&tokens=2000&mode=hybrid`
660
+ - `GET /api/search?q=<query>&limit=8&mode=hybrid`
661
+ - `GET /api/context?q=<query>&limit=8&tokens=1500&mode=hybrid`
651
662
  - `GET /api/links`
652
663
  - `GET /api/backlinks?title=<title>`
653
664
  - `GET /api/stats`
@@ -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
@@ -904,7 +936,7 @@ Context selection uses a middle-out strategy: it starts from the strongest chunk
904
936
  ### `context`
905
937
 
906
938
  ```bash
907
- blink context "question" --vault ./vault --limit 12 --tokens 2000
939
+ blink context "question" --vault ./vault --limit 8 --tokens 1500
908
940
  blink context "question" --vault ./vault --agent coding-agent --json
909
941
  blink context "question" --vault ./vault --agent coding-agent --mode hybrid --json
910
942
  blink context "question" --vault ./vault --agent coding-agent --strategy cag --json
@@ -915,7 +947,7 @@ blink context-packs --vault ./vault --stale --clear
915
947
 
916
948
  Builds a compact context package for an agent.
917
949
  Repeated calls with the same vault, agent, query, mode and token/limit settings are served from a short in-memory cache while the index is unchanged.
918
- The default strategy is configured by `defaultContextStrategy` and starts as `rag`, which retrieves and assembles context from the current index. `--strategy cag` enables cache-augmented context generation by reading or refreshing a persisted context pack under `.brainlink/context-packs`; `--strategy auto` uses CAG when a fresh pack exists and RAG otherwise, refreshing a pack for future calls. Context responses include `cache`, `metrics`, `requestedStrategy` and `recommendedStrategy` metadata. Packs are derived artifacts and become stale when the index or volatile memory signature changes.
950
+ The default strategy is configured by `defaultContextStrategy` and starts as `auto`, which uses CAG when a fresh pack exists and RAG otherwise, refreshing a pack for future calls. `--strategy cag` enables cache-augmented context generation by reading or refreshing a persisted context pack under `.brainlink/context-packs`; `--strategy rag` forces fresh retrieval assembly from the current index. Context responses include `cache`, `metrics`, `requestedStrategy` and `recommendedStrategy` metadata. Packs are derived artifacts and become stale when the index or volatile memory signature changes.
919
951
 
920
952
  ### `links`
921
953
 
@@ -1056,9 +1088,9 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
1056
1088
  "defaultAgent": "shared",
1057
1089
  "autoIndexOnWrite": true,
1058
1090
  "autoCanonicalContextLinks": true,
1059
- "defaultSearchLimit": 10,
1060
- "defaultContextTokens": 2000,
1061
- "defaultContextStrategy": "rag",
1091
+ "defaultSearchLimit": 8,
1092
+ "defaultContextTokens": 1500,
1093
+ "defaultContextStrategy": "auto",
1062
1094
  "embeddingProvider": "local",
1063
1095
  "defaultSearchMode": "hybrid",
1064
1096
  "chunkSize": 1200,
@@ -1073,7 +1105,7 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
1073
1105
  "coding-agent": {
1074
1106
  "defaultSearchMode": "semantic",
1075
1107
  "defaultSearchLimit": 8,
1076
- "defaultContextTokens": 2400,
1108
+ "defaultContextTokens": 1500,
1077
1109
  "defaultContextStrategy": "auto"
1078
1110
  },
1079
1111
  "*": {
@@ -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();