brainllm 12.6.4 → 12.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
- [![Version](https://img.shields.io/badge/version-12.6.4-f59e0b?style=flat-square)](https://github.com/miisodev/BrainLLM/releases)
13
+ [![Version](https://img.shields.io/badge/version-12.7.0-f59e0b?style=flat-square)](https://github.com/miisodev/BrainLLM/releases)
14
14
  [![CI](https://img.shields.io/github/actions/workflow/status/miisodev/BrainLLM/ci.yml?branch=main&style=flat-square&label=CI&color=f59e0b)](https://github.com/miisodev/BrainLLM/actions/workflows/ci.yml)
15
15
  [![License: MIT](https://img.shields.io/badge/license-MIT-a1a1aa?style=flat-square)](./LICENSE)
16
16
  [![Runtime: Bun](https://img.shields.io/badge/runtime-Bun%20%E2%89%A5%201.0-a1a1aa?style=flat-square&logo=bun&logoColor=white)](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
@@ -6,8 +6,8 @@ Security fixes ship on the latest release line. Older versions may remain useful
6
6
 
7
7
  | Version | Supported |
8
8
  |---|---|
9
- | 12.6.x | ✅ |
10
- | 12.5.x and earlier | ❌ |
9
+ | 12.7.x | ✅ |
10
+ | 12.6.x and earlier | ❌ |
11
11
 
12
12
  ## Reporting a vulnerability
13
13
 
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 = `brainllm-${d}`;
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-{today}). Use a descriptive name for milestones.")
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 ?? `brainllm-${d}`;
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 (&lt;p&gt;) 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 runIn = (ancestor, q) => ancestor ? trilium.searchNotes(q, { ancestorNoteId: ancestor, limit: 100, fastSearch: true, includeArchivedNotes: includeArchived ?? false }).then((r) => r.results).catch(() => []) : Promise.resolve([]);
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 (&lt;p&gt;) 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((n) => {
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((n) => {
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
- identical: false,
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-{today})"),
27664
- date: stringType().optional().describe("ISO date used in the default name when name is omitted (default: today)")
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 ?? `brainllm-${date ?? localToday()}`;
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.6.4",
29144
+ version: "12.7.0",
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.6.4",
5
+ "version": "12.7.0",
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.6.4",
3
+ "version": "12.7.0",
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.6.4",
5
+ "version": "12.7.0",
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.6.4",
15
+ "version": "12.7.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -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
- - A revision is taken before every content write; `diff(noteId)` shows what the last write changed.
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