@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 +32 -0
- 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 +8 -0
- 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/mcp/server.js +51 -1
- package/dist/mcp/tools.js +264 -1
- package/docs/AGENT_USAGE.md +11 -0
- package/docs/QUICKSTART.md +13 -0
- 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,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
|
+
};
|