brainllm 13.1.2 → 13.2.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 @@ Self-hosted memory for LLMs in [TriliumNext Notes](https://github.com/TriliumNex
10
10
 
11
11
  [**Site**](https://miisodev.github.io/BrainLLM/) · [Connect to Claude](https://miisodev.github.io/BrainLLM/connect.html) · [How it works](https://miisodev.github.io/BrainLLM/how-it-works.html) · [Docs](https://miisodev.github.io/BrainLLM/docs.html) · [Privacy](https://miisodev.github.io/BrainLLM/privacy.html)
12
12
 
13
- [![Version](https://img.shields.io/badge/version-13.1.2-f59e0b?style=flat-square)](https://github.com/miisodev/BrainLLM/releases)
13
+ [![Version](https://img.shields.io/badge/version-13.2.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)
@@ -60,6 +60,7 @@ Placement, naming, labels, deduplication, relation bookkeeping, lifecycle aging,
60
60
  - **A distinct connector identity** — hosted instances advertise BrainLLM and their own origin, with same-origin PNG/SVG icons, explicit page favicon links and a real multi-size ICO. The canonical mark is shared across the connector, consent pages and public assets.
61
61
  - **Preview changes and measure coverage** — `revise(noteId, edits=[…], dryRun=true)` shows sequential match counts and sanitized before/after content with no revision, labels or gate writes. Revision failures stop content overwrites. `consistency()` reports read failures and scan coverage; case-insensitive comparisons retain spelling variants without false conflicts. Incomplete scans cannot establish absence or unanimity.
62
62
  - **Registers close in one call** — `revise(find, within="tr", mode="remove", closure={thread, body})` removes a register row and records its closure in that thread's entry for the day, `closure={note, table?, row?}` moves a row into an explicitly selected register table; destination cells must match the table's shape. Destination-first ordering preserves recoverability, and `moves=[{find, row?}]` batches closures with preflight and per-row progress, and `append={table, row}` adds a row with no anchor to find; `within=` also accepts a row as a reader sees it (`"Phase 2 | Claude | Open"`), and a table row whose cells disagree with its header is flagged at write time and by `maintain(deep)`.
63
+ - **Bulk edits and lighter receipts** — `revise(find, within="tr", mode="remove", all=true)` removes every row an anchor sits in, in one write and previewable with `dryRun=true`; `remember(kind="sources", revision=[{source, from}])` renames a Sources key when a source moves instead of adding a second row; a dated-thread append returns `previousRun`, its author's previous block and run number; `read(ids, sizes=true)` plans a bulk read from sizes and section sizes alone; and `diff(since="today", summaryOnly=true)` lists what changed without the lines.
63
64
  - **Records you can prove** — sessions, diary entries and dated thread entries are append-only, and now verifiably so: every `close()` seals its day's records into a digest chained to the previous day's, and `maintain(deep)` reports any sealed record that was rewritten afterwards. Appends stay legal; edits to history do not go unnoticed.
64
65
 
65
66
  ---
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
- | 13.1.x | ✅ |
10
- | 12.9.x and earlier | ❌ |
9
+ | 13.2.x | ✅ |
10
+ | 13.1.x and earlier | ❌ |
11
11
 
12
12
  ## Reporting a vulnerability
13
13
 
package/dist/index.js CHANGED
@@ -24531,6 +24531,24 @@ function leadingIdentityText(html) {
24531
24531
  const m = /^(?:\s|<p>(?:\s|&nbsp;)*<\/p>)*<h3(?:\s[^>]*)?>([\s\S]*?)<\/h3>/i.exec(html);
24532
24532
  return m ? decodeEntities(m[1].replace(/<[^>]+>/g, "")).trim() : null;
24533
24533
  }
24534
+ function identityAuthor(text) {
24535
+ const parts = text.split("\xB7").map((p) => p.replace(/\s+/g, " ").trim().toLowerCase()).filter(Boolean);
24536
+ if (parts.length && /^run\s+\d+\b/.test(parts[parts.length - 1]))
24537
+ parts.pop();
24538
+ return parts.join(" \xB7 ");
24539
+ }
24540
+ function identityRun(text) {
24541
+ const m = /\u00B7\s*run\s+(\d+)\b[^\u00B7]*$/i.exec(text);
24542
+ return m ? Number(m[1]) : null;
24543
+ }
24544
+ function identityHeadings(html) {
24545
+ const out = [];
24546
+ const rx = /<h2>Addendum \u2014 \d{2}:\d{2}<\/h2>\s*<h3(?:\s[^>]*)?>([\s\S]*?)<\/h3>/gi;
24547
+ let m;
24548
+ while ((m = rx.exec(html)) !== null)
24549
+ out.push(decodeEntities(m[1].replace(/<[^>]+>/g, "")).trim());
24550
+ return out;
24551
+ }
24534
24552
  function identityProblem(text) {
24535
24553
  const line = text.trim();
24536
24554
  if (/[\r\n]/.test(line))
@@ -24921,7 +24939,7 @@ function hasPlaceholderRow(html, heading) {
24921
24939
  const rows = tableRows(html, heading);
24922
24940
  return rows.length > 0 && rows.every((cells) => PLACEHOLDER_CELL.test(cells[0] ?? ""));
24923
24941
  }
24924
- function upsertTableRow(html, heading, key, cells) {
24942
+ function upsertTableRow(html, heading, key, cells, renameFrom) {
24925
24943
  const noop = { html, matched: false, created: false };
24926
24944
  const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
24927
24945
  let headingEnd = -1;
@@ -24952,7 +24970,7 @@ function upsertTableRow(html, heading, key, cells) {
24952
24970
  const m = /<td[^>]*>([\s\S]*?)<\/td>/i.exec(row);
24953
24971
  return m ? decodeEntities(m[1].replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim().toLowerCase() : "";
24954
24972
  };
24955
- const keyNorm = decodeEntities(key).replace(/\s+/g, " ").trim().toLowerCase();
24973
+ const keyNorm = decodeEntities(renameFrom ?? key).replace(/\s+/g, " ").trim().toLowerCase();
24956
24974
  const rowRe = /<tr[^>]*>[\s\S]*?<\/tr>/gi;
24957
24975
  let matchedRow = null;
24958
24976
  let placeholderRow = null;
@@ -24967,6 +24985,8 @@ function upsertTableRow(html, heading, key, cells) {
24967
24985
  if (!placeholderRow && (text === "" || text === "\u2014 none yet \u2014"))
24968
24986
  placeholderRow = span;
24969
24987
  }
24988
+ if (renameFrom !== undefined && !matchedRow)
24989
+ return noop;
24970
24990
  const newRow = `<tr>${[key, ...cells].map((c) => `<td>${escapeHtml(c)}</td>`).join("")}</tr>`;
24971
24991
  const target = matchedRow ?? placeholderRow;
24972
24992
  const newTbody = target ? tbody.slice(0, target.start) + newRow + tbody.slice(target.end) : tbody + newRow;
@@ -26574,7 +26594,7 @@ function containingElement(html, tag, from, to) {
26574
26594
  }
26575
26595
  return best;
26576
26596
  }
26577
- function editWithin(html, hits, tag, action, body, nth) {
26597
+ function editWithin(html, hits, tag, action, body, nth, all = false) {
26578
26598
  if (!hits.length)
26579
26599
  return { ok: false, reason: "no_match", matches: 0 };
26580
26600
  const seen = new Map;
@@ -26587,6 +26607,14 @@ function editWithin(html, hits, tag, action, body, nth) {
26587
26607
  if (!elements.length)
26588
26608
  return { ok: false, reason: "no_element", matches: hits.length };
26589
26609
  const preview = (s) => html.slice(s.start, Math.min(s.end, s.start + 160));
26610
+ if (all) {
26611
+ const outer = elements.filter((e) => !elements.some((o) => o !== e && o.start <= e.start && o.end >= e.end));
26612
+ let out = html;
26613
+ for (const t of [...outer].reverse()) {
26614
+ out = action === "replace" ? out.slice(0, t.start) + body + out.slice(t.end) : action === "remove" ? out.slice(0, t.start) + out.slice(t.end) : action === "before" ? out.slice(0, t.start) + body + out.slice(t.start) : out.slice(0, t.end) + body + out.slice(t.end);
26615
+ }
26616
+ return { ok: true, html: out, element: html.slice(outer[0].start, outer[0].end), matches: outer.length, elements: outer.map((e) => html.slice(e.start, e.end)) };
26617
+ }
26590
26618
  let target;
26591
26619
  if (nth !== undefined)
26592
26620
  target = elements[nth - 1];
@@ -26598,6 +26626,19 @@ function editWithin(html, hits, tag, action, body, nth) {
26598
26626
  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);
26599
26627
  return { ok: true, html: out, element: el, matches: elements.length };
26600
26628
  }
26629
+ function pipeParagraphHint(html, hits, tag) {
26630
+ if (tag !== "tr" && tag !== "td" && tag !== "th")
26631
+ return null;
26632
+ for (const h of hits) {
26633
+ const p = containingElement(html, "p", h.start, h.start + h.length);
26634
+ if (!p)
26635
+ continue;
26636
+ const text = html.slice(p.start, p.end).replace(/<[^>]+>/g, "");
26637
+ if ((text.match(/\|/g) ?? []).length >= 2)
26638
+ return `The anchor sits in a paragraph written with "|" separators, which is text, not a table row. Use within="p" to act on that line (mode="after" adds a sibling line), or convert the roster to a real table.`;
26639
+ }
26640
+ return null;
26641
+ }
26601
26642
 
26602
26643
  // src/tools-surface.ts
26603
26644
  var txt = (obj) => ({
@@ -26985,6 +27026,8 @@ function applyFindEdit(source, needle, replacement, index) {
26985
27026
  return inline.length ? spans(inline, "inline-tolerant") : null;
26986
27027
  }
26987
27028
  var CHANGES_CAP = 25;
27029
+ var SOURCE_KEYS_ECHO = 30;
27030
+ var PREVIOUS_RUN_DAYS = 7;
26988
27031
  var boundPreview = (s, max = 1600) => s.length <= max ? s : `${s.slice(0, max)}\u2026 (${s.length - max} more characters)`;
26989
27032
  function capChanges(changes, cap = CHANGES_CAP) {
26990
27033
  if (changes.length <= cap)
@@ -27243,6 +27286,7 @@ function registerTools(server, trilium, brainRef) {
27243
27286
  }
27244
27287
  async function appendThreadEntry(threadId, block, d) {
27245
27288
  const found = await trilium.searchNotes(`#noteType=threadEntry #created='${d}'`, { ancestorNoteId: threadId, fastSearch: true, limit: 1 });
27289
+ const incomingIdentity = leadingIdentityText(block);
27246
27290
  const time = localNowTime();
27247
27291
  const marker = `Addendum \u2014 ${time}`;
27248
27292
  if (found.results[0]) {
@@ -27256,7 +27300,8 @@ function registerTools(server, trilium, brainRef) {
27256
27300
  await trilium.createRevision(noteId);
27257
27301
  await trilium.updateNoteContent(noteId, safeAppend(current, `<h2>${marker}</h2>`, block));
27258
27302
  await trilium.updateLabelValue(noteId, "updated", d);
27259
- return { noteId, action: "appended", marker };
27303
+ const previousRun = incomingIdentity ? await previousSameAuthor(threadId, incomingIdentity, current, d) : undefined;
27304
+ return { noteId, action: "appended", marker, ...previousRun ? { previousRun } : {} };
27260
27305
  }
27261
27306
  const created = await trilium.createNote(threadId, `[${d}]`, contentFor("threadEntry", { date: d, body: `<h2>${marker}</h2>
27262
27307
  ${block}` }));
@@ -27264,7 +27309,33 @@ ${block}` }));
27264
27309
  await trilium.addLabel(noteId, "noteType", "threadEntry");
27265
27310
  await trilium.addLabel(noteId, "created", d);
27266
27311
  await ensureIcon(trilium, noteId);
27267
- return { noteId, action: "created", marker };
27312
+ const previousRun = incomingIdentity ? await previousSameAuthor(threadId, incomingIdentity, "", d) : undefined;
27313
+ return { noteId, action: "created", marker, ...previousRun ? { previousRun } : {} };
27314
+ }
27315
+ async function previousSameAuthor(threadId, identity, todayBefore, d) {
27316
+ const author = identityAuthor(identity);
27317
+ const pick = (html, date) => {
27318
+ const heading = identityHeadings(html).reverse().find((h) => identityAuthor(h) === author);
27319
+ if (!heading)
27320
+ return;
27321
+ const run = identityRun(heading);
27322
+ return { heading, date, ...run !== null ? { run } : {} };
27323
+ };
27324
+ const today = pick(todayBefore, d);
27325
+ if (today || identityRun(identity) === null)
27326
+ return today;
27327
+ try {
27328
+ const entries = await trilium.searchNotes("#noteType=threadEntry", { ancestorNoteId: threadId, fastSearch: true, limit: 500 });
27329
+ const earlier = entries.results.filter((n) => n.parentNoteIds.includes(threadId)).map((n) => ({ n, date: ownedLabel(n, "created") ?? "" })).filter((e) => e.date && e.date < d).sort((a, b) => b.date.localeCompare(a.date)).slice(0, PREVIOUS_RUN_DAYS);
27330
+ for (const e of earlier) {
27331
+ const found = pick(await trilium.getNoteContent(e.n.noteId), e.date);
27332
+ if (found)
27333
+ return found;
27334
+ }
27335
+ } catch {
27336
+ return;
27337
+ }
27338
+ return;
27268
27339
  }
27269
27340
  function collectionAppendRefusal(book) {
27270
27341
  return err("collection_thread", `"${book.title}" is a collection thread \u2014 it holds one titled entry per item, not dated appends.`, `Add an entry: remember(kind="threadEntry", thread="${book.noteId}", title, body). Change one: revise(<entry id>, section=/find=/mode=\u2026) \u2014 memory("${book.noteId}") lists them.`);
@@ -27835,7 +27906,7 @@ ${html}` }));
27835
27906
  });
27836
27907
  server.tool("remember", `Store something the moment it matters; the server owns placement, labels and dedup.
27837
27908
 
27838
- Kinds: biography | goals | preferences | responsibilities | protocols (singletons, upsert) \xB7 thread (goal= required to create; shape="collection" for a titled-entry thread; appends need identity=) \xB7 threadEntry (a titled entry in a collection thread: thread= + title) \xB7 user (knowledge about the user) \xB7 information (domain= + title) \xB7 sources (domain=; revision=[\u2026] upserts rows; dryRun=true previews rows and near-duplicate keys without writing).
27909
+ Kinds: biography | goals | preferences | responsibilities | protocols (singletons, upsert) \xB7 thread (goal= required to create; shape="collection" for a titled-entry thread; appends need identity=) \xB7 threadEntry (a titled entry in a collection thread: thread= + title) \xB7 user (knowledge about the user) \xB7 information (domain= + title) \xB7 sources (domain=; revision=[\u2026] upserts rows, revision[].from= renames one when a source moves; dryRun=true previews rows, body placement and near-duplicate keys without writing).
27839
27910
 
27840
27911
  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.`, {
27841
27912
  kind: _enum(Kinds).describe("What kind of memory this is"),
@@ -27849,8 +27920,9 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
27849
27920
  revision: array(object2({
27850
27921
  source: string2().describe("Must exactly match how the source is introduced in the Sources list \u2014 this is the upsert key"),
27851
27922
  marker: string2().describe('"\u2747\uFE0F" (discovered/credible) or "\u2705" (used)'),
27852
- date: string2().optional().describe("ISO date (default: today)")
27853
- })).optional().describe("kind=sources: upsert Revision rows by source name"),
27923
+ date: string2().optional().describe("ISO date (default: today)"),
27924
+ from: string2().optional().describe("Rename: the row's current key. The row keyed from= is rekeyed to source= (marker and date updated) instead of a new row being added; refused when source= already has a row")
27925
+ })).optional().describe("kind=sources: upsert Revision rows by source name; from= renames a row when a source moves"),
27854
27926
  topics: array(string2()).optional().describe("Topic tags \u2014 slugged server-side"),
27855
27927
  supersedes: string2().optional().describe("noteId this replaces \u2014 old note is archived and wired supersedes"),
27856
27928
  mandate: boolean2().optional().describe("kind=information: flag the note as a standing brief a session must follow (#mandate)"),
@@ -27926,6 +27998,39 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
27926
27998
  didYouMeanNote: `${pairs.map((p) => `"${p.added}" \u2192 "${p.near.key}"`).join("; ")}. New Revision row(s) sit beside an existing source whose key is close \u2014 if one was meant to update that source, its key must match it exactly.`
27927
27999
  };
27928
28000
  };
28001
+ const planRevision = (content) => {
28002
+ let html = content;
28003
+ const changes = [];
28004
+ const added = [];
28005
+ const refused = [];
28006
+ const norm = (k) => k.replace(/\s+/g, " ").trim().toLowerCase();
28007
+ for (const row of revision ?? []) {
28008
+ const keys = tableRows(html, "Revision").map((cells) => cells[0] ?? "");
28009
+ if (row.from !== undefined && norm(row.from) !== norm(row.source)) {
28010
+ if (keys.some((k) => norm(k) === norm(row.source))) {
28011
+ refused.push(`rename "${row.from}" \u2192 "${row.source}": "${row.source}" already has a row \u2014 update that one, or remove one of the two rows first`);
28012
+ continue;
28013
+ }
28014
+ const result = upsertTableRow(html, "Revision", row.source, [row.marker, row.date ?? d], row.from);
28015
+ if (!result.matched) {
28016
+ refused.push(`rename "${row.from}" \u2192 "${row.source}": no Revision row is keyed "${row.from}"`);
28017
+ continue;
28018
+ }
28019
+ html = result.html;
28020
+ changes.push(`renamed: ${row.from} \u2192 ${row.source}`);
28021
+ continue;
28022
+ }
28023
+ const result = upsertTableRow(html, "Revision", row.source, [row.marker, row.date ?? d]);
28024
+ if (result.matched || result.created) {
28025
+ html = result.html;
28026
+ changes.push(`${result.matched ? "updated" : "added"}: ${row.source}`);
28027
+ if (!result.matched)
28028
+ added.push(row.source);
28029
+ }
28030
+ }
28031
+ return { html, changes, added, refused };
28032
+ };
28033
+ const isLooseProse = (h) => !/<(?:ul|ol|li|h[34]|table)\b/i.test(h);
27929
28034
  if (isSingleton(kind)) {
27930
28035
  const id = kindHome(b(), kind);
27931
28036
  if (!id)
@@ -27954,25 +28059,18 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
27954
28059
  else {
27955
28060
  const content = await trilium.getNoteContent(sidPreview);
27956
28061
  const existingKeys = tableRows(content, "Revision").map((cells) => cells[0]).filter(Boolean);
27957
- const previewChanges = [];
27958
- const previewAdded = [];
27959
- let sim = content;
27960
- for (const row of revision ?? []) {
27961
- const result = upsertTableRow(sim, "Revision", row.source, [row.marker, row.date ?? d]);
27962
- if (result.matched || result.created) {
27963
- sim = result.html;
27964
- previewChanges.push(`${result.matched ? "would update" : "would add"}: ${row.source}`);
27965
- if (!result.matched)
27966
- previewAdded.push(row.source);
27967
- }
27968
- }
27969
- if (previewChanges.length)
27970
- preview.revision = previewChanges;
28062
+ const plan = planRevision(content);
28063
+ if (plan.changes.length)
28064
+ preview.revision = plan.changes.map((c) => `would ${c.replace(/^(\w+):/, (_, v) => `${{ updated: "update", added: "add", renamed: "rename" }[v] ?? v}:`)}`);
28065
+ if (plan.refused.length)
28066
+ preview.refused = plan.refused;
27971
28067
  if (html && toText(html, 50) && !content.includes(html)) {
27972
28068
  const merged = mergeUnderSection(content, html, "Sources");
27973
28069
  preview.body = `would merge into the Sources section${merged.mergedInto.length ? `: ${merged.mergedInto.join(", ")}` : ""}${merged.appended.length ? `; new group(s): ${merged.appended.join(", ")}` : ""}`;
28070
+ if (isLooseProse(html))
28071
+ preview.placementHint = "This body has no list or group markup, so it would land as a loose block under Sources. Pass it as list items (or under an existing h3 group) to join the list structurally.";
27974
28072
  }
27975
- const hints = sourceKeyHints(previewAdded, existingKeys);
28073
+ const hints = sourceKeyHints(plan.added, existingKeys);
27976
28074
  if (hints) {
27977
28075
  preview.didYouMean = hints.didYouMean;
27978
28076
  preview.didYouMeanNote = hints.didYouMeanNote;
@@ -28005,26 +28103,20 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
28005
28103
  wrote = true;
28006
28104
  }
28007
28105
  }
28008
- const revisionChanges = [];
28009
- const addedKeys = [];
28106
+ let revisionChanges = [];
28107
+ let addedKeys = [];
28108
+ let refusedRows = [];
28010
28109
  let existingKeys = [];
28011
28110
  if (sid && revision?.length) {
28012
- let current = await trilium.getNoteContent(sid);
28013
- existingKeys = tableRows(current, "Revision").map((cells) => cells[0]).filter(Boolean);
28014
- let changed = false;
28015
- for (const row of revision) {
28016
- const result = upsertTableRow(current, "Revision", row.source, [row.marker, row.date ?? d]);
28017
- if (result.matched || result.created) {
28018
- current = result.html;
28019
- changed = true;
28020
- revisionChanges.push(`${result.matched ? "updated" : "added"}: ${row.source}`);
28021
- if (!result.matched)
28022
- addedKeys.push(row.source);
28023
- }
28024
- }
28025
- if (changed) {
28111
+ const before = await trilium.getNoteContent(sid);
28112
+ existingKeys = tableRows(before, "Revision").map((cells) => cells[0]).filter(Boolean);
28113
+ const plan = planRevision(before);
28114
+ revisionChanges = plan.changes;
28115
+ addedKeys = plan.added;
28116
+ refusedRows = plan.refused;
28117
+ if (plan.changes.length) {
28026
28118
  await trilium.createRevision(sid);
28027
- const stamped = bumpLastUpdated(current, d);
28119
+ const stamped = bumpLastUpdated(plan.html, d);
28028
28120
  await trilium.updateNoteContent(sid, stamped.html);
28029
28121
  await trilium.updateLabelValue(sid, "updated", d);
28030
28122
  wrote = true;
@@ -28038,7 +28130,7 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
28038
28130
  const revisionKeys = tableRows(finalHtml, "Revision").map((cells) => cells[0]).filter(Boolean);
28039
28131
  const placeholderLeft = hasPlaceholderRow(finalHtml, "Revision");
28040
28132
  const bodyWritten = !!html && !!toText(html, 50);
28041
- const looseProse = bodyWritten && !createdDomain && !/<(?:ul|ol|li|h[34]|table)\b/i.test(html);
28133
+ const looseProse = bodyWritten && !createdDomain && isLooseProse(html);
28042
28134
  const keyHints = sourceKeyHints(addedKeys, existingKeys);
28043
28135
  return txt3({
28044
28136
  action: wrote ? "maintained" : "already_written",
@@ -28048,9 +28140,10 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
28048
28140
  location: locationLabel(kind, domainTitle),
28049
28141
  ...createdDomain ? { createdDomain: domainTitle } : {},
28050
28142
  ...revisionChanges.length ? { revision: revisionChanges } : {},
28143
+ ...refusedRows.length ? { refused: refusedRows } : {},
28051
28144
  ...sourceMerge,
28052
28145
  ...revisionKeys.length ? { revisionRows: revisionKeys.length } : {},
28053
- ...revisionChanges.some((c) => c.startsWith("added:")) && revisionKeys.length ? { revisionKeys, revisionKeysNote: "A new Revision row was added. If it was meant to update an existing source, its key must match one of these exactly." } : {},
28146
+ ...addedKeys.length && revisionKeys.length && revisionKeys.length <= SOURCE_KEYS_ECHO ? { revisionKeys, revisionKeysNote: "A new Revision row was added. If it was meant to update an existing source, its key must match one of these exactly." } : addedKeys.length && revisionKeys.length ? { revisionKeysNote: `A new Revision row was added beside ${revisionKeys.length - addedKeys.length} existing keys (too many to echo). ${keyHints ? "didYouMean names the nearest existing key." : "No existing key is close to it."} Preview a new key with dryRun=true, and rename a moved source with revision=[{source, from}].` } : {},
28054
28147
  ...keyHints ? { didYouMean: keyHints.didYouMean, didYouMeanNote: keyHints.didYouMeanNote } : {},
28055
28148
  ...placeholderLeft ? { structureHint: "The Revision table still holds only its placeholder row. Every source marked \u2705 was verified by someone \u2014 record that with revision=[{source, marker, date}] so the table says so too; marker dates live there, never inline." } : {},
28056
28149
  ...looseProse ? { placementHint: "The body was appended to the Sources section as a loose block, which is rarely what a maintained source list wants. Fold it into the existing entries with revise(noteId, find=\u2026) \u2014 or pass list markup so it joins the list structurally." } : {},
@@ -28172,7 +28265,7 @@ ${html}` : html;
28172
28265
  const connected = await wireRequested(existing.noteId);
28173
28266
  const iconSet = await applyIcon(existing.noteId, icon);
28174
28267
  const relations = relationSnippet(existing);
28175
- return txt3({ action: "updated", noteId: existing.noteId, entryId: entry.noteId, entryAction: entry.action, ...entry.marker ? { entryMarker: entry.marker } : {}, ...runHeading ? { runHeading } : {}, entry: `today's [${d}] entry in this thread; revise(entryId) edits it`, kind, title: existing.title, ...connected.length ? { connected } : {}, ...iconSet ? { icon: iconSet } : {}, ...relations ? { relations } : {}, ...sanitizeWarnings.length ? { sanitized: sanitizeWarnings } : {} });
28268
+ return txt3({ action: "updated", noteId: existing.noteId, entryId: entry.noteId, entryAction: entry.action, ...entry.marker ? { entryMarker: entry.marker } : {}, ...runHeading ? { runHeading } : {}, ...entry.previousRun ? { previousRun: entry.previousRun } : {}, entry: `today's [${d}] entry in this thread; revise(entryId) edits it`, kind, title: existing.title, ...connected.length ? { connected } : {}, ...iconSet ? { icon: iconSet } : {}, ...relations ? { relations } : {}, ...sanitizeWarnings.length ? { sanitized: sanitizeWarnings } : {} });
28176
28269
  }
28177
28270
  const current = await trilium.getNoteContent(existing.noteId);
28178
28271
  if (isDuplicateAppend(current, block))
@@ -28569,12 +28662,15 @@ ${block}`), d);
28569
28662
  });
28570
28663
  });
28571
28664
  const READ_BUDGET = 80000;
28572
- server.tool("read", `Several note bodies in one round trip: ids=[\u2026] (up to 10) \u2192 {notes:[{id, title, kind, chars, content, relations}]}. Every entry carries chars, the size of that note's body in characters, so follow-up reads can be planned from this response alone. Bodies are returned in order until about 80k characters; the rest come back under deferred[] with their sizes, to read in a second call. section="<heading>" reads one section of each note instead of its whole body (h2\u2192h3\u2192h4 by heading text, occurrence= among repeats) \u2014 the cheap read of a huge note; a section holding a table also reports tableRows, its row count. text=true returns readable plain text with the markup removed (headings, list items and table rows kept), smaller and easier to read \u2014 right for orienting, not for building a find= anchor. For one note prefer the kinded read.`, {
28665
+ server.tool("read", `Several note bodies in one round trip: ids=[\u2026] (up to 10) \u2192 {notes:[{id, title, kind, chars, content, relations}]}. Every entry carries chars, the size of that note's body in characters, so follow-up reads can be planned from this response alone. Bodies are returned in order until about 80k characters; the rest come back under deferred[] with their sizes, to read in a second call. section="<heading>" reads one section of each note instead of its whole body (h2\u2192h3\u2192h4 by heading text, occurrence= among repeats) \u2014 the cheap read of a huge note; a section holding a table also reports tableRows, its row count. text=true returns readable plain text with the markup removed (headings, list items and table rows kept), smaller and easier to read \u2014 right for orienting, not for building a find= anchor. sizes=true returns no bodies at all: each note's chars and its h2\u2013h4 headings with their section sizes, so a bulk read can be planned before it is made. For one note prefer the kinded read.`, {
28573
28666
  ids: array(string2()).min(1).max(10).describe("Note ids to read \u2014 one body per id, one round trip (cap 10)"),
28574
28667
  section: string2().optional().describe("Read only this heading's section of each note (h2/h3/h4, same matching as the singletons' section=) instead of each whole body \u2014 the cheap read of a huge note; a miss returns the note's real headings under available[]"),
28575
28668
  occurrence: number2().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)"),
28576
- text: boolean2().optional().describe("Return readable plain text instead of stored HTML (default false; with section=, the section only)")
28577
- }, async ({ ids, text, section, occurrence }) => {
28669
+ text: boolean2().optional().describe("Return readable plain text instead of stored HTML (default false; with section=, the section only)"),
28670
+ sizes: boolean2().optional().describe("Return sizes only \u2014 chars and each h2\u2013h4 heading with its section's size \u2014 and no bodies: plan a read that would otherwise overflow")
28671
+ }, async ({ ids, text, section, occurrence, sizes }) => {
28672
+ if (sizes && (section !== undefined || text !== undefined))
28673
+ return err("conflicting_params", "sizes=true returns no bodies, so section= and text= do not apply.", "Read the sizes first, then read(ids, section=\u2026) or read(ids, text=true) for the bodies you need.");
28578
28674
  const fetched = await Promise.all(ids.map(async (id) => {
28579
28675
  try {
28580
28676
  const note = await trilium.getNote(id);
@@ -28582,6 +28678,10 @@ ${block}`), d);
28582
28678
  const relations = relationSnippet(note);
28583
28679
  const raw = typeof contentResult === "string" ? contentResult : contentResult.content;
28584
28680
  const identity = { id, title: note.title, kind: ownedLabel(note, "noteType") ?? undefined, chars: raw.length };
28681
+ if (sizes) {
28682
+ const flat = typeof contentResult === "string" ? headingOutline(raw).filter((h) => !/^Last updated\b/i.test(h.text)).map((h) => ({ heading: h.text, level: h.level, chars: getSection(raw, h.text, h.occurrence).content.length })) : [];
28683
+ return { ...identity, sizesOnly: true, ...flat.length ? { headings: flat.slice(0, 60) } : {}, ...flat.length > 60 ? { headingsShown: 60 } : {} };
28684
+ }
28585
28685
  if (section && typeof contentResult !== "string") {
28586
28686
  return { ...identity, content: "", section, matched: false, hint: "Binary content cannot be sectioned as text; read it without section=." };
28587
28687
  }
@@ -28710,7 +28810,7 @@ ${block}`), d);
28710
28810
  };
28711
28811
  server.tool("revise", `Edit a note by id. A revision is taken first; "Last updated" lines are bumped on content writes.
28712
28812
 
28713
- 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 the matched span; it is not an insertion anchor. Use within= with mode=before/after for element insertion, or section= with mode=prepend/before/after for section insertion. It 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. within= names the element acted on: within="tr" replaces the whole row (body is a full <tr>\u2026</tr>), within="td" one cell (body is a <td>\u2026</td>), and a plain find= inside a cell replaces only that text. dryRun=true with find=, edits=, within= or moves= previews the change first \u2014 the element's full visible text and byte size, and for remove/replace the before/after shape \u2014 with nothing written and no revision taken. Keep anchors short; quotes match in any form, and within= also matches an element by its visible text ("Phase 2 | Claude | Open" for a row). append={table, row} adds one row to the table under that heading with no anchor to locate first \u2014 pipes are cell boundaries, a lone placeholder row is replaced, and append= composes with nothing else. closure= with within="tr" and mode="remove" closes a register row in one call: {thread, body} removes the row and appends the closure (quoting it) to that thread's entry for today (needs identity=); {note, table?, row?} moves a row into another register. Destination cells must match exactly; row= supplies explicit plain-text cells. Ambiguous tables are refused. Destination content is written before source removal, and same-note moves use one write. Partial failures report recovery instructions; exact destination rows and identical thread blocks are deduplicated on retry. moves=[{find, nth?, row?}] batches closures to one target, preflights every row, and stops with per-row progress on failure. "Last updated" lines are server-owned: every content write bumps them, so never find= on one. A replacement equal to its match writes nothing and reports unchanged. title= composes with every mode; retitling a domain book cascades its #domain slug.
28813
+ 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 the matched span; it is not an insertion anchor. Use within= with mode=before/after for element insertion, or section= with mode=prepend/before/after for section insertion. It 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; all=true acts on every element the anchor sits in (preview with dryRun=true). within= names the element acted on: within="tr" replaces the whole row (body is a full <tr>\u2026</tr>), within="td" one cell (body is a <td>\u2026</td>), and a plain find= inside a cell replaces only that text. dryRun=true with find=, edits=, within= or moves= previews the change first \u2014 the element's full visible text and byte size, and for remove/replace the before/after shape \u2014 with nothing written and no revision taken. Keep anchors short; quotes match in any form, and within= also matches an element by its visible text ("Phase 2 | Claude | Open" for a row). append={table, row} adds one row to the table under that heading with no anchor to locate first \u2014 pipes are cell boundaries, a lone placeholder row is replaced, and append= composes with nothing else. closure= with within="tr" and mode="remove" closes a register row in one call: {thread, body} removes the row and appends the closure (quoting it) to that thread's entry for today (needs identity=); {note, table?, row?} moves a row into another register. Destination cells must match exactly; row= supplies explicit plain-text cells. Ambiguous tables are refused. Destination content is written before source removal, and same-note moves use one write. Partial failures report recovery instructions; exact destination rows and identical thread blocks are deduplicated on retry. moves=[{find, nth?, row?}] batches closures to one target, preflights every row, and stops with per-row progress on failure. "Last updated" lines are server-owned: every content write bumps them, so never find= on one. A replacement equal to its match writes nothing and reports unchanged. title= composes with every mode; retitling a domain book cascades its #domain slug.
28714
28814
 
28715
28815
  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. A find= or within= anchor that matches nothing always names the cause in hint=, and a miss shows the stored text nearby when part of the anchor is still there (matchedUpTo= / storedNearby=).`, {
28716
28816
  noteId: string2().optional().describe("Note to update (omit only with domain=)"),
@@ -28725,6 +28825,7 @@ Check the receipt: matched=false means a NEW section was written (available= lis
28725
28825
  find: string2().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."),
28726
28826
  nth: number2().int().positive().optional().describe("find=: replace only the Nth occurrence, 1-based (default: all of them). With within=: the Nth containing element"),
28727
28827
  within: _enum(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. dryRun=true previews it first'),
28828
+ all: boolean2().optional().describe("With within=: act on EVERY element containing the anchor instead of refusing an anchor found in several (e.g. remove every Revision row naming a retired source). Preview with dryRun=true first; does not combine with nth= or closure="),
28728
28829
  closure: object2({
28729
28830
  thread: string2().optional().describe('Thread variant: the dated thread to record the closure in: its id or title (e.g. "Escalations") \u2014 together with body='),
28730
28831
  body: string2().optional().describe("Thread variant: the closure prose: what settled it, who ruled, and where its durable substance went"),
@@ -28749,9 +28850,9 @@ Check the receipt: matched=false means a NEW section was written (available= lis
28749
28850
  identity: string2().optional().describe('Appending to a dated thread: the identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" (required)'),
28750
28851
  icon: string2().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name; normalized server-side'),
28751
28852
  date: string2().optional().describe("ISO date (default: today)")
28752
- }, async function reviseNote({ noteId: noteIdArg, domain, dryRun, body, title, section, occurrence, mode, find, nth, within, closure, append, edits, moves, identity, icon, date, strict }) {
28853
+ }, async function reviseNote({ noteId: noteIdArg, domain, dryRun, body, title, section, occurrence, mode, find, nth, within, all, closure, append, edits, moves, identity, icon, date, strict }) {
28753
28854
  if (moves) {
28754
- if (!noteIdArg || domain || !closure || within !== "tr" || mode !== "remove" || find !== undefined || edits !== undefined || append || section || body !== undefined || title || icon || nth !== undefined)
28855
+ if (!noteIdArg || domain || !closure || within !== "tr" || mode !== "remove" || find !== undefined || edits !== undefined || append || section || body !== undefined || title || icon || nth !== undefined || all !== undefined)
28755
28856
  return err("conflicting_params", "moves= needs noteId, within=tr, mode=remove and closure; it cannot combine with other edit operations.", "Supply one unique find anchor and optional destination row per move.");
28756
28857
  const results = [];
28757
28858
  const elements = new Set;
@@ -28774,7 +28875,8 @@ Check the receipt: matched=false means a NEW section was written (available= lis
28774
28875
  edits: undefined,
28775
28876
  moves: undefined,
28776
28877
  icon: undefined,
28777
- strict: undefined
28878
+ strict: undefined,
28879
+ all: undefined
28778
28880
  });
28779
28881
  for (const move of moves) {
28780
28882
  const r = JSON.parse((await one(move, true)).content[0].text);
@@ -28831,6 +28933,10 @@ Check the receipt: matched=false means a NEW section was written (available= lis
28831
28933
  const noteId = noteIdArg;
28832
28934
  if (isContainer(b(), noteId))
28833
28935
  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.");
28936
+ if (all !== undefined && !within)
28937
+ return err("conflicting_params", "all= applies to within=; nothing was written.", 'e.g. revise(noteId, find="<anchor>", within="tr", mode="remove", all=true, dryRun=true). A plain find= already replaces every occurrence.');
28938
+ if (all && (nth !== undefined || closure || moves))
28939
+ return err("conflicting_params", "all=true acts on every matching element, so it does not combine with nth=, closure= or moves=; nothing was written.", "Drop all= to target one element, or drop nth=/closure= to act on all of them.");
28834
28940
  if (within && find === undefined)
28835
28941
  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>").');
28836
28942
  if (append) {
@@ -28950,9 +29056,9 @@ Check the receipt: matched=false means a NEW section was written (available= lis
28950
29056
  const inlineHits = exact.length || tolerantHits.length ? [] : inlineTolerantSpans(current, find);
28951
29057
  const visibleHits = exact.length || tolerantHits.length || inlineHits.length ? [] : visibleTextSpans(current, find, within);
28952
29058
  const hits = exact.length ? exact : tolerantHits.length ? tolerantHits : inlineHits.length ? inlineHits : visibleHits;
28953
- const res = editWithin(current, hits, within, action, body ?? "", nth);
29059
+ const res = editWithin(current, hits, within, action, body ?? "", nth, all === true);
28954
29060
  if (!res.ok) {
28955
- 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.`;
29061
+ 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}>. ${pipeParagraphHint(current, hits, 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, pass nth= (1-based, in document order), or all=true to act on every one \u2014 elements[] previews them.`;
28956
29062
  return txt3({
28957
29063
  ok: false,
28958
29064
  noteId,
@@ -29046,6 +29152,7 @@ ${closureHtml}`;
29046
29152
  ...closureTarget ? { closure: { noteId: closureTarget.note.noteId, note: closureTarget.note.title, table: closureTarget.heading ?? "(first table)", row: closureCells.map((c) => toText(c, 80)).join(" | "), action: "would-append" } } : {}
29047
29153
  },
29048
29154
  matchedElements: res.matches,
29155
+ ...all && res.elements ? { elements: res.elements.slice(0, 20).map((e) => toText(e, 160)), ...res.elements.length > 20 ? { elementsShown: 20 } : {} } : {},
29049
29156
  next: "Nothing was written and no revision was taken. Re-run with dryRun=false to apply exactly this change."
29050
29157
  });
29051
29158
  }
@@ -29065,7 +29172,8 @@ ${closureHtml}`;
29065
29172
  entryId: entry.noteId,
29066
29173
  action: entry.action,
29067
29174
  ...entry.marker ? { entryMarker: entry.marker } : {},
29068
- runHeading: leadingIdentityText(closureBlock)
29175
+ runHeading: leadingIdentityText(closureBlock),
29176
+ ...entry.previousRun ? { previousRun: entry.previousRun } : {}
29069
29177
  };
29070
29178
  await trilium.updateLabelValue(closureThread.noteId, "updated", d);
29071
29179
  }
@@ -29138,7 +29246,7 @@ ${closureHtml}`;
29138
29246
  noteId,
29139
29247
  mode: `within:${within}:${action}`,
29140
29248
  date: d,
29141
- element: toText(res.element, 200),
29249
+ ...all ? { elements: res.matches } : { element: toText(res.element, 200) },
29142
29250
  ...closureReceipt ? { closure: closureReceipt } : {},
29143
29251
  ...titledWithin.retitled ? { retitled: titledWithin.retitled } : {},
29144
29252
  ...iconWithin ? { icon: iconWithin } : {},
@@ -29382,7 +29490,7 @@ ${block}`), d).html;
29382
29490
  } : {},
29383
29491
  ...sectionMiss,
29384
29492
  ...sectionHint ? { hint: sectionHint } : {},
29385
- ...threadEntryResult ? { entryId: threadEntryResult.noteId, entryAction: threadEntryResult.action, ...threadEntryResult.marker ? { entryMarker: threadEntryResult.marker } : {}, ...threadRunHeading ? { runHeading: threadRunHeading } : {}, entry: `today's [${d}] entry in this thread; revise(entryId) edits it` } : {},
29493
+ ...threadEntryResult ? { entryId: threadEntryResult.noteId, entryAction: threadEntryResult.action, ...threadEntryResult.marker ? { entryMarker: threadEntryResult.marker } : {}, ...threadRunHeading ? { runHeading: threadRunHeading } : {}, ...threadEntryResult.previousRun ? { previousRun: threadEntryResult.previousRun } : {}, entry: `today's [${d}] entry in this thread; revise(entryId) edits it` } : {},
29386
29494
  ...titled.retitled ? { retitled: titled.retitled } : {},
29387
29495
  ...titled.cascaded ? { domainCascade: `#domain updated on ${titled.cascaded} descendant note(s)` } : {},
29388
29496
  ...structuralFindings(noteKind, finalContent),
@@ -30188,15 +30296,18 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
30188
30296
  ...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.' } : {}
30189
30297
  });
30190
30298
  });
30191
- 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.`, {
30299
+ 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; summaryOnly=true lists those notes with their write counts and sizes and no changed lines, the cheap sweep for a recap. Run it before close to check today's corrections agree with each other.`, {
30192
30300
  noteId: string2().optional().describe("Note to diff (omit with since= to review every note changed since then)"),
30193
30301
  revisionId: string2().optional().describe("Compare against this revision (default: the most recent one)"),
30194
30302
  since: string2().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'),
30195
30303
  includeRecords: boolean2().optional().describe("since= without noteId: also list records (sessions, diary, logs, dated thread entries), which only ever gain addenda (default false)"),
30196
30304
  limit: number2().int().min(1).max(50).optional().describe("since= without noteId: max notes to diff (default 25)"),
30197
- context: number2().int().min(0).max(10).optional().describe("Unchanged lines to show around each change (default: 1)")
30198
- }, async ({ noteId, revisionId, since, includeRecords, limit, context }) => {
30305
+ context: number2().int().min(0).max(10).optional().describe("Unchanged lines to show around each change (default: 1)"),
30306
+ summaryOnly: boolean2().optional().describe("since= without noteId: list each changed note with writes, chars and sizeChange (characters gained or lost), without the changed lines")
30307
+ }, async ({ noteId, revisionId, since, includeRecords, limit, context, summaryOnly }) => {
30199
30308
  const pad = context ?? 1;
30309
+ if (summaryOnly !== undefined && (since === undefined || noteId !== undefined))
30310
+ return err("conflicting_params", "summaryOnly= applies to since= without noteId.", 'e.g. diff(since="today", summaryOnly=true, includeRecords=true); diff one note with noteId= for its lines.');
30200
30311
  if (since !== undefined) {
30201
30312
  const cutoff = sinceCutoff(since);
30202
30313
  if (!cutoff)
@@ -30225,6 +30336,10 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
30225
30336
  continue;
30226
30337
  }
30227
30338
  const [before, after] = await Promise.all([trilium.getRevisionContent(first.revisionId), trilium.getNoteContent(n.noteId)]);
30339
+ if (summaryOnly) {
30340
+ notes.push({ ...stub, writes: revisionsSince(revisions, cutoff).length, chars: after.length, sizeChange: after.length - before.length, ...before === after ? { identical: true } : {} });
30341
+ continue;
30342
+ }
30228
30343
  const d = blockDiff(before, after, pad, 12);
30229
30344
  notes.push({ ...stub, writes: revisionsSince(revisions, cutoff).length, ...d.identical ? { identical: true } : { summary: d.summary, removed: d.removed, added: d.added } });
30230
30345
  }
@@ -32991,7 +33106,7 @@ function createServer(origin = null) {
32991
33106
  const s = new McpServer({
32992
33107
  name: BRAND2.name,
32993
33108
  title: BRAND2.name,
32994
- version: "13.1.2",
33109
+ version: "13.2.0",
32995
33110
  description: BRAND2.description,
32996
33111
  websiteUrl: origin ?? BRAND2.website,
32997
33112
  icons: brandIcons(origin)
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": "13.1.2",
5
+ "version": "13.2.0",
6
6
  "description": "Self-hosted memory for LLMs in TriliumNext, structured by the server and editable by you.",
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": "13.1.2",
3
+ "version": "13.2.0",
4
4
  "mcpName": "io.github.miisodev/brainllm",
5
5
  "description": "Self-hosted memory for LLMs in TriliumNext, structured by the server and editable by you.",
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": "Self-hosted memory for LLMs in TriliumNext, structured by the server and editable by you.",
5
- "version": "13.1.2",
5
+ "version": "13.2.0",
6
6
  "websiteUrl": "https://miisodev.github.io/BrainLLM/",
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": "13.1.2",
15
+ "version": "13.2.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -95,7 +95,7 @@ worth keeping?
95
95
  | skim a surface | `<surface>_recall` |
96
96
  | everything about an area | `domain(name)` — the reliable path; reach here first. `outline=true` adds every maintained note's headings and size, for planning a multi-note rewrite |
97
97
  | search | `recall(query, domain=…)` — scope it; `since=`/`until=` keep only notes last modified inside the window (date-only bounds cover whole days); every hit names `matchedBy` (`label`/`title`/`text`/`fuzzy`/`ordered`/`regex`) and the top hits carry the `evidence` passage; `regex=` for structure, evidenced on every hit (records included; `kinds=` narrows); fuzzy hits are leads; recent notes rank slightly higher. A receipt that saturated a strategy's 30-candidate cap says so; `searchFailed: true` is a backend failure, never a "nothing matched" verdict — retry |
98
- | several bodies at once | `read(ids=[…])` (≤ 10). Every entry carries `chars` (the full stored size, even when a section is returned); `section=` (+ `occurrence=`) reads one heading's body from each note — a table section reports `tableRows`, and a miss follows the singleton miss shape (`missing: true` for an unknown id; `available[]` lists the real headings for a section miss). Bodies stop at ~80k characters; the rest come back in `deferred[]` for a second call. `text=true` returns readable plain text for orienting (not for building a `find=` anchor) |
98
+ | several bodies at once | `read(ids=[…])` (≤ 10). Every entry carries `chars` (the full stored size, even when a section is returned); `section=` (+ `occurrence=`) reads one heading's body from each note — a table section reports `tableRows`, and a miss follows the singleton miss shape (`missing: true` for an unknown id; `available[]` lists the real headings for a section miss). Bodies stop at ~80k characters; the rest come back in `deferred[]` for a second call. `text=true` returns readable plain text for orienting (not for building a `find=` anchor). `sizes=true` returns no bodies, only each note's `chars` and its headings with their section sizes: plan a bulk read before it overflows |
99
99
  | storage and growth | `health()` — Trilium version, estimated database size, heaviest notes and their revisions, named backups |
100
100
  | what the brain holds | `assembly(area?)` — titles by surface with purposes |
101
101
  | inventory / locate by id | `brain()` — read `parent`, not position |
@@ -120,7 +120,7 @@ Every read that can be large takes `section=` — `read(ids)` too, across each n
120
120
  | the session log | `close(summary, identity, title?, learned?)` |
121
121
 
122
122
  - **Dedup is by title.** Generic titles (Current State, Sources) exist in many domains — pass `mustCreate=true` when you mean to create, and read `action` on every receipt.
123
- - **Sources preview before they land.** `remember(kind="sources", …, revision=)` takes `dryRun=true`, which previews each Revision row addition/update without writing; a new key near an existing one returns `didYouMean` (with `didYouMeanNote`) on the dry run and on the real call, so a qualifier added to a name updates its row instead of creating a second one.
123
+ - **Sources preview before they land.** `remember(kind="sources", …, revision=)` takes `dryRun=true`, which previews each Revision row addition/update without writing; a new key near an existing one returns `didYouMean` (with `didYouMeanNote`) on the dry run and on the real call, so a qualifier added to a name updates its row instead of creating a second one. When a source moves or is renamed, `revision=[{source: "<new>", from: "<old>", marker}]` rekeys its row instead of adding one (refused when `<new>` already has a row). The dry run also previews where a `body=` lands and warns before loose prose joins the list.
124
124
  - **Wire at creation:** `connect=[{relation, toNoteId}]` on the same call. An unconnected note is an orphan until wired.
125
125
  - **Identity line** (`"LLM · environment · agent/mode [· Run N]"`) is required on diary, close and dated thread appends. It is checked on write: at least three `·`-separated parts, one line, no draft text — a malformed one is refused with nothing written.
126
126
  - **Every note carries an icon except logs.** The server sets the kind default at creation (a thread entry takes its thread's icon) and the sweep backfills any missing; `icon=` picks a better one (boxicons class or bare name). Removing an icon is refused.
@@ -151,17 +151,18 @@ Read `template(kind)` before your first write of a kind, then read a sibling.
151
151
  | `find="<text>"` | replace every occurrence (`nth=` for one) — exact first, then through re-serialized tags, then ignoring inline formatting (`matchMode` says which) |
152
152
  | `edits=[{find, body}]` | several surgeries in one read and one write |
153
153
  | `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). `within` names what is replaced: `"tr"` takes a whole `<tr>…</tr>` body, `"td"` one `<td>…</td>`; a plain `find=` inside a cell replaces only that text |
154
+ | `within=` + `all=true` | act on **every** element the anchor sits in (remove every Revision row naming a retired source, in one write) instead of refusing an ambiguous anchor; preview with `dryRun=true`, which lists them. Does not combine with `nth=` or `closure=` |
154
155
  | `within=` + `dryRun=true` | preview the element first: its full visible extent and byte size, plus the before/after shape (for a `closure=`, what would be appended or moved) — nothing written, no revision. `find=` and `edits=` also support `dryRun=true` using the same replacement and sanitizer path; metadata changes cannot be combined with a preview. `domain=` previews by default. Unsupported preview modes are refused |
155
156
  | `within="tr"` + `mode="remove"` + `closure=` | close a register row in one call: `{thread, body}` sends the row, quoted, into that thread's entry for today (needs `identity=`); `{note, table?, row?}` moves the row into another register. Column counts must match exactly; `row=["cell", …]` provides explicitly shaped plain-text destination cells. Name `table` when several tables exist. Destination content lands before source removal; same-note moves use one write. On a partial failure, read both notes before retrying; exact destination rows and identical dated blocks are deduplicated. Run `consistency()` after on any fact the row asserted |
156
157
  | `append={table, row}` | append one row to the table under that heading with no anchor to locate first — pipes are cell boundaries, a lone placeholder row is replaced; composes with nothing else |
157
158
  | `moves=[{find, nth?, row?}]` + `within="tr"`, `mode="remove"`, `closure=` | batch row closures to one target; all rows are preflighted, then applied in order. `dryRun=true` previews without writes. Stops on failure with completed and remaining rows; it is not an atomic transaction |
158
159
  | `domain="<name>"` + `find=`/`edits=` (no noteId) | the same find/replace across every maintained note in a domain; a dry run listing each match unless `dryRun=false`. Records are never touched |
159
160
 
160
- - **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=`. The row's visible text works too (`"Phase 2 | Claude | Open"`), and quotes match in any form. An anchor in several rows is refused with previews; `nth=` picks one. Write one `<td>` per column — a row whose cells disagree with its header comes back as `tableShape`.
161
+ - **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=`. The row's visible text works too (`"Phase 2 | Claude | Open"`), and quotes match in any form. An anchor in several rows is refused with previews; `nth=` picks one, `all=true` takes them all. A `within="tr"` miss on a line written as `a | b | c` says it is a paragraph, not a row — use `within="p"`. Write one `<td>` per column — a row whose cells disagree with its header comes back as `tableShape`.
161
162
  - **"Last updated" lines are server-owned.** Every content write bumps them (the receipt says `lastUpdated`); never `find=` on one.
162
163
  - A replacement equal to its match writes nothing and reports `unchanged`.
163
- - A failed revision snapshot stops the content overwrite. System errors are not success receipts. 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.
164
- - **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. A record append (`remember`/`revise`/`closure`) names where it landed: `entryMarker` (the `Addendum — HH:mm` block heading written) and `runHeading` (the identity heading, when the body opened with one).
164
+ - A failed revision snapshot stops the content overwrite. System errors are not success receipts. 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. `summaryOnly=true` lists the changed notes with write counts and size changes and no lines, the cheap sweep for a recap.
165
+ - **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. A record append (`remember`/`revise`/`closure`) names where it landed: `entryMarker` (the `Addendum — HH:mm` block heading written) and `runHeading` (the identity heading, when the body opened with one); a dated-thread append adds `previousRun`, the same author's previous block (`heading`, `date`, and `run` when it carries `Run N`), so the next run number needs no walk-back read.
165
166
  - **`find=` replaces text; it does not insert beside an anchor.** To insert, use `within=` with `mode="before"/"after"`, or `section=` with an insertion mode. **`find=` prefers stored HTML.** Plain text also matches through inline formatting (`<strong>`, `<code>`, `<em>`, entities), so a short anchor rarely needs its markup; a span that opens or closes formatting is widened to keep it balanced. Block tags still match literally; `outline()` gives the `raw` form of headings. On a miss the hint names the cause in every shape: drifted text shows the stored text nearby (`storedNearby`), `nth=` past the last occurrence says so, and an all-miss `edits=` returns a hint per edit.
166
167
  - A section replace swaps everything under the heading — use `find=` for anything smaller.
167
168
  - **Oversized note (past the read ceiling)?** It usually holds two subjects: `split(noteId, sections=[…], into="<title>")` moves whole sections to a new sibling and leaves a pointer.