brainllm 12.6.4 → 12.7.1
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 +2 -2
- package/SECURITY.md +2 -2
- package/dist/index.js +359 -61
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/brainllm/SKILL.md +8 -5
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ A persistent, graph-structured second brain for Claude and any MCP client — bu
|
|
|
10
10
|
|
|
11
11
|
[**brainllm site**](https://miisodev.github.io/BrainLLM/) · [How it works](https://miisodev.github.io/BrainLLM/how-it-works.html) · [Use cases](https://miisodev.github.io/BrainLLM/use-cases.html) · [Docs](https://miisodev.github.io/BrainLLM/docs.html)
|
|
12
12
|
|
|
13
|
-
[](https://github.com/miisodev/BrainLLM/releases)
|
|
14
14
|
[](https://github.com/miisodev/BrainLLM/actions/workflows/ci.yml)
|
|
15
15
|
[](./LICENSE)
|
|
16
16
|
[](https://bun.sh)
|
|
@@ -54,7 +54,7 @@ Placement, naming, labels, deduplication, relation bookkeeping, lifecycle aging,
|
|
|
54
54
|
- **Timeless notes, dated records** — knowledge, information and thread books hold no state or history (only a domain's *Current State* does); `maintain(deep=true)` flags dated prose that creeps in. Every note except a log carries an icon, set by kind and backfilled by the sweep.
|
|
55
55
|
- **Resilient plumbing** — every backend call is timeout-bounded with retry on idempotent reads; routine core writes are idempotent or duplicate-guarded, while full-mode raw overwrites remain explicit; content surgery survives the editor's own HTML rewriting; renaming a domain cascades to everything inside it; the maintenance sweep heals drift it finds.
|
|
56
56
|
- **Multi-agent by default** — all write-classified tools serialize behind a process-wide FIFO lock, so two agents (an interactive session and an automated run, say) writing through one hosted instance queue in arrival order instead of racing; reads stay fully parallel. Deletion catch-up in the daily log keeps a note deleted after its day's close from vanishing without a trace — and its window is configurable (`deletionCatchupDays` in the lifecycle policy), defaulting to Trilium's 7-day retention.
|
|
57
|
-
- **Ask in prose, split on a seam** — `consistency(subject="…")` finds every note asserting about a fact however it is phrased, no regex guessing; `consistency(pattern, staleAfterDays=N)` surfaces figures held in exactly one untouched note, the ones that rot silently because nothing disagrees with them; `read(ids=[…])` batches a multi-note orientation into one round trip; and `split(noteId, sections=[…], into="…")` is the write half of the oversized-note problem — it lifts whole sections into a new note and leaves a pointer back. Information notes can carry a `#mandate` marker so a scoped session finds the one note it must obey without reading every note's prose.
|
|
57
|
+
- **Ask in prose, split on a seam** — `consistency(subject="…")` finds every note asserting about a fact however it is phrased, no regex guessing; `consistency(pattern, staleAfterDays=N)` surfaces figures held in exactly one untouched note, the ones that rot silently because nothing disagrees with them, and skips sessions and diaries, which are history rather than claims; `diff(since="today")` reviews every note a session changed in one call; `revise(find="Phase 2", within="tr")` edits, inserts or removes a table row named by a few words inside it; `read(ids=[…])` batches a multi-note orientation into one round trip; and `split(noteId, sections=[…], into="…")` is the write half of the oversized-note problem — it lifts whole sections into a new note and leaves a pointer back. Information notes can carry a `#mandate` marker so a scoped session finds the one note it must obey without reading every note's prose.
|
|
58
58
|
|
|
59
59
|
---
|
|
60
60
|
|
package/SECURITY.md
CHANGED
package/dist/index.js
CHANGED
|
@@ -20488,6 +20488,27 @@ function checkedDate(value) {
|
|
|
20488
20488
|
function localNowTime() {
|
|
20489
20489
|
return localNowDateTime().slice(11, 16);
|
|
20490
20490
|
}
|
|
20491
|
+
function sinceCutoff(value) {
|
|
20492
|
+
const v = value.trim().toLowerCase();
|
|
20493
|
+
if (v === "today" || v === "session")
|
|
20494
|
+
return `${localToday()} 00:00`;
|
|
20495
|
+
const m = /^(\d{4}-\d{2}-\d{2})(?:[ t](\d{2}):(\d{2}))?$/.exec(v);
|
|
20496
|
+
if (!m)
|
|
20497
|
+
return null;
|
|
20498
|
+
try {
|
|
20499
|
+
checkedDate(m[1]);
|
|
20500
|
+
} catch {
|
|
20501
|
+
return null;
|
|
20502
|
+
}
|
|
20503
|
+
if (m[2] !== undefined && (Number(m[2]) > 23 || Number(m[3]) > 59))
|
|
20504
|
+
return null;
|
|
20505
|
+
return `${m[1]} ${m[2] ?? "00"}:${m[3] ?? "00"}`;
|
|
20506
|
+
}
|
|
20507
|
+
function rollingBackupName(date) {
|
|
20508
|
+
const [y, m, d] = checkedDate(date).split("-").map(Number);
|
|
20509
|
+
const day = new Date(Date.UTC(y, m - 1, d)).getUTCDay();
|
|
20510
|
+
return `brainllm-${["sun", "mon", "tue", "wed", "thu", "fri", "sat"][day]}`;
|
|
20511
|
+
}
|
|
20491
20512
|
|
|
20492
20513
|
// src/trilium.ts
|
|
20493
20514
|
var MAX_ETAPI_CONTENT_BYTES = 50 * 1024 * 1024;
|
|
@@ -23894,6 +23915,134 @@ async function catchUpDeletions(trilium, cfg, today) {
|
|
|
23894
23915
|
return { windowDays, coverage: "full", deletionsFound: total, regenerated };
|
|
23895
23916
|
}
|
|
23896
23917
|
|
|
23918
|
+
// src/diffing.ts
|
|
23919
|
+
function revisionsSince(revisions, cutoff) {
|
|
23920
|
+
return revisions.filter((r) => (r.dateCreated ?? "").slice(0, 16) >= cutoff);
|
|
23921
|
+
}
|
|
23922
|
+
function firstRevisionSince(revisions, cutoff) {
|
|
23923
|
+
const since = revisionsSince(revisions, cutoff);
|
|
23924
|
+
if (!since.length)
|
|
23925
|
+
return null;
|
|
23926
|
+
return since.reduce((a, b) => (a.dateCreated ?? "") <= (b.dateCreated ?? "") ? a : b);
|
|
23927
|
+
}
|
|
23928
|
+
function blockDiff(before, after, pad = 1, cap = 40) {
|
|
23929
|
+
if (before === after)
|
|
23930
|
+
return { identical: true, sizeBefore: before.length, sizeAfter: after.length, summary: "Identical: the writes left the body byte-for-byte as it was." };
|
|
23931
|
+
const lines = (s) => s.replace(/></g, `>
|
|
23932
|
+
<`).split(`
|
|
23933
|
+
`);
|
|
23934
|
+
const a = lines(before);
|
|
23935
|
+
const b = lines(after);
|
|
23936
|
+
let head = 0;
|
|
23937
|
+
while (head < a.length && head < b.length && a[head] === b[head])
|
|
23938
|
+
head++;
|
|
23939
|
+
let tail = 0;
|
|
23940
|
+
while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail])
|
|
23941
|
+
tail++;
|
|
23942
|
+
const midA = a.slice(head, a.length - tail);
|
|
23943
|
+
const midB = b.slice(head, b.length - tail);
|
|
23944
|
+
const lcs = changedLines(midA, midB);
|
|
23945
|
+
const removed = lcs ? lcs.removed : midA;
|
|
23946
|
+
const added = lcs ? lcs.added : midB;
|
|
23947
|
+
const bound = (arr) => arr.length > cap ? [...arr.slice(0, cap), `\u2026 ${arr.length - cap} more line(s)`] : arr;
|
|
23948
|
+
const delta = after.length - before.length;
|
|
23949
|
+
return {
|
|
23950
|
+
identical: false,
|
|
23951
|
+
sizeBefore: before.length,
|
|
23952
|
+
sizeAfter: after.length,
|
|
23953
|
+
contextBefore: bound(a.slice(Math.max(0, head - pad), head)),
|
|
23954
|
+
removed: bound(removed),
|
|
23955
|
+
added: bound(added),
|
|
23956
|
+
contextAfter: bound(a.slice(a.length - tail, a.length - tail + pad)),
|
|
23957
|
+
summary: `${lcs ? `${lcs.hunks} change(s): ` : ""}${removed.length} block(s) removed, ${added.length} added, ${delta >= 0 ? "+" : ""}${delta} characters.`
|
|
23958
|
+
};
|
|
23959
|
+
}
|
|
23960
|
+
function changedLines(a, b, budget = 4000000) {
|
|
23961
|
+
const n = a.length;
|
|
23962
|
+
const m = b.length;
|
|
23963
|
+
if ((n + 1) * (m + 1) > budget)
|
|
23964
|
+
return null;
|
|
23965
|
+
const w = m + 1;
|
|
23966
|
+
const t = new Uint32Array((n + 1) * w);
|
|
23967
|
+
for (let i = n - 1;i >= 0; i--)
|
|
23968
|
+
for (let j = m - 1;j >= 0; j--)
|
|
23969
|
+
t[i * w + j] = a[i] === b[j] ? t[(i + 1) * w + j + 1] + 1 : Math.max(t[(i + 1) * w + j], t[i * w + j + 1]);
|
|
23970
|
+
const removed = [];
|
|
23971
|
+
const added = [];
|
|
23972
|
+
let hunks = 0;
|
|
23973
|
+
let inHunk = false;
|
|
23974
|
+
let i = 0;
|
|
23975
|
+
let j = 0;
|
|
23976
|
+
while (i < n || j < m) {
|
|
23977
|
+
if (i < n && j < m && a[i] === b[j]) {
|
|
23978
|
+
inHunk = false;
|
|
23979
|
+
i++;
|
|
23980
|
+
j++;
|
|
23981
|
+
continue;
|
|
23982
|
+
}
|
|
23983
|
+
if (!inHunk) {
|
|
23984
|
+
hunks++;
|
|
23985
|
+
inHunk = true;
|
|
23986
|
+
}
|
|
23987
|
+
if (j >= m || i < n && t[(i + 1) * w + j] >= t[i * w + j + 1])
|
|
23988
|
+
removed.push(a[i++]);
|
|
23989
|
+
else
|
|
23990
|
+
added.push(b[j++]);
|
|
23991
|
+
}
|
|
23992
|
+
return { removed, added, hunks };
|
|
23993
|
+
}
|
|
23994
|
+
|
|
23995
|
+
// src/elements.ts
|
|
23996
|
+
var WITHIN_TAGS = ["tr", "td", "th", "li", "p", "blockquote", "figure", "table", "ul", "ol"];
|
|
23997
|
+
function elementSpans(html, tag) {
|
|
23998
|
+
const rx = new RegExp(`<(/?)${tag}(?=[\\s>/])[^>]*>`, "gi");
|
|
23999
|
+
const spans = [];
|
|
24000
|
+
const open = [];
|
|
24001
|
+
let m;
|
|
24002
|
+
while ((m = rx.exec(html)) !== null) {
|
|
24003
|
+
if (m[1] === "/") {
|
|
24004
|
+
const start = open.pop();
|
|
24005
|
+
if (start !== undefined)
|
|
24006
|
+
spans.push({ start, end: m.index + m[0].length });
|
|
24007
|
+
} else if (!m[0].endsWith("/>")) {
|
|
24008
|
+
open.push(m.index);
|
|
24009
|
+
}
|
|
24010
|
+
}
|
|
24011
|
+
return spans.sort((a, b) => a.start - b.start);
|
|
24012
|
+
}
|
|
24013
|
+
function containingElement(html, tag, from, to) {
|
|
24014
|
+
let best = null;
|
|
24015
|
+
for (const s of elementSpans(html, tag)) {
|
|
24016
|
+
if (s.start <= from && s.end >= to && (!best || s.end - s.start < best.end - best.start))
|
|
24017
|
+
best = s;
|
|
24018
|
+
}
|
|
24019
|
+
return best;
|
|
24020
|
+
}
|
|
24021
|
+
function editWithin(html, hits, tag, action, body, nth) {
|
|
24022
|
+
if (!hits.length)
|
|
24023
|
+
return { ok: false, reason: "no_match", matches: 0 };
|
|
24024
|
+
const seen = new Map;
|
|
24025
|
+
for (const h of hits) {
|
|
24026
|
+
const el = containingElement(html, tag, h.start, h.start + h.length);
|
|
24027
|
+
if (el)
|
|
24028
|
+
seen.set(el.start, el);
|
|
24029
|
+
}
|
|
24030
|
+
const elements = [...seen.values()].sort((a, b) => a.start - b.start);
|
|
24031
|
+
if (!elements.length)
|
|
24032
|
+
return { ok: false, reason: "no_element", matches: hits.length };
|
|
24033
|
+
const preview = (s) => html.slice(s.start, Math.min(s.end, s.start + 160));
|
|
24034
|
+
let target;
|
|
24035
|
+
if (nth !== undefined)
|
|
24036
|
+
target = elements[nth - 1];
|
|
24037
|
+
else if (elements.length === 1)
|
|
24038
|
+
target = elements[0];
|
|
24039
|
+
if (!target)
|
|
24040
|
+
return { ok: false, reason: "ambiguous", matches: elements.length, elements: elements.slice(0, 10).map(preview) };
|
|
24041
|
+
const el = html.slice(target.start, target.end);
|
|
24042
|
+
const out = action === "replace" ? html.slice(0, target.start) + body + html.slice(target.end) : action === "remove" ? html.slice(0, target.start) + html.slice(target.end) : action === "before" ? html.slice(0, target.start) + body + html.slice(target.start) : html.slice(0, target.end) + body + html.slice(target.end);
|
|
24043
|
+
return { ok: true, html: out, element: el, matches: elements.length };
|
|
24044
|
+
}
|
|
24045
|
+
|
|
23897
24046
|
// src/tools-surface.ts
|
|
23898
24047
|
var txt = (obj) => ({
|
|
23899
24048
|
content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }]
|
|
@@ -24062,13 +24211,15 @@ Resolution plus an index of its [yyyy-mm-dd] children (newest first: the latest
|
|
|
24062
24211
|
index passes ~24k characters, unless limit= says otherwise; with the total count), each listed by its addendum blocks;
|
|
24063
24212
|
date="yyyy-mm-dd" resolves straight to one day. A collection thread returns its
|
|
24064
24213
|
Context plus its titled entries (alphabetical, with a lead). Read a child with memory(<child id>).
|
|
24065
|
-
section="<heading>" reads one section
|
|
24214
|
+
section="<heading>" reads one section. index=true on a dated thread returns only child ids,
|
|
24215
|
+
dates and block markers (no book body, no leads): the cheap answer to "what is the newest entry".`, {
|
|
24066
24216
|
...idParams,
|
|
24067
24217
|
date: stringType().optional().describe("Thread books only: resolve directly to this day's child note (yyyy-mm-dd)"),
|
|
24068
24218
|
limit: numberType().int().positive().optional().describe("Dated threads: how many of the newest day-children to index (default 30)"),
|
|
24219
|
+
index: booleanType().optional().describe("Dated threads: ids, dates and block markers only \u2014 no book body, no block leads"),
|
|
24069
24220
|
section: stringType().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole note"),
|
|
24070
24221
|
occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)")
|
|
24071
|
-
}, async ({ id: idArg, noteId, date, limit, section, occurrence }) => {
|
|
24222
|
+
}, async ({ id: idArg, noteId, date, limit, index, section, occurrence }) => {
|
|
24072
24223
|
const id = idArg ?? noteId;
|
|
24073
24224
|
if (!id)
|
|
24074
24225
|
return missingId("memory");
|
|
@@ -24101,6 +24252,21 @@ section="<heading>" reads one section.`, {
|
|
|
24101
24252
|
orderBy: "dateCreated",
|
|
24102
24253
|
orderDirection: "desc"
|
|
24103
24254
|
}).catch(() => ({ results: [] }));
|
|
24255
|
+
if (index) {
|
|
24256
|
+
const compact = await Promise.all(children.results.map(async (c) => ({
|
|
24257
|
+
id: c.noteId,
|
|
24258
|
+
date: labelOf(c, "created") ?? c.dateCreated.slice(0, 10),
|
|
24259
|
+
blocks: addendumIndex(await trilium.getNoteContent(c.noteId).catch(() => "")).map(({ marker, identity }) => ({ marker, identity }))
|
|
24260
|
+
})));
|
|
24261
|
+
return txt({
|
|
24262
|
+
id,
|
|
24263
|
+
title: note.title,
|
|
24264
|
+
kind: "thread",
|
|
24265
|
+
totalChildren,
|
|
24266
|
+
children: compact,
|
|
24267
|
+
...totalChildren > compact.length ? { more: `Showing the newest ${compact.length} of ${totalChildren}. Pass limit= for more.` } : {}
|
|
24268
|
+
});
|
|
24269
|
+
}
|
|
24104
24270
|
const indexed = await Promise.all(children.results.map(async (c) => {
|
|
24105
24271
|
const content = await trilium.getNoteContent(c.noteId).catch(() => "");
|
|
24106
24272
|
const blocks = addendumIndex(content);
|
|
@@ -24300,6 +24466,12 @@ function registerTools(server, trilium, brainRef) {
|
|
|
24300
24466
|
preCloseSeq = 0;
|
|
24301
24467
|
};
|
|
24302
24468
|
const RECORD_KINDS = new Set(["session", "diary", "log", "threadEntry"]);
|
|
24469
|
+
const isRecordNote = (n) => {
|
|
24470
|
+
const kind = ownedLabel(n, "noteType");
|
|
24471
|
+
if (kind === "session" || kind === "diary" || kind === "log")
|
|
24472
|
+
return true;
|
|
24473
|
+
return kind === "threadEntry" && /^\[\d{4}-\d{2}-\d{2}\]$/.test(n.title.trim());
|
|
24474
|
+
};
|
|
24303
24475
|
const structuralFindings = (kind, html) => {
|
|
24304
24476
|
if (!html || kind && RECORD_KINDS.has(kind))
|
|
24305
24477
|
return {};
|
|
@@ -24678,7 +24850,7 @@ ${contentBlock}` }));
|
|
|
24678
24850
|
await trilium.addRelation(logReport.noteId, "references", noteId).catch(() => null);
|
|
24679
24851
|
}
|
|
24680
24852
|
let backupStatus = "disabled";
|
|
24681
|
-
let backupName =
|
|
24853
|
+
let backupName = rollingBackupName(d);
|
|
24682
24854
|
if (backup !== false) {
|
|
24683
24855
|
try {
|
|
24684
24856
|
await trilium.createBackup(backupName);
|
|
@@ -24702,11 +24874,11 @@ ${contentBlock}` }));
|
|
|
24702
24874
|
...warnings.length ? { sanitized: warnings } : {}
|
|
24703
24875
|
});
|
|
24704
24876
|
});
|
|
24705
|
-
server.tool("backup", `Named database snapshot. close() already backs up; use this before a large restructure.`, {
|
|
24706
|
-
name: stringType().optional().describe("Backup name without extension (default: brainllm-
|
|
24877
|
+
server.tool("backup", `Named database snapshot. close() already backs up into a rolling weekday slot (brainllm-mon \u2026 brainllm-sun, overwritten weekly); use this before a large restructure. A named backup is kept until someone deletes it on the server, so name milestones sparingly: every one is a full copy of the database on Trilium's volume.`, {
|
|
24878
|
+
name: stringType().optional().describe("Backup name without extension (default: today's rolling slot, e.g. brainllm-thu). A descriptive name keeps a milestone.")
|
|
24707
24879
|
}, async ({ name }) => {
|
|
24708
24880
|
const d = today();
|
|
24709
|
-
const backupName = name ??
|
|
24881
|
+
const backupName = name ?? rollingBackupName(d);
|
|
24710
24882
|
try {
|
|
24711
24883
|
await trilium.createBackup(backupName);
|
|
24712
24884
|
return txt3({ ok: true, backup: backupName, backupStatus: "completed", date: d });
|
|
@@ -24768,7 +24940,7 @@ Kinds: biography | goals | preferences | responsibilities | protocols (singleton
|
|
|
24768
24940
|
Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopting an existing note, and check action= on the receipt. connect=[{relation, toNoteId}] wires relations in the same call. diary/session/log/claim/domain have dedicated paths.`, {
|
|
24769
24941
|
kind: enumType(Kinds).describe("What kind of memory this is"),
|
|
24770
24942
|
title: stringType().optional().describe("Title \u2014 collection kinds (thread/knowledge/information sub-category); ignored for singletons & sources"),
|
|
24771
|
-
body: stringType().optional().describe("Content: plain text, markdown, or HTML"),
|
|
24943
|
+
body: stringType().optional().describe("Content: plain text, markdown, or HTML. Send real tags: entity-escaped markup (<p>) is decoded and reported in sanitized[]"),
|
|
24772
24944
|
goal: stringType().optional().describe("thread creation: the goal statement \u2014 REQUIRED for a new thread (query the user for it); becomes the Context \u2192 Goal section"),
|
|
24773
24945
|
shape: enumType(["dated", "collection"]).optional().describe('thread creation: "dated" (default \u2014 one [yyyy-mm-dd] child per active day) or "collection" (one titled child per item, e.g. an ideas list)'),
|
|
24774
24946
|
thread: stringType().optional().describe(`kind="threadEntry" only: the collection thread's id \u2014 adds a titled child to it`),
|
|
@@ -25181,7 +25353,8 @@ ${block}`), d);
|
|
|
25181
25353
|
const notes = [
|
|
25182
25354
|
rejected ? `${rejected} backend candidate(s) did not actually match the pattern and were dropped \u2014 results are verified against the real regex, not just the search index.` : null,
|
|
25183
25355
|
tagSpanning ? `${tagSpanning} match(es) were found only after stripping markup \u2014 the phrase is split by an inline tag there.` : null,
|
|
25184
|
-
results.length === 0 ? "No bodies matched that pattern, searched both as stored HTML and tag-stripped. Note that Trilium's %= pre-filter reads a striptags'd copy, so a pattern anchored ON tags may never reach verification \u2014 consistency() scans exhaustively if you need certainty." : null
|
|
25356
|
+
results.length === 0 ? "No bodies matched that pattern, searched both as stored HTML and tag-stripped. Note that Trilium's %= pre-filter reads a striptags'd copy, so a pattern anchored ON tags may never reach verification \u2014 consistency() scans exhaustively if you need certainty." : null,
|
|
25357
|
+
results.length === 0 && regex.includes("\\") ? `The pattern reached the server as /${regex}/i. In it, \\d is a digit and \\\\ is one literal backslash (a Windows path separator); a backslash doubled once too often matches a literal backslash instead of escaping. If that is not the pattern you meant, adjust the escaping and retry.` : null
|
|
25185
25358
|
].filter(Boolean);
|
|
25186
25359
|
return txt3({
|
|
25187
25360
|
mode: "regex",
|
|
@@ -25258,7 +25431,13 @@ ${block}`), d);
|
|
|
25258
25431
|
}, async ({ name, includeArchived }) => {
|
|
25259
25432
|
const cfg = b();
|
|
25260
25433
|
const slug = slugify(name);
|
|
25261
|
-
const
|
|
25434
|
+
const CAP = 500;
|
|
25435
|
+
let capped = false;
|
|
25436
|
+
const runIn = (ancestor, q, archived = includeArchived ?? false) => ancestor ? trilium.searchNotes(q, { ancestorNoteId: ancestor, limit: CAP, fastSearch: true, includeArchivedNotes: archived }).then((r) => {
|
|
25437
|
+
if (r.results.length >= CAP)
|
|
25438
|
+
capped = true;
|
|
25439
|
+
return r.results;
|
|
25440
|
+
}).catch(() => []) : Promise.resolve([]);
|
|
25262
25441
|
const [domainContainers, byTopic, byDomain] = await Promise.all([
|
|
25263
25442
|
runIn(cfg.knowledge.domains, `#noteType=domain #domain='${slug}'`),
|
|
25264
25443
|
runIn(cfg.root, `#topic='${slug}'`),
|
|
@@ -25306,11 +25485,24 @@ ${block}`), d);
|
|
|
25306
25485
|
const k = ownedLabel(n, "noteType");
|
|
25307
25486
|
return k && k !== "domain";
|
|
25308
25487
|
}).length;
|
|
25488
|
+
let archivedOmitted = 0;
|
|
25489
|
+
if (!includeArchived) {
|
|
25490
|
+
const [archTopic, archDomain] = await Promise.all([runIn(cfg.root, `#topic='${slug}'`, true), runIn(cfg.root, `#domain='${slug}'`, true)]);
|
|
25491
|
+
const extra = new Set;
|
|
25492
|
+
for (const n of [...archTopic, ...archDomain]) {
|
|
25493
|
+
const k = ownedLabel(n, "noteType");
|
|
25494
|
+
if (k && k !== "domain" && !seen.has(n.noteId))
|
|
25495
|
+
extra.add(n.noteId);
|
|
25496
|
+
}
|
|
25497
|
+
archivedOmitted = extra.size;
|
|
25498
|
+
}
|
|
25309
25499
|
return txt3({
|
|
25310
25500
|
domain: name,
|
|
25311
25501
|
slug,
|
|
25312
25502
|
knowledgeDomain,
|
|
25313
25503
|
total,
|
|
25504
|
+
...archivedOmitted ? { archivedOmitted, archivedNote: `${archivedOmitted} archived note(s) carry this slug and are not listed; includeArchived=true shows them.` } : {},
|
|
25505
|
+
...capped ? { truncated: `A query hit its ${CAP}-note cap, so some notes may be missing; narrow with recall(domain=) or maintain(domain=).` } : {},
|
|
25314
25506
|
...staleCount ? { stale: `${staleCount} note(s) untouched ${staleAfter}d+ \u2014 marked stale:true below; re-verify against live sources before treating them as current` } : {},
|
|
25315
25507
|
groups,
|
|
25316
25508
|
...total === 0 && !knowledgeDomain ? { note: `No content found for "${slug}". Create a Knowledge domain with remember(kind="information", domain="${name}") or tag notes with topics=["${slug}"].` } : {}
|
|
@@ -25340,18 +25532,19 @@ ${block}`), d);
|
|
|
25340
25532
|
});
|
|
25341
25533
|
server.tool("revise", `Edit a note by id. A revision is taken first; "Last updated" lines are bumped on content writes.
|
|
25342
25534
|
|
|
25343
|
-
Modes: default append (a dated addendum \u2014 right only for records; a dated thread's append lands in today's child), mode=replace (whole body), section="<heading>" (replace that section's whole body, h2\u2192h3\u2192h4, occurrence= for repeats), section= + mode=before|after (insert a sibling block around the whole section), mode=prepend (top of the section body), mode=remove (delete the section). find="<exact stored text>" replaces every occurrence (nth= for one); edits=[{find, body}] applies several in one write. title= composes with every mode; retitling a domain book cascades its #domain slug.
|
|
25535
|
+
Modes: default append (a dated addendum \u2014 right only for records; a dated thread's append lands in today's child), mode=replace (whole body), section="<heading>" (replace that section's whole body, h2\u2192h3\u2192h4, occurrence= for repeats), section= + mode=before|after (insert a sibling block around the whole section), mode=prepend (top of the section body), mode=remove (delete the section). find="<exact stored text>" replaces every occurrence (nth= for one); edits=[{find, body}] applies several in one write. find= + within="tr" (or li, p, td, \u2026) acts on the element containing a short anchor: replace it, insert a row before/after it, or remove it, so a table row never needs its full stored markup. A replacement equal to its match writes nothing and reports unchanged. title= composes with every mode; retitling a domain book cascades its #domain slug.
|
|
25344
25536
|
|
|
25345
25537
|
Check the receipt: matched=false means a NEW section was written (available= lists real headings; strict=true refuses instead). A section replace swaps everything under the heading \u2014 use find= for anything smaller. find= matches stored HTML (tags literal), not rendered text, and is taken literally: a Windows path's backslash is one backslash, never doubled.`, {
|
|
25346
25538
|
noteId: stringType().describe("Note to update"),
|
|
25347
|
-
body: stringType().optional().describe("Content to add/replace: plain text, markdown, or HTML. With find=, the raw replacement string (no conversion)."),
|
|
25539
|
+
body: stringType().optional().describe("Content to add/replace: plain text, markdown, or HTML. Send real tags: entity-escaped markup (<p>) is decoded and reported in sanitized[]. With find=, the raw replacement string (no conversion)."),
|
|
25348
25540
|
title: stringType().optional().describe("New title (normalized server-side)"),
|
|
25349
25541
|
section: stringType().optional().describe("Target a section by heading text (h2/h3/h4, in that order); omit for whole-note append/replace"),
|
|
25350
25542
|
occurrence: numberType().int().positive().optional().describe("section=: which same-text heading to target, 1-based (default: the first). Read them with outline(noteId)."),
|
|
25351
25543
|
strict: booleanType().optional().describe("Refuse (note untouched) on a section= miss or a body needing structural repair, instead of writing a new section"),
|
|
25352
25544
|
mode: enumType(["append", "replace", "before", "after", "prepend", "remove"]).optional().describe("append (default) | replace | before | after (around the whole section=) | prepend (top of its body) | remove (delete section=, no body needed)"),
|
|
25353
25545
|
find: stringType().optional().describe("Exact raw string to replace throughout the body with body= \u2014 targeted surgery without a read+full-replace. Takes precedence over section/mode."),
|
|
25354
|
-
nth: numberType().int().positive().optional().describe("find=: replace only the Nth occurrence, 1-based (default: all of them)"),
|
|
25546
|
+
nth: numberType().int().positive().optional().describe("find=: replace only the Nth occurrence, 1-based (default: all of them). With within=: the Nth containing element"),
|
|
25547
|
+
within: enumType(WITHIN_TAGS).optional().describe('With find=: act on the <tag> element that contains the find text (a short unique anchor) \u2014 mode="replace" (default) swaps it for body=, "before"/"after" insert body= as a sibling (add a table row), "remove" deletes it'),
|
|
25355
25548
|
edits: arrayType(objectType({
|
|
25356
25549
|
find: stringType().describe("Exact raw string to replace"),
|
|
25357
25550
|
body: stringType().describe("Raw replacement string"),
|
|
@@ -25360,9 +25553,11 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
25360
25553
|
identity: stringType().optional().describe('Appending to a dated thread: the identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" (required)'),
|
|
25361
25554
|
icon: stringType().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name; normalized server-side'),
|
|
25362
25555
|
date: stringType().optional().describe("ISO date (default: today)")
|
|
25363
|
-
}, async ({ noteId, body, title, section, occurrence, mode, find, nth, edits, identity, icon, date, strict }) => {
|
|
25556
|
+
}, async ({ noteId, body, title, section, occurrence, mode, find, nth, within, edits, identity, icon, date, strict }) => {
|
|
25364
25557
|
if (isContainer(b(), noteId))
|
|
25365
25558
|
return err("protected_note", `Note ${noteId} is a container \u2014 its content cannot be edited directly.`, "Use remember() to write to singletons, or specify a content note id.");
|
|
25559
|
+
if (within && find === undefined)
|
|
25560
|
+
return err("missing_param", "within= needs find= as its anchor.", 'Pass a short text unique to the element, e.g. revise(noteId, find="Phase 2", within="tr", body="<tr>\u2026</tr>").');
|
|
25366
25561
|
const d = checkedDate(date);
|
|
25367
25562
|
const note = await trilium.getNote(noteId);
|
|
25368
25563
|
const noteKind = labelOf2(note, "noteType");
|
|
@@ -25402,7 +25597,7 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
25402
25597
|
for (const [i, e] of plan.entries()) {
|
|
25403
25598
|
if (!e.find)
|
|
25404
25599
|
return err("missing_param", `edits[${i}].find cannot be empty.`, 'Pass the exact text to replace, e.g. revise(noteId, find="Brainllm", body="BrainLLM").');
|
|
25405
|
-
if (e.body === undefined)
|
|
25600
|
+
if (e.body === undefined && !within)
|
|
25406
25601
|
return err("missing_param", `edits[${i}] requires body as the replacement string.`, 'Call revise(noteId, find="<exact text>", body="<replacement>").');
|
|
25407
25602
|
}
|
|
25408
25603
|
const applyEdit = (source, needle, replacement, index) => {
|
|
@@ -25451,6 +25646,59 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
25451
25646
|
return near ? { matchedUpTo: near.fragment, storedNearby: near.context } : {};
|
|
25452
25647
|
};
|
|
25453
25648
|
const current = await trilium.getNoteContent(noteId);
|
|
25649
|
+
if (within) {
|
|
25650
|
+
if (edits !== undefined)
|
|
25651
|
+
return err("conflicting_params", "within= works with a single find=, not edits=.", "Make one revise() call per element.");
|
|
25652
|
+
const action = mode === "before" || mode === "after" || mode === "remove" ? mode : mode === undefined || mode === "replace" ? "replace" : null;
|
|
25653
|
+
if (!action)
|
|
25654
|
+
return err("invalid_mode", `mode="${mode}" does not apply to within=.`, 'Use mode="replace" (default), "before", "after" or "remove".');
|
|
25655
|
+
if (action !== "remove" && body === undefined)
|
|
25656
|
+
return err("missing_param", `within= with mode="${action}" needs body= (the raw element HTML).`, `e.g. revise(noteId, find="Phase 2", within="tr", mode="after", body="<tr><td>\u2026</td></tr>")`);
|
|
25657
|
+
const exact = [];
|
|
25658
|
+
for (let at = current.indexOf(find);at !== -1; at = current.indexOf(find, at + find.length))
|
|
25659
|
+
exact.push({ start: at, length: find.length });
|
|
25660
|
+
const rx = exact.length ? null : tolerantFindRegex(find);
|
|
25661
|
+
const hits = exact.length ? exact : rx ? [...current.matchAll(rx)].map((m) => ({ start: m.index, length: m[0].length })) : [];
|
|
25662
|
+
const res = editWithin(current, hits, within, action, body ?? "", nth);
|
|
25663
|
+
if (!res.ok) {
|
|
25664
|
+
const hint = res.reason === "no_match" ? missHint(find, current, false) : res.reason === "no_element" ? `The anchor matched ${res.matches} time(s), but never inside a <${within}>. Check the tag, or drop within= to edit the text itself.` : `The anchor sits in ${res.matches} different <${within}> elements. Lengthen it until it is unique, or pass nth= (1-based, in document order) \u2014 elements[] previews them.`;
|
|
25665
|
+
return txt3({
|
|
25666
|
+
ok: false,
|
|
25667
|
+
noteId,
|
|
25668
|
+
mode: `within:${within}:${action}`,
|
|
25669
|
+
replaced: 0,
|
|
25670
|
+
date: d,
|
|
25671
|
+
reason: res.reason,
|
|
25672
|
+
hint,
|
|
25673
|
+
...res.elements ? { elements: res.elements } : {},
|
|
25674
|
+
...res.reason === "no_match" ? missContext(find, current) : {}
|
|
25675
|
+
});
|
|
25676
|
+
}
|
|
25677
|
+
if (res.html === current)
|
|
25678
|
+
return txt3({ ok: true, noteId, mode: `within:${within}:${action}`, unchanged: true, date: d, note: "The body equals the element it would replace, so nothing was written and no revision was taken." });
|
|
25679
|
+
await trilium.createRevision(noteId).catch(() => null);
|
|
25680
|
+
const withinResult = sanitizeHtml(res.html);
|
|
25681
|
+
const stampedWithin = bumpLastUpdated(withinResult.html, d);
|
|
25682
|
+
await trilium.updateNoteContent(noteId, stampedWithin.html);
|
|
25683
|
+
const titledWithin = await applyTitle();
|
|
25684
|
+
const iconWithin = await applyIcon(noteId, icon);
|
|
25685
|
+
await trilium.updateLabelValue(noteId, "updated", d);
|
|
25686
|
+
if (labelOf2(note, "status") === "dormant")
|
|
25687
|
+
await trilium.updateLabelValue(noteId, "status", "active");
|
|
25688
|
+
const relsWithin = relationSnippet(note);
|
|
25689
|
+
return txt3({
|
|
25690
|
+
ok: true,
|
|
25691
|
+
noteId,
|
|
25692
|
+
mode: `within:${within}:${action}`,
|
|
25693
|
+
date: d,
|
|
25694
|
+
element: toText(res.element, 200),
|
|
25695
|
+
...titledWithin.retitled ? { retitled: titledWithin.retitled } : {},
|
|
25696
|
+
...iconWithin ? { icon: iconWithin } : {},
|
|
25697
|
+
...relsWithin ? { relations: relsWithin } : {},
|
|
25698
|
+
...structuralFindings(noteKind, stampedWithin.html),
|
|
25699
|
+
...withinResult.warnings.length ? { sanitized: withinResult.warnings } : {}
|
|
25700
|
+
});
|
|
25701
|
+
}
|
|
25454
25702
|
let working = current;
|
|
25455
25703
|
const results = [];
|
|
25456
25704
|
let total = 0;
|
|
@@ -25486,6 +25734,22 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
25486
25734
|
}
|
|
25487
25735
|
});
|
|
25488
25736
|
}
|
|
25737
|
+
if (working === current) {
|
|
25738
|
+
const titledNoop = await applyTitle();
|
|
25739
|
+
const iconNoop = await applyIcon(noteId, icon);
|
|
25740
|
+
return txt3({
|
|
25741
|
+
ok: true,
|
|
25742
|
+
noteId,
|
|
25743
|
+
mode: edits ? "edits" : "find-replace",
|
|
25744
|
+
replaced: 0,
|
|
25745
|
+
unchanged: true,
|
|
25746
|
+
date: d,
|
|
25747
|
+
...edits ? { results } : { matchMode: results[0].matchMode },
|
|
25748
|
+
...titledNoop.retitled ? { retitled: titledNoop.retitled } : {},
|
|
25749
|
+
...iconNoop ? { icon: iconNoop } : {},
|
|
25750
|
+
note: "Every match already equals its replacement, so the body was not written and no revision was taken."
|
|
25751
|
+
});
|
|
25752
|
+
}
|
|
25489
25753
|
await trilium.createRevision(noteId).catch(() => null);
|
|
25490
25754
|
const replacedResult = sanitizeHtml(working);
|
|
25491
25755
|
const stamped = bumpLastUpdated(replacedResult.html, d);
|
|
@@ -25913,16 +26177,32 @@ calling twice is safe. Use remove=true to delete an edge.`, {
|
|
|
25913
26177
|
}
|
|
25914
26178
|
}
|
|
25915
26179
|
});
|
|
25916
|
-
server.tool("consistency", `Does the brain agree with itself? pattern= is a regex with ONE capture group naming the value that should match across notes, e.g. "(\\\\d+) mailboxes"; the result groups every asserting note by value with agreement unanimous or DISAGREEMENT. subject="<fact in prose>" finds notes asserting about a subject however phrased. staleAfterDays=N also reports values held in exactly one note untouched N+ days. Matches stored HTML and tag-stripped text; escape backslashes; scope with domain=/kinds=. Scans every in-scope note (fast=true uses Trilium's lossy pre-filter). Run it after correcting any fact recorded in more than one place.`, {
|
|
26180
|
+
server.tool("consistency", `Does the brain agree with itself? pattern= is a regex with ONE capture group naming the value that should match across notes, e.g. "(\\\\d+) mailboxes"; the result groups every asserting note by value with agreement unanimous or DISAGREEMENT. subject="<fact in prose>" finds notes asserting about a subject however phrased. staleAfterDays=N also reports values held in exactly one note untouched N+ days. Matches stored HTML and tag-stripped text; escape backslashes; scope with domain=/kinds=. Records (sessions, diary, logs, dated thread entries) are skipped by default because they are never rewritten; includeRecords=true or a kinds= naming them brings them back. Scans every in-scope note (fast=true uses Trilium's lossy pre-filter). Run it after correcting any fact recorded in more than one place.`, {
|
|
25917
26181
|
pattern: stringType().optional().describe("Regex over note bodies. One capture group = the value that should agree across notes. Omit when using subject= (prose mode)."),
|
|
25918
26182
|
subject: stringType().optional().describe("A fact in prose \u2014 returns notes asserting about it however phrased (instead of pattern=)"),
|
|
25919
26183
|
staleAfterDays: numberType().optional().describe("With pattern: also report values held in exactly one note untouched N+ days (staleSingles)"),
|
|
25920
26184
|
domain: stringType().optional().describe("Restrict to one knowledge domain"),
|
|
25921
|
-
kinds: arrayType(enumType(Kinds)).optional().describe("Restrict to these kinds"),
|
|
26185
|
+
kinds: arrayType(enumType(Kinds)).optional().describe("Restrict to these kinds. Naming a record kind (session, diary, log, threadEntry) includes those records"),
|
|
26186
|
+
includeRecords: booleanType().optional().describe("Also match records: sessions, diary, logs and dated thread entries (default false: they are history, never rewritten)"),
|
|
25922
26187
|
includeArchived: booleanType().optional().describe("Include archived notes (default false)"),
|
|
25923
26188
|
limit: numberType().optional().describe("Max notes to examine (default 60)"),
|
|
25924
26189
|
fast: booleanType().optional().describe("Use Trilium's faster but lossy %= pre-filter (default: scan every in-scope note)")
|
|
25925
|
-
}, async ({ pattern, subject, staleAfterDays, domain, kinds, includeArchived, limit, fast }) => {
|
|
26190
|
+
}, async ({ pattern, subject, staleAfterDays, domain, kinds, includeRecords, includeArchived, limit, fast }) => {
|
|
26191
|
+
const wantsRecords = includeRecords === true || (kinds ?? []).some((k) => RECORD_KINDS.has(k));
|
|
26192
|
+
let recordsSkipped = 0;
|
|
26193
|
+
const inScope = (n) => {
|
|
26194
|
+
const kind = ownedLabel(n, "noteType");
|
|
26195
|
+
if (!kind)
|
|
26196
|
+
return false;
|
|
26197
|
+
if (kinds?.length && !kinds.includes(kind))
|
|
26198
|
+
return false;
|
|
26199
|
+
if (!wantsRecords && isRecordNote(n)) {
|
|
26200
|
+
recordsSkipped++;
|
|
26201
|
+
return false;
|
|
26202
|
+
}
|
|
26203
|
+
return true;
|
|
26204
|
+
};
|
|
26205
|
+
const recordsNote = () => recordsSkipped ? { recordsSkipped, recordsNote: `${recordsSkipped} record(s) (sessions, diary, logs, dated thread entries) were skipped: they are never rewritten. includeRecords=true matches them too.` } : {};
|
|
25926
26206
|
if (!pattern && !subject)
|
|
25927
26207
|
return err("missing_param", "consistency() needs either a regex pattern or a prose subject.", 'Pass pattern="(\\\\d+) users" to compare a captured value, or subject="<a fact in prose>" to find every note asserting about that subject however phrased.');
|
|
25928
26208
|
if (subject && !pattern) {
|
|
@@ -25933,13 +26213,8 @@ calling twice is safe. Use remove=true to delete an edge.`, {
|
|
|
25933
26213
|
const clauses = ["#noteType"];
|
|
25934
26214
|
if (domain)
|
|
25935
26215
|
clauses.push(`#domain='${slugify(domain)}'`);
|
|
25936
|
-
const notes = await trilium.searchNotes(clauses.join(" AND "), { ancestorNoteId: b().root, limit: max, includeArchivedNotes: includeArchived ?? false }).then((r) => r.results).catch(() => []);
|
|
25937
|
-
const scoped = notes.filter((
|
|
25938
|
-
const kind = ownedLabel(n, "noteType");
|
|
25939
|
-
if (!kind)
|
|
25940
|
-
return false;
|
|
25941
|
-
return !kinds?.length || kinds.includes(kind);
|
|
25942
|
-
});
|
|
26216
|
+
const notes = await trilium.searchNotes(clauses.join(" AND "), { ancestorNoteId: b().root, limit: wantsRecords ? max : max * 4, includeArchivedNotes: includeArchived ?? false }).then((r) => r.results).catch(() => []);
|
|
26217
|
+
const scoped = notes.filter(inScope).slice(0, max);
|
|
25943
26218
|
const threshold = Math.max(1, Math.ceil(tokens.length / 2));
|
|
25944
26219
|
const hits = [];
|
|
25945
26220
|
for (const n of scoped) {
|
|
@@ -25961,6 +26236,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
|
|
|
25961
26236
|
notes: hits,
|
|
25962
26237
|
total: hits.length,
|
|
25963
26238
|
...domain ? { domain: slugify(domain) } : {},
|
|
26239
|
+
...recordsNote(),
|
|
25964
26240
|
note: hits.length ? `${hits.length} note(s) assert something about "${subject}". Open each to read the claim \u2014 or narrow with domain= / kinds=.` : `No note in scope matched ${threshold}+ of the subject's tokens. That is evidence about the phrasing, not the brain \u2014 this mode is deliberately phrase-agnostic, so if you expected a hit, the fact may not be recorded here at all.`
|
|
25965
26241
|
});
|
|
25966
26242
|
}
|
|
@@ -25976,13 +26252,8 @@ calling twice is safe. Use remove=true to delete an edge.`, {
|
|
|
25976
26252
|
const clauses = fast ? [`note.content %= '${escapeQueryRegex(pattern)}'`] : ["#noteType"];
|
|
25977
26253
|
if (domain)
|
|
25978
26254
|
clauses.push(`#domain='${slugify(domain)}'`);
|
|
25979
|
-
const notes = await trilium.searchNotes(clauses.join(" AND "), { ancestorNoteId: b().root, limit: max, includeArchivedNotes: includeArchived ?? false }).then((r) => r.results).catch(() => []);
|
|
25980
|
-
const scoped = notes.filter((
|
|
25981
|
-
const kind = ownedLabel(n, "noteType");
|
|
25982
|
-
if (!kind)
|
|
25983
|
-
return false;
|
|
25984
|
-
return !kinds?.length || kinds.includes(kind);
|
|
25985
|
-
});
|
|
26255
|
+
const notes = await trilium.searchNotes(clauses.join(" AND "), { ancestorNoteId: b().root, limit: wantsRecords ? max : max * 4, includeArchivedNotes: includeArchived ?? false }).then((r) => r.results).catch(() => []);
|
|
26256
|
+
const scoped = notes.filter(inScope).slice(0, max);
|
|
25986
26257
|
const byValue = new Map;
|
|
25987
26258
|
const noCapture = [];
|
|
25988
26259
|
let hasCaptureGroup = false;
|
|
@@ -26031,6 +26302,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
|
|
|
26031
26302
|
pattern,
|
|
26032
26303
|
scan: fast ? "fast (%= pre-filter)" : "exhaustive",
|
|
26033
26304
|
notesExamined: scoped.length,
|
|
26305
|
+
...recordsNote(),
|
|
26034
26306
|
notes: noCapture,
|
|
26035
26307
|
total: noCapture.length,
|
|
26036
26308
|
note: noCapture.length === 0 ? `No note body matched that pattern across ${scoped.length} note(s), searched both as stored HTML and tag-stripped.${fast ? " fast=true used Trilium's %= pre-filter, which reads a striptags'd copy and can drop notes \u2014 re-run without it before concluding anything." : " That is evidence about the pattern, not the brain."}` : 'The pattern has no capture group, so this is a presence check only. Add one \u2014 consistency("(\\\\d+) users") \u2014 to compare the values these notes actually assert.'
|
|
@@ -26045,6 +26317,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
|
|
|
26045
26317
|
scan: fast ? "fast (%= pre-filter)" : "exhaustive",
|
|
26046
26318
|
...domain ? { domain: slugify(domain) } : {},
|
|
26047
26319
|
notesExamined: scoped.length,
|
|
26320
|
+
...recordsNote(),
|
|
26048
26321
|
...tagSpanning ? { tagSpanning, tagSpanningNote: `${tagSpanning} note(s) matched only once markup was stripped \u2014 the phrase is split by an inline tag there. Before v10.3 those were invisible.` } : {},
|
|
26049
26322
|
distinctValues: groups.length,
|
|
26050
26323
|
agreement: groups.length === 0 ? "no-data" : agrees ? "unanimous" : "DISAGREEMENT",
|
|
@@ -26329,11 +26602,58 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
|
|
|
26329
26602
|
...all.length === 0 ? { note: 'No claims registered. Register one with claim(assertion="\u2026", check="\u2026") \u2014 the assertions worth registering are the ones that would be expensive to discover had gone stale.' } : {}
|
|
26330
26603
|
});
|
|
26331
26604
|
});
|
|
26332
|
-
server.tool("diff", `What a write actually changed: a revision snapshot (default the latest) against the note as it stands, as changed lines with context and added/removed counts. The revision list comes back on every call; pass revisionId to compare against an earlier one.`, {
|
|
26333
|
-
noteId: stringType().describe("Note to diff"),
|
|
26605
|
+
server.tool("diff", `What a write actually changed: a revision snapshot (default the latest) against the note as it stands, as changed lines with context and added/removed counts. The revision list comes back on every call; pass revisionId to compare against an earlier one. since="today" (or a date, or "YYYY-MM-DD HH:mm") reviews everything changed since then in one call: with noteId, that note's net change across all its writes since; without, every maintained note changed since, each diffed against its state before the first of those writes. Run it before close to check today's corrections agree with each other.`, {
|
|
26606
|
+
noteId: stringType().optional().describe("Note to diff (omit with since= to review every note changed since then)"),
|
|
26334
26607
|
revisionId: stringType().optional().describe("Compare against this revision (default: the most recent one)"),
|
|
26608
|
+
since: stringType().optional().describe('"today", a date (YYYY-MM-DD) or a local datetime (YYYY-MM-DD HH:mm): the net change since then, across all writes'),
|
|
26609
|
+
includeRecords: booleanType().optional().describe("since= without noteId: also list records (sessions, diary, logs, dated thread entries), which only ever gain addenda (default false)"),
|
|
26610
|
+
limit: numberType().int().min(1).max(50).optional().describe("since= without noteId: max notes to diff (default 25)"),
|
|
26335
26611
|
context: numberType().int().min(0).max(10).optional().describe("Unchanged lines to show around each change (default: 1)")
|
|
26336
|
-
}, async ({ noteId, revisionId, context }) => {
|
|
26612
|
+
}, async ({ noteId, revisionId, since, includeRecords, limit, context }) => {
|
|
26613
|
+
const pad = context ?? 1;
|
|
26614
|
+
if (since !== undefined) {
|
|
26615
|
+
const cutoff = sinceCutoff(since);
|
|
26616
|
+
if (!cutoff)
|
|
26617
|
+
return err("invalid_since", `since="${since}" is not "today", a date or a local datetime.`, 'Pass since="today", since="2026-10-01" or since="2026-10-01 14:30".');
|
|
26618
|
+
if (noteId) {
|
|
26619
|
+
const revisions = await trilium.getNoteRevisions(noteId).catch(() => []);
|
|
26620
|
+
const after = await trilium.getNoteContent(noteId).catch(() => null);
|
|
26621
|
+
if (after === null)
|
|
26622
|
+
return err("not_found", `Note ${noteId} not found.`, "Check the id with brain() or recall().");
|
|
26623
|
+
const first = firstRevisionSince(revisions, cutoff);
|
|
26624
|
+
if (!first)
|
|
26625
|
+
return txt3({ noteId, since: cutoff, changed: false, note: `No content write to this note since ${cutoff} (no revision was taken after it).` });
|
|
26626
|
+
const before = await trilium.getRevisionContent(first.revisionId);
|
|
26627
|
+
return txt3({ noteId, since: cutoff, writes: revisionsSince(revisions, cutoff).length, comparedTo: first.revisionId, ...blockDiff(before, after, pad) });
|
|
26628
|
+
}
|
|
26629
|
+
const changed = await trilium.searchNotes(`#noteType note.dateModified >= '${cutoff}'`, { ancestorNoteId: b().root, limit: 200, orderBy: "dateModified", orderDirection: "desc" }).then((r) => r.results).catch(() => []);
|
|
26630
|
+
const maintained = includeRecords ? changed : changed.filter((n) => !isRecordNote(n));
|
|
26631
|
+
const max = limit ?? 25;
|
|
26632
|
+
const notes = [];
|
|
26633
|
+
for (const n of maintained.slice(0, max)) {
|
|
26634
|
+
const stub = { id: n.noteId, title: n.title, kind: ownedLabel(n, "noteType") ?? "" };
|
|
26635
|
+
const revisions = await trilium.getNoteRevisions(n.noteId).catch(() => []);
|
|
26636
|
+
const first = firstRevisionSince(revisions, cutoff);
|
|
26637
|
+
if (!first) {
|
|
26638
|
+
notes.push({ ...stub, ...n.dateCreated >= cutoff ? { created: true } : { note: "Changed without a content revision (attributes or title only)." } });
|
|
26639
|
+
continue;
|
|
26640
|
+
}
|
|
26641
|
+
const [before, after] = await Promise.all([trilium.getRevisionContent(first.revisionId), trilium.getNoteContent(n.noteId)]);
|
|
26642
|
+
const d = blockDiff(before, after, pad, 12);
|
|
26643
|
+
notes.push({ ...stub, writes: revisionsSince(revisions, cutoff).length, ...d.identical ? { identical: true } : { summary: d.summary, removed: d.removed, added: d.added } });
|
|
26644
|
+
}
|
|
26645
|
+
const recordsSkipped = changed.length - maintained.length;
|
|
26646
|
+
return txt3({
|
|
26647
|
+
since: cutoff,
|
|
26648
|
+
notesChanged: maintained.length,
|
|
26649
|
+
...maintained.length > max ? { shown: max, more: `${maintained.length - max} more; raise limit= or diff one note with noteId=.` } : {},
|
|
26650
|
+
...recordsSkipped ? { recordsSkipped, recordsNote: "Records only gain dated addenda; includeRecords=true lists them too." } : {},
|
|
26651
|
+
notes,
|
|
26652
|
+
note: maintained.length ? "Each note is diffed from its state before its first write since the cutoff. Check the changes agree with each other, and run consistency() on any fact you changed in more than one note." : `No maintained note changed since ${cutoff}.`
|
|
26653
|
+
});
|
|
26654
|
+
}
|
|
26655
|
+
if (!noteId)
|
|
26656
|
+
return err("missing_param", "diff() needs noteId=, since=, or both.", `Pass noteId= for one note's last write, or since="today" to review everything changed today.`);
|
|
26337
26657
|
const revisions = await trilium.getNoteRevisions(noteId).catch(() => []);
|
|
26338
26658
|
if (!revisions.length)
|
|
26339
26659
|
return txt3({ noteId, note: "No revisions \u2014 this note has not been written through a content-mutating tool yet, or its revisions have been pruned.", revisions: [] });
|
|
@@ -26359,33 +26679,11 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
|
|
|
26359
26679
|
note: "The snapshot and the current body are byte-identical \u2014 the write after this revision changed nothing, or the revision was taken after it."
|
|
26360
26680
|
});
|
|
26361
26681
|
}
|
|
26362
|
-
const lines = (s) => s.replace(/></g, `>
|
|
26363
|
-
<`).split(`
|
|
26364
|
-
`);
|
|
26365
|
-
const a = lines(before);
|
|
26366
|
-
const bLines = lines(after);
|
|
26367
|
-
let head = 0;
|
|
26368
|
-
while (head < a.length && head < bLines.length && a[head] === bLines[head])
|
|
26369
|
-
head++;
|
|
26370
|
-
let tail = 0;
|
|
26371
|
-
while (tail < a.length - head && tail < bLines.length - head && a[a.length - 1 - tail] === bLines[bLines.length - 1 - tail])
|
|
26372
|
-
tail++;
|
|
26373
|
-
const pad = context ?? 1;
|
|
26374
|
-
const removed = a.slice(head, a.length - tail);
|
|
26375
|
-
const added = bLines.slice(head, bLines.length - tail);
|
|
26376
|
-
const cap = (arr) => arr.length > 40 ? [...arr.slice(0, 40), `\u2026 ${arr.length - 40} more line(s)`] : arr;
|
|
26377
26682
|
return txt3({
|
|
26378
26683
|
noteId,
|
|
26379
26684
|
comparedTo: target.revisionId,
|
|
26380
26685
|
revisionDate: target.dateCreated?.slice(0, 16),
|
|
26381
|
-
|
|
26382
|
-
sizeBefore: before.length,
|
|
26383
|
-
sizeAfter: after.length,
|
|
26384
|
-
contextBefore: cap(a.slice(Math.max(0, head - pad), head)),
|
|
26385
|
-
removed: cap(removed),
|
|
26386
|
-
added: cap(added),
|
|
26387
|
-
contextAfter: cap(a.slice(a.length - tail, a.length - tail + pad)),
|
|
26388
|
-
summary: `${removed.length} block(s) removed, ${added.length} added, ${after.length - before.length >= 0 ? "+" : ""}${after.length - before.length} characters.`,
|
|
26686
|
+
...blockDiff(before, after, pad),
|
|
26389
26687
|
revisions: index
|
|
26390
26688
|
});
|
|
26391
26689
|
});
|
|
@@ -27660,10 +27958,10 @@ note.dateModified >= 'YYYY-MM-DD', AND/OR. Unscoped unless ancestorNoteId is giv
|
|
|
27660
27958
|
server.tool("get_inbox_note", "Get the Trilium inbox note for a date (fixed #inbox note, or the day note). Format: YYYY-MM-DD.", { date: stringType().optional() }, async ({ date }) => txt4(await trilium.getInboxNote(date ?? localToday())));
|
|
27661
27959
|
server.tool("get_app_info", "Trilium server version, DB version, runtime metadata.", {}, async () => txt4(await trilium.getAppInfo()));
|
|
27662
27960
|
server.tool("create_backup", `Named Trilium database backup (raw). The resulting file path is not exposed.`, {
|
|
27663
|
-
name: stringType().optional().describe("Backup file name without .db extension (default: brainllm-
|
|
27664
|
-
date: stringType().optional().describe("ISO date
|
|
27961
|
+
name: stringType().optional().describe("Backup file name without .db extension (default: the date's rolling weekday slot, e.g. brainllm-thu)"),
|
|
27962
|
+
date: stringType().optional().describe("ISO date whose weekday slot is used when name is omitted (default: today)")
|
|
27665
27963
|
}, async ({ name, date }) => {
|
|
27666
|
-
const backupName = name ??
|
|
27964
|
+
const backupName = name ?? rollingBackupName(date ?? localToday());
|
|
27667
27965
|
try {
|
|
27668
27966
|
await trilium.createBackup(backupName);
|
|
27669
27967
|
return txt4({ ok: true, backup: backupName, backupStatus: "completed" });
|
|
@@ -28843,7 +29141,7 @@ function createServer(origin = null) {
|
|
|
28843
29141
|
const s = new McpServer({
|
|
28844
29142
|
name: "BrainLLM",
|
|
28845
29143
|
title: "BrainLLM",
|
|
28846
|
-
version: "12.
|
|
29144
|
+
version: "12.7.1",
|
|
28847
29145
|
icons: brandingIcons(origin)
|
|
28848
29146
|
});
|
|
28849
29147
|
registerTools(s, trilium, brainRef);
|
package/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"manifest_version": "0.3",
|
|
3
3
|
"name": "brainllm",
|
|
4
4
|
"display_name": "BrainLLM",
|
|
5
|
-
"version": "12.
|
|
5
|
+
"version": "12.7.1",
|
|
6
6
|
"description": "Turns TriliumNext Notes into a persistent, graph-structured second brain for Claude and other LLM clients.",
|
|
7
7
|
"long_description": "BrainLLM is an MCP server that organises memory into five purpose-built areas (Master, LLM, Memory, Knowledge, Insights). The model supplies content while the server owns placement, format, lifecycle, dates and backups. Includes a full raw ETAPI surface for advanced use. Requires a running TriliumNext instance and the Bun runtime.",
|
|
8
8
|
"author": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainllm",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.7.1",
|
|
4
4
|
"mcpName": "io.github.miisodev/brainllm",
|
|
5
5
|
"description": "MCP server that turns TriliumNext Notes into a persistent, graph-structured second brain for Claude and other LLM clients.",
|
|
6
6
|
"author": "Kevin Miiso Novo <miisodev@gmail.com>",
|
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.miisodev/brainllm",
|
|
4
4
|
"description": "Persistent graph-structured memory for LLMs in TriliumNext Notes — readable and editable by humans.",
|
|
5
|
-
"version": "12.
|
|
5
|
+
"version": "12.7.1",
|
|
6
6
|
"websiteUrl": "https://github.com/miisodev/BrainLLM#readme",
|
|
7
7
|
"repository": {
|
|
8
8
|
"url": "https://github.com/miisodev/BrainLLM",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "brainllm",
|
|
15
|
-
"version": "12.
|
|
15
|
+
"version": "12.7.1",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
package/skills/brainllm/SKILL.md
CHANGED
|
@@ -30,7 +30,7 @@ DURING remember(...) the moment something is worth keeping
|
|
|
30
30
|
resolve / withdraw / recover thread and note lifecycle
|
|
31
31
|
connect(...) wire a real relation when you see one
|
|
32
32
|
read: <surface>, domain, recall, read(ids), outline, inspect, assembly, brain
|
|
33
|
-
verify: consistency(...), claim(...)
|
|
33
|
+
verify: consistency(...), claim(...), diff(since="today")
|
|
34
34
|
END session() → [update singletons] → addendum() → maintain() → remarks() → diary() → close()
|
|
35
35
|
```
|
|
36
36
|
|
|
@@ -89,7 +89,7 @@ worth keeping?
|
|
|
89
89
|
| Need | Tool |
|
|
90
90
|
|---|---|
|
|
91
91
|
| a singleton | `master(which)` / `llm(which)` — `section=` for one section |
|
|
92
|
-
| a thread or session | `memory(id)` — dated index (newest days, up to 30 and ~24k characters; `limit=` for more) or collection entries; `date=` for one day |
|
|
92
|
+
| a thread or session | `memory(id)` — dated index (newest days, up to 30 and ~24k characters; `limit=` for more) or collection entries; `date=` for one day; `index=true` for ids, dates and block markers only |
|
|
93
93
|
| a knowledge note | `knowledge(id)` — `section=` for one section |
|
|
94
94
|
| a day's change log | `insights(date?)` |
|
|
95
95
|
| skim a surface | `<surface>_recall` |
|
|
@@ -148,8 +148,11 @@ Read `template(kind)` before your first write of a kind, then read a sibling.
|
|
|
148
148
|
| `section=` + `mode="remove"` | delete the section |
|
|
149
149
|
| `find="<exact stored text>"` | replace every occurrence (`nth=` for one) |
|
|
150
150
|
| `edits=[{find, body}]` | several surgeries in one read and one write |
|
|
151
|
+
| `find="<short anchor>"` + `within="tr"` | act on the element containing the anchor: replace it (default), `mode="before"/"after"` to insert a row beside it, `mode="remove"` to delete it (`li`, `p`, `td` … too) |
|
|
151
152
|
|
|
152
|
-
-
|
|
153
|
+
- **Tables are edited by row.** Anchor on a few words unique to the row and pass `within="tr"`; never copy a whole stored row into `find=`. An anchor in several rows is refused with previews; `nth=` picks one.
|
|
154
|
+
- A replacement equal to its match writes nothing and reports `unchanged`.
|
|
155
|
+
- A revision is taken before every content write; `diff(noteId)` shows what the last write changed, and `diff(since="today")` reviews every note you changed today in one call — run it before closing.
|
|
153
156
|
- **Check the receipt.** `matched: false` means a new section was written — `available[]` lists real headings and `didYouMean` catches typos; `strict=true` refuses instead. `headingCount > 1` means only the first match was touched. `replacedSubsections[]` names nested headings a section replace took with it.
|
|
154
157
|
- **`find=` matches stored HTML, not rendered text.** Pass tags literally; `outline()` gives the `raw` form of headings with inline markup. On a miss the hint names the cause and shows the stored text nearby.
|
|
155
158
|
- A section replace swaps everything under the heading — use `find=` for anything smaller.
|
|
@@ -175,7 +178,7 @@ Read `template(kind)` before your first write of a kind, then read a sibling.
|
|
|
175
178
|
|
|
176
179
|
## Verification — `consistency` and `claim`
|
|
177
180
|
|
|
178
|
-
- **`consistency(pattern | subject)`** — does the brain agree with itself? A regex with one capture group (or a fact in prose) returns every asserting note grouped by value. **Run it after correcting any fact that could be recorded in more than one place.** `staleAfterDays=N` reports values held in only one note untouched N+ days.
|
|
181
|
+
- **`consistency(pattern | subject)`** — does the brain agree with itself? A regex with one capture group (or a fact in prose) returns every asserting note grouped by value. **Run it after correcting any fact that could be recorded in more than one place.** `staleAfterDays=N` reports values held in only one note untouched N+ days. Records (sessions, diary, logs, dated thread entries) are skipped because they are never rewritten; `includeRecords=true` brings them back.
|
|
179
182
|
- **`claim(...)`** — does the brain still agree with the world? Register `assertion` + `check`; verify with `claimId` + `holds` + `evidence` (evidence required); read with `claimId`; list with no arguments. BrainLLM never runs the check — you do. Register claims that would be expensive to discover had gone stale.
|
|
180
183
|
|
|
181
184
|
---
|
|
@@ -205,7 +208,7 @@ Lite runs inside `start`/`close` (thread aging, label checks). `maintain(deep=tr
|
|
|
205
208
|
|
|
206
209
|
## Other tools
|
|
207
210
|
|
|
208
|
-
`attach`/`detach` — raw artifacts on a note. `backup(name)` — milestone snapshot (close already backs up). `bootstrap()` — create or refresh the structure. `template(kind)` — the canonical skeleton. **Full mode** (`BRAINLLM_MODE=full`) adds raw ETAPI tools with none of the server's guarantees. Read `references/fullmode.md` before any raw work.
|
|
211
|
+
`attach`/`detach` — raw artifacts on a note. `backup(name)` — milestone snapshot (close already backs up into a rolling weekday slot; every named backup is a full database copy kept on Trilium's volume, so name them sparingly). `bootstrap()` — create or refresh the structure. `template(kind)` — the canonical skeleton. **Full mode** (`BRAINLLM_MODE=full`) adds raw ETAPI tools with none of the server's guarantees. Read `references/fullmode.md` before any raw work.
|
|
209
212
|
|
|
210
213
|
## Quick-fix
|
|
211
214
|
|