@andespindola/brainlink 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -9
- package/dist/application/frontend/client-css.js +34 -0
- package/dist/application/frontend/client-html.js +9 -0
- package/dist/application/frontend/client-js.js +85 -1
- package/dist/application/inbox.js +54 -0
- package/dist/application/memory-suggestions.js +220 -0
- package/dist/application/operational-workflows.js +153 -0
- package/dist/application/repair-broken-links.js +157 -0
- package/dist/application/server/routes.js +20 -9
- package/dist/cli/commands/practical-commands.js +278 -0
- package/dist/cli/commands/read-commands.js +13 -0
- package/dist/cli/commands/write-commands.js +13 -0
- package/dist/cli/main.js +2 -0
- package/dist/infrastructure/config.js +4 -4
- package/dist/mcp/server.js +51 -1
- package/dist/mcp/tools.js +264 -1
- package/docs/AGENT_USAGE.md +15 -4
- package/docs/QUICKSTART.md +14 -1
- package/package.json +1 -1
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 (`
|
|
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=
|
|
650
|
-
- `GET /api/context?q=<query>&limit=
|
|
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
|
|
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 `
|
|
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":
|
|
1060
|
-
"defaultContextTokens":
|
|
1061
|
-
"defaultContextStrategy": "
|
|
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":
|
|
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();
|