brainllm 12.9.0 → 13.0.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/dist/index.js CHANGED
@@ -22143,11 +22143,12 @@ var TAG_ATTRIBUTES = {
22143
22143
  img: new Set(["src", "alt", "width", "height", "loading"]),
22144
22144
  th: new Set(["colspan", "rowspan", "scope", "headers"]),
22145
22145
  td: new Set(["colspan", "rowspan", "headers"]),
22146
- code: new Set(["class"]),
22146
+ code: new Set(["class", "spellcheck"]),
22147
22147
  pre: new Set(["class"]),
22148
22148
  span: new Set(["class"]),
22149
22149
  mark: new Set(["class"]),
22150
- ol: new Set(["start", "reversed", "type"])
22150
+ ol: new Set(["start", "reversed", "type"]),
22151
+ li: new Set(["data-list-item-id"])
22151
22152
  };
22152
22153
  function safeHtmlUrl(value) {
22153
22154
  const decoded = decodeEntities(value).replace(/[\u0000\u0001-\u001f\u007f]/g, "").trim();
@@ -22807,26 +22808,35 @@ function unbalancedTags(html) {
22807
22808
  }
22808
22809
  function tableShapeIssues(html) {
22809
22810
  const issues = [];
22810
- const cellSpan = (attrs) => Number(/colspan\s*=\s*"?(\d+)/i.exec(attrs ?? "")?.[1] ?? 1);
22811
+ const spanOf = (attrs, name) => Number(new RegExp(`${name}\\s*=\\s*"?(\\d+)`, "i").exec(attrs ?? "")?.[1] ?? 1);
22811
22812
  for (const table of html.matchAll(/<table\b[^>]*>([\s\S]*?)<\/table>/gi)) {
22812
22813
  const rows = [...table[1].matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)].map((r) => [...r[1].matchAll(/<(t[hd])\b([^>]*)>([\s\S]*?)<\/\1>/gi)].map((c) => ({
22813
- span: cellSpan(c[2]),
22814
+ span: spanOf(c[2], "colspan"),
22815
+ rows: spanOf(c[2], "rowspan"),
22814
22816
  text: decodeEntities(c[3].replace(/<[^>]+>/g, "")).trim()
22815
22817
  })));
22816
22818
  if (rows.length < 2)
22817
22819
  continue;
22818
22820
  const width = rows[0].reduce((sum, c) => sum + c.span, 0);
22819
- for (const cells of rows.slice(1)) {
22820
- const label = (cells[0]?.text ?? "").slice(0, 60) || "(empty row)";
22821
- const count = cells.reduce((sum, c) => sum + c.span, 0);
22822
- if (count !== width) {
22823
- issues.push(`row "${label}" has ${count} cell(s), its header has ${width}`);
22824
- continue;
22825
- }
22826
- const piped = cells.find((c) => (c.text.match(/\s\|\s/g)?.length ?? 0) >= Math.max(1, width - 2) && width > 1);
22827
- if (piped && cells.filter((c) => c.text).length < width) {
22828
- issues.push(`row "${label}" carries pipe-separated values inside one cell \u2014 send each value as its own <td>`);
22821
+ let spanning = [];
22822
+ for (const [i, cells] of rows.entries()) {
22823
+ const inherited = spanning.reduce((sum, s) => sum + s.columns, 0);
22824
+ if (i > 0) {
22825
+ const label = (cells[0]?.text ?? "").slice(0, 60) || "(empty row)";
22826
+ const count = cells.reduce((sum, c) => sum + c.span, 0) + inherited;
22827
+ if (count !== width) {
22828
+ issues.push(`row "${label}" has ${count} cell(s), its header has ${width}`);
22829
+ } else {
22830
+ const piped = cells.find((c) => (c.text.match(/\s\|\s/g)?.length ?? 0) >= Math.max(1, width - 2) && width > 1);
22831
+ if (piped && cells.filter((c) => c.text).length < width) {
22832
+ issues.push(`row "${label}" carries pipe-separated values inside one cell \u2014 send each value as its own <td>`);
22833
+ }
22834
+ }
22829
22835
  }
22836
+ spanning = [
22837
+ ...spanning.map((s) => ({ ...s, rowsLeft: s.rowsLeft - 1 })).filter((s) => s.rowsLeft > 0),
22838
+ ...cells.filter((c) => c.rows > 1).map((c) => ({ columns: c.span, rowsLeft: c.rows - 1 }))
22839
+ ];
22830
22840
  }
22831
22841
  }
22832
22842
  return issues;
@@ -22933,6 +22943,108 @@ function upsertTableRow(html, heading, key, cells) {
22933
22943
  created: !matchedRow
22934
22944
  };
22935
22945
  }
22946
+ function findTable(html, heading) {
22947
+ let scope = html;
22948
+ let scopeStart = 0;
22949
+ if (heading !== undefined) {
22950
+ const section = locateSection(html, heading);
22951
+ if (!section)
22952
+ return null;
22953
+ scope = html.slice(section.contentStart, section.end);
22954
+ scopeStart = section.contentStart;
22955
+ }
22956
+ const tableMatch = /<table[^>]*>[\s\S]*?<\/table>/i.exec(scope);
22957
+ if (!tableMatch)
22958
+ return null;
22959
+ const tbodyMatch = /<tbody[^>]*>([\s\S]*?)<\/tbody>/i.exec(tableMatch[0]);
22960
+ if (!tbodyMatch)
22961
+ return null;
22962
+ const firstRow = /<tr[^>]*>([\s\S]*?)<\/tr>/i.exec(tableMatch[0]);
22963
+ if (!firstRow)
22964
+ return null;
22965
+ const columns = [...firstRow[1].matchAll(/<t[hd](?=[\s>/])([^>]*)>/gi)].reduce((sum, c) => sum + Number(/colspan\s*=\s*"?(\d+)/i.exec(c[1] ?? "")?.[1] ?? 1), 0);
22966
+ if (!columns)
22967
+ return null;
22968
+ const tbodyInner = tbodyMatch[1];
22969
+ const tbodyInnerStart = scopeStart + tableMatch.index + tbodyMatch.index + tbodyMatch[0].indexOf(">") + 1;
22970
+ const insertAt = tbodyInnerStart + tbodyInner.length;
22971
+ let placeholder;
22972
+ const rowRe = /<tr[^>]*>[\s\S]*?<\/tr>/gi;
22973
+ let m;
22974
+ while ((m = rowRe.exec(tbodyInner)) !== null) {
22975
+ const first = /<td[^>]*>([\s\S]*?)<\/td>/i.exec(m[0]);
22976
+ const text = first ? decodeEntities(first[1].replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim().toLowerCase() : "";
22977
+ if (text === "" || text === "\u2014 none yet \u2014") {
22978
+ placeholder = { start: tbodyInnerStart + m.index, end: tbodyInnerStart + m.index + m[0].length };
22979
+ break;
22980
+ }
22981
+ }
22982
+ return { insertAt, columns, ...placeholder ? { placeholder } : {} };
22983
+ }
22984
+ function appendRowToTable(html, spot, cells) {
22985
+ const row = `<tr>${cells.map((c) => `<td>${c}</td>`).join("")}</tr>`;
22986
+ const at = spot.placeholder ?? { start: spot.insertAt, end: spot.insertAt };
22987
+ return html.slice(0, at.start) + row + html.slice(at.end);
22988
+ }
22989
+ function fitRowCells(cells, columns) {
22990
+ if (columns <= 0 || cells.length > columns)
22991
+ return null;
22992
+ return [...cells, ...Array.from({ length: columns - cells.length }, () => "")];
22993
+ }
22994
+ function rowCells(row) {
22995
+ return [...row.matchAll(/<(t[hd])(?=[\s>/])[^>]*>([\s\S]*?)<\/\1>/gi)].map((m) => m[2].trim());
22996
+ }
22997
+ function nearestKey(requested, available) {
22998
+ const clean = (s) => decodeEntities(s).replace(/\s+/g, " ").trim();
22999
+ const req = clean(requested);
23000
+ if (!req)
23001
+ return null;
23002
+ const wordSet = (s) => new Set(s.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(Boolean));
23003
+ const reqWords = wordSet(req);
23004
+ let best = null;
23005
+ const rank = (c) => c.reason === "compound" ? [0, c.distance] : [1, c.distance];
23006
+ for (const key of available) {
23007
+ const cand = clean(key);
23008
+ if (!cand || cand.toLowerCase() === req.toLowerCase())
23009
+ continue;
23010
+ let hit = null;
23011
+ const candWords = wordSet(cand);
23012
+ const small = reqWords.size <= candWords.size ? reqWords : candWords;
23013
+ const large = reqWords.size <= candWords.size ? candWords : reqWords;
23014
+ if (small.size) {
23015
+ let extra = 0;
23016
+ let subset = true;
23017
+ for (const w of small)
23018
+ if (!large.has(w)) {
23019
+ subset = false;
23020
+ break;
23021
+ }
23022
+ if (subset) {
23023
+ for (const w of large)
23024
+ if (!small.has(w))
23025
+ extra++;
23026
+ if (extra <= 2)
23027
+ hit = { distance: extra, reason: "compound" };
23028
+ }
23029
+ }
23030
+ if (!hit) {
23031
+ const d = editDistance(req, cand);
23032
+ if (d > 0 && d <= Math.max(2, Math.floor(req.length / 5)))
23033
+ hit = { distance: d, reason: "typo" };
23034
+ }
23035
+ if (!hit)
23036
+ continue;
23037
+ if (!best) {
23038
+ best = { key, ...hit };
23039
+ continue;
23040
+ }
23041
+ const [r1, d1] = rank(hit);
23042
+ const [r2, d2] = rank(best);
23043
+ if (r1 < r2 || r1 === r2 && d1 < d2)
23044
+ best = { key, ...hit };
23045
+ }
23046
+ return best;
23047
+ }
22936
23048
 
22937
23049
  // src/templates.ts
22938
23050
  var RESOLUTION_ANCHOR = "<h2>Resolution</h2>";
@@ -23382,6 +23494,9 @@ function isoDaysAgo(days) {
23382
23494
  function reviewKey(n) {
23383
23495
  return n.blobId ?? n.dateModified;
23384
23496
  }
23497
+ function isAcknowledged(n) {
23498
+ return ownedLabel(n, "reviewed") === reviewKey(n);
23499
+ }
23385
23500
  var TIMELESS_KINDS = new Set(["thread", "user", "information", "domain"]);
23386
23501
  var DATED_PROSE_LIMIT = 4;
23387
23502
  var MONTHS = "Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|June?|July?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?";
@@ -23420,7 +23535,7 @@ async function sweep(trilium, cfg, opts = {}) {
23420
23535
  const inScope = (n) => !domainSlug || n.attributes.some((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === "domain" && a.value === domainSlug);
23421
23536
  let suppressed = 0;
23422
23537
  const acknowledged = (n) => {
23423
- if (ownedLabel(n, "reviewed") !== reviewKey(n))
23538
+ if (!isAcknowledged(n))
23424
23539
  return false;
23425
23540
  suppressed++;
23426
23541
  return true;
@@ -24839,6 +24954,94 @@ function applyFindEdit(source, needle, replacement, index) {
24839
24954
  const inline = inlineTolerantSpans(source, needle);
24840
24955
  return inline.length ? spans(inline, "inline-tolerant") : null;
24841
24956
  }
24957
+ var CHANGES_CAP = 25;
24958
+ var boundPreview = (s, max = 1600) => s.length <= max ? s : `${s.slice(0, max)}\u2026 (${s.length - max} more characters)`;
24959
+ function capChanges(changes, cap = CHANGES_CAP) {
24960
+ if (changes.length <= cap)
24961
+ return { changes };
24962
+ return { changes: changes.slice(0, cap), changesTruncated: { shown: cap, total: changes.length } };
24963
+ }
24964
+ function sectionTableRows(html) {
24965
+ const table = /<table[^>]*>[\s\S]*?<\/table>/i.exec(html)?.[0];
24966
+ if (!table)
24967
+ return;
24968
+ const tbody = /<tbody[^>]*>([\s\S]*?)<\/tbody>/i.exec(table)?.[1];
24969
+ const scope = tbody ?? table.replace(/<thead[^>]*>[\s\S]*?<\/thead>/gi, "");
24970
+ return (scope.match(/<tr[^>]*>/gi) ?? []).length;
24971
+ }
24972
+ var RECALL_STRATEGIES = ["label", "title", "text", "fuzzy", "ordered", "regex"];
24973
+ function dominantStrategy(contributions) {
24974
+ let best = null;
24975
+ let bestWeight = 0;
24976
+ for (const s of RECALL_STRATEGIES) {
24977
+ const w = contributions.get(s) ?? 0;
24978
+ if (w > bestWeight) {
24979
+ bestWeight = w;
24980
+ best = s;
24981
+ }
24982
+ }
24983
+ return best;
24984
+ }
24985
+ function passageAround(text, index, length) {
24986
+ const from = Math.max(0, index - 80);
24987
+ const to = Math.min(text.length, index + length + 80);
24988
+ return `${from > 0 ? "\u2026" : ""}${text.slice(from, to).replace(/\s+/g, " ").trim()}${to < text.length ? "\u2026" : ""}`;
24989
+ }
24990
+ function tokenPassage(text, tokens) {
24991
+ const lower = text.toLowerCase();
24992
+ let best = null;
24993
+ for (const t of tokens) {
24994
+ const idx = lower.indexOf(t.toLowerCase());
24995
+ if (idx < 0)
24996
+ continue;
24997
+ if (!best || t.length > best.length)
24998
+ best = { index: idx, length: t.length };
24999
+ }
25000
+ return best ? passageAround(text, best.index, best.length) : null;
25001
+ }
25002
+ function withinModifiedWindow(modified, since, until) {
25003
+ const day = modified.slice(0, 10);
25004
+ const dayOk = /^\d{4}-\d{2}-\d{2}$/.test(day);
25005
+ const isDay = (v) => /^\d{4}-\d{2}-\d{2}$/.test(v);
25006
+ const ms = Date.parse(modified.replace(" ", "T"));
25007
+ if (since) {
25008
+ if (isDay(since)) {
25009
+ if (dayOk && day < since)
25010
+ return false;
25011
+ } else {
25012
+ const s = Date.parse(since);
25013
+ if (Number.isFinite(s) && Number.isFinite(ms) && ms < s)
25014
+ return false;
25015
+ }
25016
+ }
25017
+ if (until) {
25018
+ if (isDay(until)) {
25019
+ if (dayOk && day > until)
25020
+ return false;
25021
+ } else {
25022
+ const u = Date.parse(until);
25023
+ if (Number.isFinite(u) && Number.isFinite(ms) && ms > u)
25024
+ return false;
25025
+ }
25026
+ }
25027
+ return true;
25028
+ }
25029
+ function capWarning(saturated) {
25030
+ const names = [...new Set(saturated)].sort();
25031
+ if (!names.length)
25032
+ return null;
25033
+ return `The backend returned its 30-candidate cap on the ${names.join(", ")} ${names.length === 1 ? "strategy" : "strategies"}, so further matches may exist \u2014 narrow with domain= or kinds=, or use consistency() for an exhaustive scan.`;
25034
+ }
25035
+ var SEARCH_FAILURE_NOTE = "One or more backend search queries failed \u2014 this is a backend failure (Trilium unreachable, or its search index busy), NOT a verdict that nothing matched. Retry the call, and do not conclude from this receipt that the content is unstored.";
25036
+ async function markWithRetry(mark) {
25037
+ for (let attempt = 1;attempt <= 2; attempt++) {
25038
+ try {
25039
+ await mark();
25040
+ return true;
25041
+ } catch {}
25042
+ }
25043
+ return false;
25044
+ }
24842
25045
  var err = (code, detail, hint) => txt3({ error: code, detail, ...hint ? { hint } : {} });
24843
25046
  var labelOf2 = (n, name) => n.attributes.find((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === name)?.value;
24844
25047
  var hasLabel = (n, name) => n.attributes.some((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === name);
@@ -24875,6 +25078,7 @@ function registerTools(server, trilium, brainRef) {
24875
25078
  const REQUIRED_PRECLOSE_STEPS = ["session", "addendum", "maintain", "remarks", "diary"];
24876
25079
  const LIGHT_PRECLOSE_STEPS = ["session", "remarks", "diary"];
24877
25080
  let wroteSinceClose = false;
25081
+ let writeMarkPending = null;
24878
25082
  const gateNote = async (date) => {
24879
25083
  const cfg = b();
24880
25084
  if (!cfg.memory.sessions)
@@ -24919,6 +25123,16 @@ function registerTools(server, trilium, brainRef) {
24919
25123
  preCloseSteps.set(step, next);
24920
25124
  preCloseSeq = next;
24921
25125
  };
25126
+ const markWriteStep = async () => {
25127
+ const ok = await markWithRetry(() => markStep("write"));
25128
+ writeMarkPending = ok ? null : 'the "write" gate mark could not be recorded durably (Trilium label write failed twice)';
25129
+ return ok;
25130
+ };
25131
+ const stampSessionScope = async (date, scope) => {
25132
+ const noteId = await ensureGateNote(date);
25133
+ if (noteId)
25134
+ await trilium.updateLabelValue(noteId, "scope", scope).catch(() => null);
25135
+ };
24922
25136
  const readGate = async (date) => {
24923
25137
  const noteId = await gateNote(date);
24924
25138
  if (!noteId)
@@ -25000,6 +25214,7 @@ function registerTools(server, trilium, brainRef) {
25000
25214
  async function appendThreadEntry(threadId, block, d) {
25001
25215
  const found = await trilium.searchNotes(`#noteType=threadEntry #created='${d}'`, { ancestorNoteId: threadId, fastSearch: true, limit: 1 });
25002
25216
  const time = localNowTime();
25217
+ const marker = `Addendum \u2014 ${time}`;
25003
25218
  if (found.results[0]) {
25004
25219
  const noteId = found.results[0].noteId;
25005
25220
  const current = fixRecordHeader(await trilium.getNoteContent(noteId), "threadEntry", d).html;
@@ -25009,17 +25224,17 @@ function registerTools(server, trilium, brainRef) {
25009
25224
  if (incoming && blocks.some((b) => norm(b) === incoming))
25010
25225
  return { noteId, action: "already_written" };
25011
25226
  await trilium.createRevision(noteId);
25012
- await trilium.updateNoteContent(noteId, safeAppend(current, `<h2>Addendum \u2014 ${time}</h2>`, block));
25227
+ await trilium.updateNoteContent(noteId, safeAppend(current, `<h2>${marker}</h2>`, block));
25013
25228
  await trilium.updateLabelValue(noteId, "updated", d);
25014
- return { noteId, action: "appended" };
25229
+ return { noteId, action: "appended", marker };
25015
25230
  }
25016
- const created = await trilium.createNote(threadId, `[${d}]`, contentFor("threadEntry", { date: d, body: `<h2>Addendum \u2014 ${time}</h2>
25231
+ const created = await trilium.createNote(threadId, `[${d}]`, contentFor("threadEntry", { date: d, body: `<h2>${marker}</h2>
25017
25232
  ${block}` }));
25018
25233
  const noteId = created.note.noteId;
25019
25234
  await trilium.addLabel(noteId, "noteType", "threadEntry");
25020
25235
  await trilium.addLabel(noteId, "created", d);
25021
25236
  await ensureIcon(trilium, noteId);
25022
- return { noteId, action: "created" };
25237
+ return { noteId, action: "created", marker };
25023
25238
  }
25024
25239
  function collectionAppendRefusal(book) {
25025
25240
  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.`);
@@ -25074,7 +25289,8 @@ ${block}` }));
25074
25289
  sessionNoteId = digest.todaySession.id;
25075
25290
  const content = await trilium.getNoteContent(sessionNoteId).catch(() => "");
25076
25291
  sessionPreview = toText(content, 200);
25077
- newDay = !/<h2(?:\s[^>]*)?>\s*Addendum/i.test(content);
25292
+ const note = await trilium.getNote(sessionNoteId).catch(() => null);
25293
+ newDay = note ? !hasLabel(note, "interactive") : !/<h2(?:\s[^>]*)?>\s*Addendum/i.test(content);
25078
25294
  } else {
25079
25295
  sessionNoteId = await ensureGateNote(todayStr);
25080
25296
  newDay = true;
@@ -25082,6 +25298,7 @@ ${block}` }));
25082
25298
  } catch {}
25083
25299
  }
25084
25300
  let changesSinceLastSession = [];
25301
+ let changesTruncated;
25085
25302
  if (digest.lastSession && cfg.root) {
25086
25303
  const sinceDate = digest.lastSession.date;
25087
25304
  try {
@@ -25093,12 +25310,14 @@ ${block}` }));
25093
25310
  deduped.set(h.noteId, h);
25094
25311
  }
25095
25312
  }
25096
- changesSinceLastSession = [...deduped.values()].slice(0, 25).map((h) => ({
25313
+ const capped = capChanges([...deduped.values()].map((h) => ({
25097
25314
  id: h.noteId,
25098
25315
  title: h.current_title,
25099
25316
  changed: h.date.slice(0, 10),
25100
25317
  ...h.current_isDeleted ? { deleted: true } : {}
25101
- }));
25318
+ })));
25319
+ changesSinceLastSession = capped.changes;
25320
+ changesTruncated = capped.changesTruncated;
25102
25321
  } catch {}
25103
25322
  }
25104
25323
  return txt3({
@@ -25112,6 +25331,7 @@ ${block}` }));
25112
25331
  dormantThreads: digest.reviewQueue.length ? { note: "These threads went dormant from inactivity. Mention them if relevant; revise() or resolve() to act.", items: digest.reviewQueue } : [],
25113
25332
  lastSession: digest.lastSession ?? null,
25114
25333
  changesSinceLastSession: changesSinceLastSession.length ? changesSinceLastSession : undefined,
25334
+ ...changesTruncated ? { changesTruncated } : {},
25115
25335
  ...newDay ? { newDay: true, newDayHint: "First session of the day \u2014 call day() for the sweep payload (previous session + log + changes in one call)." } : {},
25116
25336
  ...(depth ?? "digest") === "digest" ? { depthHint: 'Preferences and protocols came back IN FULL \u2014 the two needed whole to orient from the first message. Biography, goals and responsibilities are section headings + preview; pull one with master(which) / llm(which), or a single section with their section= parameter. start(depth="full") serves all five inline.' } : {},
25117
25337
  hygiene: { scanned: hygiene.scanned, fixed: hygiene.fixed.length, transitions: hygiene.transitions, flagged: hygiene.flagged, ...hygiene.suppressed ? { suppressed: hygiene.suppressed } : {} }
@@ -25119,22 +25339,42 @@ ${block}` }));
25119
25339
  });
25120
25340
  server.tool("session", `Pre-close review. Returns the six singletons as {id, lastModified} stubs (full=true inlines them), today's diary as {id, blocks, size}, the maintained notes changed today with their write counts, runs the lite maintenance sweep, and returns pending= (what each remaining close step has to do), audit= (do the singletons agree, and do the LLM singletons still serve the master ones) and next[]. Records the session step of the close gate. Idempotent.
25121
25341
 
25122
- The close sequence next[] lays out: singleton updates from the session, then addendum(), maintain(), remarks(), diary() and close(). close() checks that addendum, maintain, remarks and diary ran, in the order session \u2192 remarks \u2192 diary. scope="agent" omits the singleton updates for scoped or autonomous runs. A session that wrote no content gets a light close: session() returns only the short next[] (remarks, a one-paragraph diary, close) and close() does not require addendum() or maintain().`, {
25342
+ The close sequence next[] lays out: singleton updates from the session, then addendum(), maintain(), remarks(), diary() and close(). close() checks that addendum, maintain, remarks and diary ran, in the order session \u2192 remarks \u2192 diary. scope="agent" omits the singleton updates for scoped or autonomous runs. A session that wrote no content gets a light close: session() returns only the short next[] (remarks, a one-paragraph diary, close) and close() does not require addendum() or maintain(). continuing=true is a second session on a day already closed \u2014 verified against the day's addendum, it returns the short continuation receipt (no singleton review or audit) while still recording the session step.`, {
25123
25343
  date: stringType().optional().describe("ISO date YYYY-MM-DD (default: today)"),
25124
25344
  full: booleanType().optional().describe("Inline every singleton's content and the diary body (default: stubs only)"),
25125
25345
  scope: enumType(["interactive", "agent"]).optional().describe(`"agent" for a scoped/autonomous run whose brief excludes the user's personal singletons \u2014 omits those steps from next[] (default: "interactive")`),
25346
+ continuing: booleanType().optional().describe("A second session on a day already closed \u2014 verified against today's addendum, returns the short continuation receipt (no singleton review). Refused on the day's first session."),
25126
25347
  light: booleanType().optional().describe("Deprecated \u2014 light is now the default; accepted for compatibility and ignored")
25127
- }, async ({ date, full, scope }) => {
25348
+ }, async ({ date, full, scope, continuing }) => {
25128
25349
  const d = checkedDate(date);
25129
25350
  const cfg = b();
25130
25351
  if (!cfg.master.root || !cfg.llm.root)
25131
25352
  throw new Error("BrainLLM not bootstrapped \u2014 run bootstrap.");
25132
- if (!(await readGate(d)).has("write")) {
25353
+ let continued = false;
25354
+ if (continuing) {
25355
+ const prior = await trilium.searchNotes(`#noteType=session #created='${d}'`, { ancestorNoteId: cfg.memory.sessions, fastSearch: true, limit: 1 });
25356
+ const priorContent = prior.results[0] ? await trilium.getNoteContent(prior.results[0].noteId).catch(() => "") : "";
25357
+ continued = /<h2(?:\s[^>]*)?>\s*Addendum/i.test(priorContent);
25358
+ if (!continued) {
25359
+ return err("not_a_continuation", `continuing=true needs a session note for ${d} that already carries an addendum \u2014 there is none, so this is the day's first session.`, "Run the full pre-close protocol: session() \u2192 addendum() \u2192 maintain() \u2192 remarks() \u2192 diary() \u2192 close().");
25360
+ }
25361
+ }
25362
+ const writeGate = await readGate(d);
25363
+ let gateNote;
25364
+ if (wroteSinceClose && !writeGate.has("write")) {
25365
+ if (await markWriteStep())
25366
+ writeGate.set("write", 1);
25367
+ else
25368
+ gateNote = writeMarkPending ?? "the durable write gate could not be repaired";
25369
+ }
25370
+ if (!(writeGate.has("write") || wroteSinceClose)) {
25133
25371
  await markStep("session", d);
25372
+ await stampSessionScope(d, scope ?? "interactive");
25134
25373
  return txt3({
25135
25374
  date: d,
25375
+ ...continued ? { continuing: true } : {},
25136
25376
  closeMode: "light",
25137
- note: "No content was written this session, so addendum() and maintain() are not required and there is nothing to audit. If you learned something durable about the user or yourself, revise that singleton first \u2014 that makes it a full close, and session() will say so when re-run.",
25377
+ note: "Light close \u2014 strictly because no content was written this session: addendum() and maintain() are not required and there is nothing to audit. If you learned something durable about the user or yourself, revise that singleton first \u2014 that makes it a full close, and session() will say so when re-run.",
25138
25378
  next: [
25139
25379
  "Call remarks() \u2014 one short cue for a short session.",
25140
25380
  "Call diary() \u2014 one short paragraph: what the session was and anything worth the team knowing.",
@@ -25142,6 +25382,49 @@ The close sequence next[] lays out: singleton updates from the session, then add
25142
25382
  ]
25143
25383
  });
25144
25384
  }
25385
+ const diaryEntryOfToday = async () => {
25386
+ if (!cfg.llm.diary)
25387
+ return null;
25388
+ const diarySearch = await trilium.searchNotes(`#noteType=diary #created='${d}'`, { ancestorNoteId: cfg.llm.diary, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
25389
+ if (!diarySearch.results[0])
25390
+ return null;
25391
+ const id = diarySearch.results[0].noteId;
25392
+ const content = await trilium.getNoteContent(id).catch(() => "");
25393
+ return full ? { id, content } : { id, blocks: (content.match(/<h2(?:\s[^>]*)?>\s*Addendum/gi) ?? []).length, size: content.length };
25394
+ };
25395
+ const pendingAddendumCount = async () => {
25396
+ try {
25397
+ const r = await trilium.searchNotes("#noteType note.content *=* 'Addendum'", { ancestorNoteId: cfg.root, fastSearch: false, limit: 40 });
25398
+ const candidates = r.results.filter((n) => {
25399
+ const kind = labelOf2(n, "noteType");
25400
+ return kind && !["session", "diary", "log", "threadEntry", "thread"].includes(kind);
25401
+ });
25402
+ const contents = await Promise.all(candidates.map((n) => trilium.getNoteContent(n.noteId).catch(() => "")));
25403
+ return contents.filter(hasAddendumMarker).length;
25404
+ } catch {
25405
+ return null;
25406
+ }
25407
+ };
25408
+ if (continued) {
25409
+ const hygiene = await sweep(trilium, cfg, { deep: false, dryRun: false }).catch(() => null);
25410
+ const pendingAddendums = await pendingAddendumCount();
25411
+ await markStep("session", d);
25412
+ await stampSessionScope(d, scope ?? "interactive");
25413
+ return txt3({
25414
+ date: d,
25415
+ continuing: true,
25416
+ note: "Continuing session \u2014 the singleton review and audit already ran with the day's first close, so this receipt carries only what the next close needs. If this stretch changed a singleton, revise() it before close() all the same.",
25417
+ diary: await diaryEntryOfToday(),
25418
+ maintenance: hygiene ? { scanned: hygiene.scanned, fixed: hygiene.fixed.length, transitions: hygiene.transitions, flagged: hygiene.flagged, ...hygiene.suppressed ? { suppressed: hygiene.suppressed } : {} } : "skipped",
25419
+ pending: { addendums: pendingAddendums === null ? "unknown" : pendingAddendums },
25420
+ next: [
25421
+ pendingAddendums === 0 ? "Call addendum() \u2014 0 notes currently carry addendum markers, so this should come back clean; the call still satisfies the gate." : pendingAddendums === null ? "Call addendum() \u2014 fold what this stretch's work left behind." : `Call addendum() \u2014 ${pendingAddendums} note(s) carry addendum markers; fold each into its section.`,
25422
+ "Call maintain() \u2014 the same hygiene pass as any session.",
25423
+ "Call remarks(), then diary() \u2014 the cues, then this stretch's record.",
25424
+ "Call close(continuing=true) \u2014 commit this stretch's log; the day's first close already carried the full sequence."
25425
+ ]
25426
+ });
25427
+ }
25145
25428
  const fetchSingleton = async (id) => {
25146
25429
  if (!full) {
25147
25430
  const note = await trilium.getNote(id);
@@ -25163,24 +25446,9 @@ The close sequence next[] lays out: singleton updates from the session, then add
25163
25446
  fetchSingleton(cfg.llm.protocols),
25164
25447
  cfg.llm.selfcorrection ? fetchSingleton(cfg.llm.selfcorrection) : Promise.resolve(null)
25165
25448
  ]);
25166
- let diaryEntry = null;
25167
- if (cfg.llm.diary) {
25168
- const diarySearch = await trilium.searchNotes(`#noteType=diary #created='${d}'`, { ancestorNoteId: cfg.llm.diary, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
25169
- if (diarySearch.results[0]) {
25170
- const id = diarySearch.results[0].noteId;
25171
- const content = await trilium.getNoteContent(id).catch(() => "");
25172
- diaryEntry = full ? { id, content } : { id, blocks: (content.match(/<h2(?:\s[^>]*)?>\s*Addendum/gi) ?? []).length, size: content.length };
25173
- }
25174
- }
25449
+ const diaryEntry = await diaryEntryOfToday();
25175
25450
  const hygiene = await sweep(trilium, cfg, { deep: false, dryRun: false }).catch(() => null);
25176
- const pendingAddendums = await trilium.searchNotes("#noteType note.content *=* 'Addendum'", { ancestorNoteId: cfg.root, fastSearch: false, limit: 40 }).then(async (r) => {
25177
- const candidates = r.results.filter((n) => {
25178
- const kind = labelOf2(n, "noteType");
25179
- return kind && !["session", "diary", "log", "threadEntry", "thread"].includes(kind);
25180
- });
25181
- const contents = await Promise.all(candidates.map((n) => trilium.getNoteContent(n.noteId).catch(() => "")));
25182
- return contents.filter(hasAddendumMarker).length;
25183
- }).catch(() => null);
25451
+ const pendingAddendums = await pendingAddendumCount();
25184
25452
  const singletonStubs = {
25185
25453
  biography,
25186
25454
  goals,
@@ -25203,11 +25471,13 @@ The close sequence next[] lays out: singleton updates from the session, then add
25203
25471
  singletonsWrittenToday: touchedToday.length ? touchedToday : "none"
25204
25472
  };
25205
25473
  await markStep("session", d);
25474
+ await stampSessionScope(d, scope ?? "interactive");
25206
25475
  const scoped = scope === "agent";
25207
25476
  return txt3({
25208
25477
  date: d,
25209
25478
  ...scoped ? { scope: "agent" } : {},
25210
- ...!full ? { mode: "light", note: "Singleton content and the diary body are omitted (default) \u2014 fetch via master()/llm() only where lastModified indicates a revision is needed; start() already served all singletons in full, and diary() needs only the id." } : {},
25479
+ ...!full ? { singletons: "stubs", note: "Singleton content and the diary body are omitted (default) \u2014 fetch via master()/llm() only where lastModified indicates a revision is needed; start() already served all singletons in full, and diary() needs only the id. This is stub mode, not a light close: 'light' means only that no content was written this session." } : {},
25480
+ ...gateNote ? { gateNote } : {},
25211
25481
  master: { biography, goals, preferences },
25212
25482
  llm: { responsibilities, protocols, ...selfcorrection ? { selfcorrection } : {} },
25213
25483
  diary: diaryEntry,
@@ -25246,6 +25516,7 @@ The close sequence next[] lays out: singleton updates from the session, then add
25246
25516
  if (!(await readGate(localToday())).has("write")) {
25247
25517
  return txt3({
25248
25518
  closeMode: "light",
25519
+ note: "Light close \u2014 strictly because no content was written this session: addendum() and maintain() are not required and nothing is audited. The full cues return the moment any content is written.",
25249
25520
  cue: "One short paragraph, first person: what this session was, how it went, and anything about BrainLLM worth the team knowing. Skip what has nothing real to say.",
25250
25521
  next: ["Write it via diary(body, identity), then call close()."]
25251
25522
  });
@@ -25293,7 +25564,9 @@ The close sequence next[] lays out: singleton updates from the session, then add
25293
25564
  }
25294
25565
  }
25295
25566
  const gate = await readGate(gateDate);
25296
- const light = !gate.has("write");
25567
+ if (wroteSinceClose && !gate.has("write") && await markWriteStep())
25568
+ gate.set("write", 1);
25569
+ const light = !(gate.has("write") || wroteSinceClose);
25297
25570
  const required = light ? LIGHT_PRECLOSE_STEPS : REQUIRED_PRECLOSE_STEPS;
25298
25571
  const missing = continued ? [] : required.filter((step) => !gate.has(step));
25299
25572
  if (missing.length && !force) {
@@ -25382,6 +25655,10 @@ ${contentBlock}` }));
25382
25655
  }
25383
25656
  }
25384
25657
  await clearGate(d);
25658
+ const closeScope = await trilium.getNote(noteId).then((n) => labelOf2(n, "scope")).catch(() => null);
25659
+ if (closeScope === "interactive") {
25660
+ await trilium.updateLabelValue(noteId, "interactive", localNowTime()).catch(() => null);
25661
+ }
25385
25662
  return txt3({
25386
25663
  action,
25387
25664
  noteId,
@@ -25394,6 +25671,7 @@ ${contentBlock}` }));
25394
25671
  ...continued ? { continuing: true } : {},
25395
25672
  ...sealReport ? { seal: `${sealReport.records} record(s) of ${d} sealed (${sealReport.digest.slice(0, 12)}\u2026), chained to the previous sealed day` } : { seal: "not written \u2014 the day's log note was unavailable" },
25396
25673
  ...light && !continued ? { closeMode: "light \u2014 no content was written this session, so addendum() and maintain() were not required" } : {},
25674
+ ...writeMarkPending ? { gateNote: writeMarkPending } : {},
25397
25675
  ...missing.length || !orderOk ? { bypassed: [...missing, ...!orderOk ? ["ordering(session\u2192remarks\u2192diary)"] : []] } : {},
25398
25676
  ...warnings.length ? { sanitized: warnings } : {}
25399
25677
  });
@@ -25527,7 +25805,7 @@ ${html}` }));
25527
25805
  });
25528
25806
  server.tool("remember", `Store something the moment it matters; the server owns placement, labels and dedup.
25529
25807
 
25530
- 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).
25808
+ 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).
25531
25809
 
25532
25810
  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.`, {
25533
25811
  kind: enumType(Kinds).describe("What kind of memory this is"),
@@ -25548,17 +25826,20 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25548
25826
  mandate: booleanType().optional().describe("kind=information: flag the note as a standing brief a session must follow (#mandate)"),
25549
25827
  mustCreate: booleanType().optional().describe("Refuse instead of adopting a note that already has this title"),
25550
25828
  strict: booleanType().optional().describe("Refuse the write when the body needs structural repair (unclosed tags, <br> runs) instead of accepting the repaired form"),
25829
+ dryRun: booleanType().optional().describe('kind="sources": preview which Revision rows would be added or updated \u2014 and which body groups would merge \u2014 without writing anything. The way to check a new source key before it lands; default false (the call writes), so re-run without dryRun to apply'),
25551
25830
  connect: arrayType(objectType({
25552
25831
  relation: enumType(RelationTypes),
25553
25832
  toNoteId: stringType()
25554
25833
  })).optional().describe("Relations to wire from this note in the same call \u2014 same semantics as connect() (idempotent, worksWith wired both ways)"),
25555
25834
  icon: stringType().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name ("brain"); normalized server-side'),
25556
25835
  date: stringType().optional().describe("ISO date override (default: today)")
25557
- }, async ({ kind, title, body, goal, shape, thread, identity, domain, revision, topics, supersedes, mandate, mustCreate, strict, connect: connectRels, icon, date }) => {
25836
+ }, async ({ kind, title, body, goal, shape, thread, identity, domain, revision, topics, supersedes, mandate, mustCreate, strict, dryRun, connect: connectRels, icon, date }) => {
25558
25837
  const refuseAdoption = (existingId, existingTitle, where) => err("already_exists", `A ${kind} note titled "${existingTitle}" already exists ${where} [${existingId}] \u2014 mustCreate=true refuses to adopt it.`, `Read it first with ${kind === "thread" ? "memory" : "knowledge"}(${existingId}). To add to it deliberately, re-run without mustCreate, or use revise(${existingId}, \u2026). To keep both, pick a title that distinguishes them.`);
25559
25838
  const opts = { domain, topics, date, ...mandate ? { mandate: true } : {} };
25560
25839
  const d = checkedDate(date);
25561
25840
  const { html, warnings: sanitizeWarnings } = renderBody(body ?? "");
25841
+ if (dryRun !== undefined && kind !== "sources")
25842
+ return err("conflicting_params", 'dryRun= applies only to remember(kind="sources"); nothing was written.', "For other kinds, read first (or use maintain(dryRun=true) for hygiene) and then write without dryRun.");
25562
25843
  if (strict) {
25563
25844
  const repairs = repairedStructure(sanitizeWarnings);
25564
25845
  if (repairs.length) {
@@ -25601,6 +25882,20 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25601
25882
  return wiredEdges;
25602
25883
  };
25603
25884
  const ORPHAN_HINT = "Unconnected \u2014 wire a real relation now with connect() (or pass connect=[{relation, toNoteId}] on remember) so this note doesn't surface as an orphan in maintain(deep=true).";
25885
+ const sourceKeyHints = (added, existing) => {
25886
+ const pairs = [];
25887
+ for (const a of added) {
25888
+ const near = nearestKey(a, existing);
25889
+ if (near)
25890
+ pairs.push({ added: a, near });
25891
+ }
25892
+ if (!pairs.length)
25893
+ return null;
25894
+ return {
25895
+ didYouMean: pairs[0].near.key,
25896
+ 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.`
25897
+ };
25898
+ };
25604
25899
  if (isSingleton(kind)) {
25605
25900
  const id = kindHome(b(), kind);
25606
25901
  if (!id)
@@ -25614,6 +25909,48 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25614
25909
  if (kind === "sources") {
25615
25910
  if (!domain)
25616
25911
  return err("missing_param", 'kind="sources" requires a domain.', 'Call remember(kind="sources", domain="<domain name>", body="...")');
25912
+ if (dryRun) {
25913
+ const slug = slugify(domain) || "general";
25914
+ const domRes = await trilium.searchNotes(`#noteType=domain #domain='${escapeQueryValue(slug)}'`, { ancestorNoteId: b().knowledge.domains, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
25915
+ const domainNote = domRes.results[0];
25916
+ const srcRes = domainNote ? await trilium.searchNotes("#noteType=sources", { ancestorNoteId: domainNote.noteId, fastSearch: true, limit: 1 }).catch(() => ({ results: [] })) : { results: [] };
25917
+ const display = domainNote?.title ?? domain.replace(/\s+/g, " ").trim();
25918
+ const preview = { ok: true, action: "dry_run", dryRun: true, kind, date: d, domain: display };
25919
+ const sidPreview = srcRes.results[0]?.noteId;
25920
+ if (!domainNote)
25921
+ preview.wouldCreate = `domain "${display}" and its canonical Sources note`;
25922
+ else if (!sidPreview)
25923
+ preview.wouldCreate = `${domainNote.title} \u2192 Sources note`;
25924
+ else {
25925
+ const content = await trilium.getNoteContent(sidPreview);
25926
+ const existingKeys = tableRows(content, "Revision").map((cells) => cells[0]).filter(Boolean);
25927
+ const previewChanges = [];
25928
+ const previewAdded = [];
25929
+ let sim = content;
25930
+ for (const row of revision ?? []) {
25931
+ const result = upsertTableRow(sim, "Revision", row.source, [row.marker, row.date ?? d]);
25932
+ if (result.matched || result.created) {
25933
+ sim = result.html;
25934
+ previewChanges.push(`${result.matched ? "would update" : "would add"}: ${row.source}`);
25935
+ if (!result.matched)
25936
+ previewAdded.push(row.source);
25937
+ }
25938
+ }
25939
+ if (previewChanges.length)
25940
+ preview.revision = previewChanges;
25941
+ if (html && toText(html, 50) && !content.includes(html)) {
25942
+ const merged = mergeUnderSection(content, html, "Sources");
25943
+ preview.body = `would merge into the Sources section${merged.mergedInto.length ? `: ${merged.mergedInto.join(", ")}` : ""}${merged.appended.length ? `; new group(s): ${merged.appended.join(", ")}` : ""}`;
25944
+ }
25945
+ const hints = sourceKeyHints(previewAdded, existingKeys);
25946
+ if (hints) {
25947
+ preview.didYouMean = hints.didYouMean;
25948
+ preview.didYouMeanNote = hints.didYouMeanNote;
25949
+ }
25950
+ }
25951
+ preview.next = "Nothing was written. Re-run without dryRun (or with dryRun=false) to apply exactly this.";
25952
+ return txt3(preview);
25953
+ }
25617
25954
  const { domainId, domainTitle, createdDomain, sourcesId } = await resolveDomain(trilium, b(), domain);
25618
25955
  const found = sourcesId ? { results: [] } : await trilium.searchNotes("#noteType=sources", { ancestorNoteId: domainId, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
25619
25956
  let sid = sourcesId ?? found.results[0]?.noteId;
@@ -25639,8 +25976,11 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25639
25976
  }
25640
25977
  }
25641
25978
  const revisionChanges = [];
25979
+ const addedKeys = [];
25980
+ let existingKeys = [];
25642
25981
  if (sid && revision?.length) {
25643
25982
  let current = await trilium.getNoteContent(sid);
25983
+ existingKeys = tableRows(current, "Revision").map((cells) => cells[0]).filter(Boolean);
25644
25984
  let changed = false;
25645
25985
  for (const row of revision) {
25646
25986
  const result = upsertTableRow(current, "Revision", row.source, [row.marker, row.date ?? d]);
@@ -25648,6 +25988,8 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25648
25988
  current = result.html;
25649
25989
  changed = true;
25650
25990
  revisionChanges.push(`${result.matched ? "updated" : "added"}: ${row.source}`);
25991
+ if (!result.matched)
25992
+ addedKeys.push(row.source);
25651
25993
  }
25652
25994
  }
25653
25995
  if (changed) {
@@ -25667,6 +26009,7 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25667
26009
  const placeholderLeft = hasPlaceholderRow(finalHtml, "Revision");
25668
26010
  const bodyWritten = !!html && !!toText(html, 50);
25669
26011
  const looseProse = bodyWritten && !createdDomain && !/<(?:ul|ol|li|h[34]|table)\b/i.test(html);
26012
+ const keyHints = sourceKeyHints(addedKeys, existingKeys);
25670
26013
  return txt3({
25671
26014
  action: wrote ? "maintained" : "already_written",
25672
26015
  noteId: sid,
@@ -25678,6 +26021,7 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25678
26021
  ...sourceMerge,
25679
26022
  ...revisionKeys.length ? { revisionRows: revisionKeys.length } : {},
25680
26023
  ...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." } : {},
26024
+ ...keyHints ? { didYouMean: keyHints.didYouMean, didYouMeanNote: keyHints.didYouMeanNote } : {},
25681
26025
  ...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." } : {},
25682
26026
  ...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." } : {},
25683
26027
  ...connected.length ? { connected } : {},
@@ -25783,6 +26127,7 @@ ${html}` : html;
25783
26127
  if (kind === "thread") {
25784
26128
  if (isCollectionThread(existing))
25785
26129
  return collectionAppendRefusal(existing);
26130
+ const runHeading = leadingIdentityText(block);
25786
26131
  const entry = await appendThreadEntry(existing.noteId, block, d);
25787
26132
  if (entry.action === "already_written") {
25788
26133
  return txt3({ action: "already_written", noteId: existing.noteId, entryId: entry.noteId, kind, title: existing.title });
@@ -25797,7 +26142,7 @@ ${html}` : html;
25797
26142
  const connected = await wireRequested(existing.noteId);
25798
26143
  const iconSet = await applyIcon(existing.noteId, icon);
25799
26144
  const relations = relationSnippet(existing);
25800
- return txt3({ action: "updated", noteId: existing.noteId, entryId: entry.noteId, entryAction: entry.action, 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 } : {} });
26145
+ 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 } : {} });
25801
26146
  }
25802
26147
  const current = await trilium.getNoteContent(existing.noteId);
25803
26148
  if (isDuplicateAppend(current, block))
@@ -25861,7 +26206,7 @@ ${block}`), d);
25861
26206
  ...sanitizeWarnings.length ? { sanitized: sanitizeWarnings } : {}
25862
26207
  });
25863
26208
  });
25864
- server.tool("recall", `Ranked search across the whole brain \u2014 label, title and full-text strategies merged, with kind and status. Pass domain= whenever you know the area. orderBy/orderDirection sort by date; fastSearch= scans titles and labels only; regex= matches note bodies with a real regular expression; a fuzzy pass runs automatically when exact results are thin (hits marked fuzzy: true are leads, not answers). Recently changed notes rank slightly higher at equal relevance. Regex hits carry evidence: the matched passage; records are included unless kinds= narrows. includeArchived= includes archived notes. A thin result is evidence about the query, not the brain.`, {
26209
+ server.tool("recall", `Ranked search across the whole brain \u2014 label, title and full-text strategies merged, with kind and status. Pass domain= whenever you know the area. orderBy/orderDirection sort by date; fastSearch= scans titles and labels only; regex= matches note bodies with a real regular expression; a fuzzy pass runs automatically when exact results are thin (hits marked fuzzy: true are leads, not answers). Recently changed notes rank slightly higher at equal relevance. since=/until= (ISO dates) keep only notes last modified inside the window \u2014 ordered, ranked, regex and fuzzy paths all honour them. Every hit names matchedBy (label, title, text, ordered, fuzzy, regex) and the top hits carry evidence \u2014 the matched passage; a label hit quotes its label, a title hit its title, regex hits carry evidence on every hit. Records are included unless kinds= narrows. includeArchived= includes archived notes. A thin result is evidence about the query, not the brain.`, {
25865
26210
  query: stringType().describe("What to find \u2014 natural phrasing is fine"),
25866
26211
  kinds: arrayType(enumType(Kinds)).optional().describe("Restrict to these kinds"),
25867
26212
  domain: stringType().optional().describe("Restrict to a knowledge domain"),
@@ -25871,8 +26216,10 @@ ${block}`), d);
25871
26216
  orderDirection: enumType(["asc", "desc"]).optional().describe("asc | desc (default: desc for dates, asc for title)"),
25872
26217
  fastSearch: booleanType().optional().describe("Title/label only \u2014 faster, skips full-text body scan"),
25873
26218
  regex: stringType().optional().describe("Regular expression matched against note bodies (Trilium %= operator). Takes precedence over the keyword strategies; escape backslashes."),
25874
- fuzzy: booleanType().optional().describe("Force the fuzzy pass on (default: automatic when exact strategies return few results) or off")
25875
- }, async ({ query, kinds, domain, includeArchived, limit, orderBy, orderDirection, fastSearch, regex, fuzzy }) => {
26219
+ fuzzy: booleanType().optional().describe("Force the fuzzy pass on (default: automatic when exact strategies return few results) or off"),
26220
+ since: stringType().optional().describe("Only notes last modified on or after this ISO date (YYYY-MM-DD covers that whole day; a full ISO timestamp compares exactly)"),
26221
+ until: stringType().optional().describe("Only notes last modified on or before this ISO date (inclusive; a date-only bound covers that whole day)")
26222
+ }, async ({ query, kinds, domain, includeArchived, limit, orderBy, orderDirection, fastSearch, regex, fuzzy, since, until }) => {
25876
26223
  const cfg = b();
25877
26224
  const max = limit ?? 10;
25878
26225
  const fast = fastSearch ?? false;
@@ -25880,13 +26227,26 @@ ${block}`), d);
25880
26227
  const tokens = queryTokens(query);
25881
26228
  const domSlug = domain ? slugify(domain) : null;
25882
26229
  const kindSet = kinds?.length ? new Set(kinds) : null;
25883
- const run = (q, useFast = false, ord) => trilium.searchNotes(q, {
26230
+ for (const [name, value] of [["since", since], ["until", until]]) {
26231
+ if (value !== undefined && Number.isNaN(Date.parse(value)))
26232
+ return err("invalid_param", `${name}="${value}" is not an ISO date.`, `Use YYYY-MM-DD (covers that whole day) or a full ISO timestamp, e.g. ${name}=2026-10-01.`);
26233
+ }
26234
+ let strategyErrors = 0;
26235
+ const saturated = new Set;
26236
+ const run = (q, useFast = false, strategy, ord) => trilium.searchNotes(q, {
25884
26237
  ancestorNoteId: cfg.root,
25885
26238
  limit: 30,
25886
26239
  fastSearch: useFast,
25887
26240
  includeArchivedNotes: includeArchived ?? false,
25888
26241
  ...ord ?? {}
25889
- }).then((r) => r.results).catch(() => []);
26242
+ }).then((r) => {
26243
+ if (strategy && r.results.length >= 30)
26244
+ saturated.add(strategy);
26245
+ return r.results;
26246
+ }).catch(() => {
26247
+ strategyErrors++;
26248
+ return [];
26249
+ });
25890
26250
  const filterNote = (note) => {
25891
26251
  const k = labelOf2(note, "noteType");
25892
26252
  if (!k)
@@ -25895,9 +26255,25 @@ ${block}`), d);
25895
26255
  return false;
25896
26256
  if (domSlug && labelOf2(note, "domain") !== domSlug)
25897
26257
  return false;
26258
+ if (!withinModifiedWindow(note.dateModified, since, until))
26259
+ return false;
25898
26260
  return true;
25899
26261
  };
25900
- const buildResult = async (note, i) => {
26262
+ const evidenceFor = (note, content, matchedBy) => {
26263
+ if (matchedBy === "label") {
26264
+ const parts = [
26265
+ labelOf2(note, "topic") === slug ? `#topic=${slug}` : null,
26266
+ labelOf2(note, "domain") === slug ? `#domain=${slug}` : null
26267
+ ].filter(Boolean);
26268
+ return parts.length ? parts.join(" ") : null;
26269
+ }
26270
+ if (matchedBy === "title" || matchedBy === "fuzzy")
26271
+ return note.title.length > 200 ? `${note.title.slice(0, 199)}\u2026` : note.title;
26272
+ if (matchedBy === "regex")
26273
+ return null;
26274
+ return tokenPassage(toText(content, 1e5), tokens);
26275
+ };
26276
+ const buildResult = async (note, i, matchedBy) => {
25901
26277
  const relations = relationSnippet(note);
25902
26278
  const base = {
25903
26279
  id: note.noteId,
@@ -25906,11 +26282,13 @@ ${block}`), d);
25906
26282
  status: labelOf2(note, "status"),
25907
26283
  updated: note.dateModified.slice(0, 10),
25908
26284
  ...hasLabel(note, "archived") ? { archived: true } : {},
25909
- ...relations ? { relations } : {}
26285
+ ...relations ? { relations } : {},
26286
+ ...matchedBy ? { matchedBy } : {}
25910
26287
  };
25911
26288
  if (i < 3) {
25912
26289
  const content = await trilium.getNoteContent(note.noteId).catch(() => "");
25913
- return { ...base, snippet: toText(content, 280) };
26290
+ const evidence = matchedBy ? evidenceFor(note, content, matchedBy) : null;
26291
+ return { ...base, snippet: toText(content, 280), ...evidence ? { evidence } : {} };
25914
26292
  }
25915
26293
  return base;
25916
26294
  };
@@ -25933,11 +26311,7 @@ ${block}`), d);
25933
26311
  let recordHits = 0;
25934
26312
  const evidenceIn = (text) => {
25935
26313
  const m = new RegExp(re.source, "i").exec(text);
25936
- if (!m)
25937
- return "";
25938
- const from = Math.max(0, m.index - 80);
25939
- const to = Math.min(text.length, m.index + m[0].length + 80);
25940
- return `${from > 0 ? "\u2026" : ""}${text.slice(from, to).replace(/\s+/g, " ").trim()}${to < text.length ? "\u2026" : ""}`;
26314
+ return m ? passageAround(text, m.index, m[0].length) : "";
25941
26315
  };
25942
26316
  for (const n of candidates) {
25943
26317
  if (confirmed.length >= max)
@@ -25962,49 +26336,60 @@ ${block}`), d);
25962
26336
  }
25963
26337
  const results = await Promise.all(confirmed.map(async ({ note, evidence }, i) => {
25964
26338
  const { snippet: _snippet, ...base } = await buildResult(note, i);
25965
- return { ...base, ...evidence ? { evidence } : {} };
26339
+ return { ...base, ...evidence ? { evidence } : {}, matchedBy: "regex" };
25966
26340
  }));
26341
+ const failed = strategyErrors > 0 && results.length === 0;
25967
26342
  const notes = [
25968
26343
  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,
25969
26344
  tagSpanning ? `${tagSpanning} match(es) were found only after stripping markup \u2014 the phrase is split by an inline tag there.` : null,
25970
26345
  widened ? "Diacritic characters (\xB7, accents) were normalised away for Trilium's pre-filter, which strips them from note text before matching; every result was then verified against your exact pattern." : null,
25971
26346
  recordHits ? `${recordHits} result(s) are records (sessions, diary, dated thread entries) \u2014 included by default; kinds= narrows to maintained notes.` : null,
25972
26347
  candidates.length >= 30 && confirmed.length < max ? "The backend returned its 30-candidate cap, so further matches may exist \u2014 narrow with domain= or kinds=, or use consistency() for an exhaustive scan." : null,
25973
- 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,
25974
- 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
26348
+ failed ? SEARCH_FAILURE_NOTE : null,
26349
+ results.length === 0 && !failed ? "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,
26350
+ results.length === 0 && !failed && 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
25975
26351
  ].filter(Boolean);
25976
26352
  return txt3({
25977
26353
  mode: "regex",
25978
26354
  pattern: regex,
25979
26355
  results,
26356
+ ...failed ? { searchFailed: true } : {},
25980
26357
  ...notes.length ? { note: notes.join(" ") } : {}
25981
26358
  });
25982
26359
  }
25983
26360
  if (orderBy) {
25984
26361
  const ord = { orderBy, orderDirection: orderDirection ?? (orderBy === "title" ? "asc" : "desc") };
25985
26362
  const q = query.trim() ? escapeQueryValue(query) : "#noteType";
25986
- const notes = await run(q, fast, ord);
26363
+ const notes = await run(q, fast, "ordered", ord);
25987
26364
  const filtered = notes.filter(filterNote).slice(0, max);
25988
- const results = await Promise.all(filtered.map(buildResult));
25989
- return txt3({ results, ...results.length === 0 ? noMatch : {} });
26365
+ const results = await Promise.all(filtered.map((n, i) => buildResult(n, i, "ordered")));
26366
+ const failed = strategyErrors > 0 && results.length === 0;
26367
+ const notices = [capWarning(saturated), results.length === 0 ? failed ? SEARCH_FAILURE_NOTE : noMatch.note : null].filter(Boolean);
26368
+ return txt3({
26369
+ results,
26370
+ ...failed ? { searchFailed: true } : {},
26371
+ ...notices.length ? { note: notices.join(" ") } : {}
26372
+ });
25990
26373
  }
25991
26374
  const scores = new Map;
25992
- const add = (notes, weight) => {
26375
+ const add = (notes, weight, strategy) => {
25993
26376
  for (const n of notes) {
25994
26377
  const entry = scores.get(n.noteId);
25995
- if (entry)
26378
+ if (entry) {
25996
26379
  entry.score += weight;
25997
- else
25998
- scores.set(n.noteId, { note: n, score: weight });
26380
+ entry.by.set(strategy, (entry.by.get(strategy) ?? 0) + weight);
26381
+ } else {
26382
+ scores.set(n.noteId, { note: n, score: weight, by: new Map([[strategy, weight]]) });
26383
+ }
25999
26384
  }
26000
26385
  };
26001
26386
  const [byLabel, byTitleRaw, byText] = await Promise.all([
26002
- slug.length >= 3 ? run(`#topic='${slug}' OR #domain='${slug}'`, true) : Promise.resolve([]),
26003
- tokens.length ? run(tokens.map((t) => `note.title *=* '${escapeQueryValue(t)}'`).join(" OR "), fast || undefined) : Promise.resolve([]),
26004
- fast ? Promise.resolve([]) : run(escapeQueryValue(query))
26387
+ slug.length >= 3 ? run(`#topic='${slug}' OR #domain='${slug}'`, true, "label") : Promise.resolve([]),
26388
+ tokens.length ? run(tokens.map((t) => `note.title *=* '${escapeQueryValue(t)}'`).join(" OR "), fast || undefined, "title") : Promise.resolve([]),
26389
+ fast ? Promise.resolve([]) : run(escapeQueryValue(query), false, "text")
26005
26390
  ]);
26006
- add(byLabel, 3);
26007
- add(byText, 1);
26391
+ add(byLabel, 3, "label");
26392
+ add(byText, 1, "text");
26008
26393
  const queryKey = titleKey(query);
26009
26394
  for (const n of byTitleRaw) {
26010
26395
  const lowerTitle = n.title.toLowerCase();
@@ -26017,18 +26402,18 @@ ${block}`), d);
26017
26402
  weight += 8;
26018
26403
  else if (queryKey.startsWith(noteKey) || noteKey.startsWith(queryKey))
26019
26404
  weight += 4;
26020
- add([n], weight);
26405
+ add([n], weight, "title");
26021
26406
  }
26022
26407
  const EXACT_ENOUGH = 5;
26023
26408
  const fuzzyTokens = tokens.filter((t) => t.length >= 3);
26024
26409
  const wantFuzzy = fuzzy ?? (scores.size < EXACT_ENOUGH && fuzzyTokens.length > 0);
26025
26410
  const fuzzyIds = new Set;
26026
26411
  if (wantFuzzy && fuzzyTokens.length) {
26027
- const byFuzzy = await run(fuzzyTokens.map((t) => `note.title ~* '${escapeQueryValue(t)}'`).join(" OR "), fast);
26412
+ const byFuzzy = await run(fuzzyTokens.map((t) => `note.title ~* '${escapeQueryValue(t)}'`).join(" OR "), fast, "fuzzy");
26028
26413
  for (const n of byFuzzy)
26029
26414
  if (!scores.has(n.noteId))
26030
26415
  fuzzyIds.add(n.noteId);
26031
- add(byFuzzy, 0.5);
26416
+ add(byFuzzy, 0.5, "fuzzy");
26032
26417
  }
26033
26418
  const now = Date.now();
26034
26419
  const recency = (n) => {
@@ -26036,15 +26421,23 @@ ${block}`), d);
26036
26421
  return Number.isFinite(age) ? 0.9 * Math.pow(0.5, age / 14) : 0;
26037
26422
  };
26038
26423
  const ranked = [...scores.values()].filter(({ note }) => filterNote(note)).map((s) => ({ ...s, rank: s.score + recency(s.note) })).sort((a, b2) => b2.rank - a.rank || (a.note.dateModified < b2.note.dateModified ? 1 : -1)).slice(0, max);
26039
- const results = await Promise.all(ranked.map(async ({ note }, i) => {
26040
- const row = await buildResult(note, i);
26424
+ const results = await Promise.all(ranked.map(async ({ note, by }, i) => {
26425
+ const row = await buildResult(note, i, dominantStrategy(by));
26041
26426
  return fuzzyIds.has(note.noteId) ? { ...row, fuzzy: true } : row;
26042
26427
  }));
26043
26428
  const fuzzyCount = results.filter((r) => ("fuzzy" in r)).length;
26429
+ const failed = strategyErrors > 0 && results.length === 0;
26430
+ const notices = [
26431
+ capWarning(saturated),
26432
+ failed ? SEARCH_FAILURE_NOTE : null,
26433
+ strategyErrors > 0 && results.length > 0 ? `${strategyErrors} of the ranking queries failed at the backend \u2014 these results come from the strategies that responded, so the ranking may be partial.` : null,
26434
+ results.length === 0 && !failed ? noMatch.note : null
26435
+ ].filter(Boolean);
26044
26436
  return txt3({
26045
26437
  results,
26046
26438
  ...fuzzyCount ? { fuzzyMatches: `${fuzzyCount} result(s) came from the fuzzy pass (marked fuzzy: true) \u2014 treat them as leads to verify, not as answers.` } : {},
26047
- ...results.length === 0 ? noMatch : {}
26439
+ ...failed ? { searchFailed: true } : {},
26440
+ ...notices.length ? { note: notices.join(" ") } : {}
26048
26441
  });
26049
26442
  });
26050
26443
  server.tool("domain", `Everything for a domain, topic or project: its knowledge book (if any) plus every note carrying a matching #domain or #topic slug, grouped by kind, each with idle days and a stale flag; outline=true adds every maintained note's headings and size. The reliable retrieval path for an area; use recall() for keyword search.`, {
@@ -26101,7 +26494,7 @@ ${block}`), d);
26101
26494
  groups[kind] = [];
26102
26495
  const relations = relationSnippet(n);
26103
26496
  const idle = idleSince(n.dateModified);
26104
- const stale = !RECORD_ROWS.has(kind) && idle >= staleAfter && !hasLabel(n, "archived");
26497
+ const stale = !RECORD_ROWS.has(kind) && idle >= staleAfter && !hasLabel(n, "archived") && !isAcknowledged(n);
26105
26498
  if (stale)
26106
26499
  staleCount++;
26107
26500
  groups[kind].push({
@@ -26146,21 +26539,49 @@ ${block}`), d);
26146
26539
  });
26147
26540
  });
26148
26541
  const READ_BUDGET = 80000;
26149
- server.tool("read", `Several note bodies in one round trip: ids=[\u2026] (up to 10) \u2192 {notes:[{id, title, kind, content, relations}]}. Bodies are returned in order until about 80k characters; the rest come back under deferred[] with their sizes, to read in a second call. 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; for a huge note prefer a section= read.`, {
26542
+ 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.`, {
26150
26543
  ids: arrayType(stringType()).min(1).max(10).describe("Note ids to read \u2014 one body per id, one round trip (cap 10)"),
26151
- text: booleanType().optional().describe("Return readable plain text instead of stored HTML (default false)")
26152
- }, async ({ ids, text }) => {
26544
+ section: stringType().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[]"),
26545
+ occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)"),
26546
+ text: booleanType().optional().describe("Return readable plain text instead of stored HTML (default false; with section=, the section only)")
26547
+ }, async ({ ids, text, section, occurrence }) => {
26153
26548
  const fetched = await Promise.all(ids.map(async (id) => {
26154
26549
  try {
26155
26550
  const note = await trilium.getNote(id);
26156
26551
  const contentResult = await trilium.getNoteContentResult(id, note.type);
26157
26552
  const relations = relationSnippet(note);
26158
26553
  const raw = typeof contentResult === "string" ? contentResult : contentResult.content;
26554
+ const identity = { id, title: note.title, kind: ownedLabel(note, "noteType") ?? undefined, chars: raw.length };
26555
+ if (section && typeof contentResult !== "string") {
26556
+ return { ...identity, content: "", section, matched: false, hint: "Binary content cannot be sectioned as text; read it without section=." };
26557
+ }
26558
+ if (section) {
26559
+ const found = getSection(raw, section, occurrence ?? 1);
26560
+ if (!found.matched) {
26561
+ return {
26562
+ ...identity,
26563
+ content: "",
26564
+ section,
26565
+ matched: false,
26566
+ available: found.available,
26567
+ hint: `No "${section}" heading at h2/h3/h4. available= lists the note's real heading texts \u2014 re-target from there rather than falling back to a whole-note read.`
26568
+ };
26569
+ }
26570
+ const tableRows = sectionTableRows(found.content);
26571
+ return {
26572
+ ...identity,
26573
+ content: text ? readableText(found.content) : found.content,
26574
+ section,
26575
+ matched: true,
26576
+ ...found.headingCount && found.headingCount > 1 ? { headingCount: found.headingCount, ...occurrence ? {} : { hint: `${found.headingCount} headings share that text \u2014 this is the FIRST. Pass occurrence= (1-${found.headingCount}) to read another.` } } : {},
26577
+ ...tableRows !== undefined ? { tableRows } : {},
26578
+ ...found.subsections?.length ? { subsections: found.subsections } : {},
26579
+ ...relations ? { relations } : {}
26580
+ };
26581
+ }
26159
26582
  const content = text && typeof contentResult === "string" ? readableText(raw) : raw;
26160
26583
  return {
26161
- id,
26162
- title: note.title,
26163
- kind: ownedLabel(note, "noteType") ?? undefined,
26584
+ ...identity,
26164
26585
  content,
26165
26586
  ...typeof contentResult === "string" ? {} : { contentEncoding: contentResult.encoding, mime: contentResult.mime },
26166
26587
  ...relations ? { relations } : {}
@@ -26184,7 +26605,7 @@ ${block}`), d);
26184
26605
  return txt3({
26185
26606
  count: notes.length,
26186
26607
  notes,
26187
- ...deferred.length ? { deferred, hint: `${deferred.length} note(s) held back to keep this result under ~${READ_BUDGET / 1000}k characters. Read them with read(ids=[\u2026]) again${text ? "" : ", or pass text=true, which is smaller"}.` } : {}
26608
+ ...deferred.length ? { deferred, hint: `${deferred.length} note(s) held back to keep this result under ~${READ_BUDGET / 1000}k characters. Read them with read(ids=[\u2026]) again${text ? "" : ", or pass text=true, which is smaller"}; a huge one is cheapest one section at a time with section=.` } : {}
26188
26609
  });
26189
26610
  });
26190
26611
  const reviseDomain = async (name, plan, dry, d) => {
@@ -26259,12 +26680,12 @@ ${block}`), d);
26259
26680
  };
26260
26681
  server.tool("revise", `Edit a note by id. A revision is taken first; "Last updated" lines are bumped on content writes.
26261
26682
 
26262
- 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. 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. 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). closure={thread, body} with within="tr" and mode="remove" closes a register row: the row is removed and the closure appended to that thread's entry for today, in one call. "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.
26683
+ 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. 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 within= 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} moves the removed row into another register note's table, shaped to it instead of into a thread. "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.
26263
26684
 
26264
- 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.`, {
26685
+ 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=).`, {
26265
26686
  noteId: stringType().optional().describe("Note to update (omit only with domain=)"),
26266
26687
  domain: stringType().optional().describe("Instead of noteId: apply find=/edits= to every maintained note in this domain (#domain or #topic slug). Records (sessions, diary, logs, dated thread entries) are never touched. Dry run unless dryRun=false"),
26267
- dryRun: booleanType().optional().describe("domain=: preview the matches without writing (default true). Re-run with dryRun=false to apply"),
26688
+ dryRun: booleanType().optional().describe("domain= or within=: preview what would change, write nothing, take no revision (domain= defaults to true; within= previews only when true). Re-run with dryRun=false to apply"),
26268
26689
  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)."),
26269
26690
  title: stringType().optional().describe("New title (normalized server-side)"),
26270
26691
  section: stringType().optional().describe("Target a section by heading text (h2/h3/h4, in that order); omit for whole-note append/replace"),
@@ -26273,11 +26694,17 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26273
26694
  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)"),
26274
26695
  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."),
26275
26696
  nth: numberType().int().positive().optional().describe("find=: replace only the Nth occurrence, 1-based (default: all of them). With within=: the Nth containing element"),
26276
- 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'),
26697
+ 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. dryRun=true previews it first'),
26277
26698
  closure: objectType({
26278
- thread: stringType().describe('The dated thread to record the closure in: its id or title (e.g. "Escalations")'),
26279
- body: stringType().describe("The closure: what settled it, who ruled, and where its durable substance went")
26280
- }).optional().describe(`With within= and mode="remove": close a register row in one call. The row is removed and body= is appended to the thread's entry for today, quoting the removed row. Needs identity=. Run consistency() afterwards on any fact the row asserted elsewhere`),
26699
+ thread: stringType().optional().describe('Thread variant: the dated thread to record the closure in: its id or title (e.g. "Escalations") \u2014 together with body='),
26700
+ body: stringType().optional().describe("Thread variant: the closure prose: what settled it, who ruled, and where its durable substance went"),
26701
+ note: stringType().optional().describe("Register variant: the register note (id or exact title) whose table receives the closed row \u2014 the row moves there instead of into a thread entry"),
26702
+ table: stringType().optional().describe("Register variant: the heading of the table in that note (default: its first table)")
26703
+ }).optional().describe(`With within= and mode="remove": close a register row in one call. {thread, body} appends the closure to that thread's entry for today, quoting the removed row \u2014 needs identity=. {note} moves the removed row into another register note's table instead, shaped to that table. Run consistency() afterwards on any fact the row asserted elsewhere`),
26704
+ append: objectType({
26705
+ table: stringType().describe("The heading (h2/h3/h4) of the table that receives the row"),
26706
+ row: stringType().describe('One row, cells separated by "|" \u2014 e.g. "Item | Owner | Open"')
26707
+ }).optional().describe('Append one row to the table under append.table without first locating an anchor row \u2014 pipes are cell boundaries, one <td> per cell (plain text, escaped server-side). A lone placeholder row is replaced. e.g. append={table: "Backlog", row: "Ship v13 | Claude | 2026-10-15"}'),
26281
26708
  edits: arrayType(objectType({
26282
26709
  find: stringType().describe("Exact raw string to replace"),
26283
26710
  body: stringType().describe("Raw replacement string"),
@@ -26286,20 +26713,30 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26286
26713
  identity: stringType().optional().describe('Appending to a dated thread: the identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" (required)'),
26287
26714
  icon: stringType().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name; normalized server-side'),
26288
26715
  date: stringType().optional().describe("ISO date (default: today)")
26289
- }, async ({ noteId: noteIdArg, domain, dryRun, body, title, section, occurrence, mode, find, nth, within, closure, edits, identity, icon, date, strict }) => {
26290
- if (closure && !(within && mode === "remove"))
26291
- return err("conflicting_params", 'closure= closes a register row and needs within= with mode="remove"; nothing was written.', 'e.g. revise(noteId, find="<row anchor>", within="tr", mode="remove", closure={thread: "Escalations", body: "Closed on \u2026"}, identity="\u2026")');
26716
+ }, async ({ noteId: noteIdArg, domain, dryRun, body, title, section, occurrence, mode, find, nth, within, closure, append, edits, identity, icon, date, strict }) => {
26717
+ if (closure) {
26718
+ if (!(within && mode === "remove"))
26719
+ return err("conflicting_params", 'closure= closes a register row and needs within= with mode="remove"; nothing was written.', 'e.g. revise(noteId, find="<row anchor>", within="tr", mode="remove", closure={thread: "Escalations", body: "Closed on \u2026"}, identity="\u2026")');
26720
+ if (closure.thread !== undefined === (closure.note !== undefined))
26721
+ return err("conflicting_params", "closure= targets exactly one of a thread or a register note: pass {thread, body} or {note}.", 'e.g. closure={thread: "Escalations", body: "Closed on \u2026"} or closure={note: "Decisions", table: "Decisions"}');
26722
+ if (closure.thread !== undefined && closure.body === undefined)
26723
+ return err("missing_param", "closure={thread}= records the closure as prose and needs body=.", 'Pass body="<what settled it, who ruled, and where its durable substance went>".');
26724
+ if (closure.note !== undefined && closure.body !== undefined)
26725
+ return err("conflicting_params", "closure.body= belongs to the {thread} variant; {note} moves the row itself and appends no prose.", "Drop body=, or target a thread instead.");
26726
+ if (closure.note === undefined && closure.table !== undefined)
26727
+ return err("conflicting_params", "closure.table= names a table inside closure.note= and takes no effect without it.", 'Pass closure={note: "<register>", table: "<heading>"}, or drop table=.');
26728
+ }
26292
26729
  if (domain !== undefined) {
26293
26730
  if (noteIdArg)
26294
26731
  return err("conflicting_params", "Pass noteId= or domain=, not both.", "domain= edits every note in the domain; noteId= edits one.");
26295
26732
  if (find === undefined && edits === undefined)
26296
26733
  return err("missing_param", "domain= needs find= (with body=) or edits=.", 'e.g. revise(domain="myClerkBook", find="Code Priority Queue", body="Backlog")');
26297
- if (within || section || title || mode || identity || icon)
26734
+ if (within || section || title || mode || identity || icon || append)
26298
26735
  return err("conflicting_params", "domain= takes only find=/body=, edits=, nth= and dryRun=.", "Use noteId= for element, section, title or icon edits.");
26299
26736
  return reviseDomain(domain, edits ?? [{ find, body: body ?? "", nth }], dryRun ?? true, checkedDate(date));
26300
26737
  }
26301
- if (dryRun !== undefined)
26302
- return err("conflicting_params", "dryRun= applies only to revise(domain=); nothing was written.", "For one note, read it first (or outline()) and then revise without dryRun. diff(noteId) shows what a write changed.");
26738
+ if (dryRun !== undefined && !within)
26739
+ return err("conflicting_params", "dryRun= applies only to revise(domain=) and revise(within=); nothing was written.", "For one note, read it first (or outline()) and then revise without dryRun. diff(noteId) shows what a write changed.");
26303
26740
  if (!noteIdArg)
26304
26741
  return err("missing_param", "revise needs noteId= (or domain= for a domain-wide find/replace).", "Pass the id of the note to edit.");
26305
26742
  const noteId = noteIdArg;
@@ -26307,6 +26744,12 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26307
26744
  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.");
26308
26745
  if (within && find === undefined)
26309
26746
  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>").');
26747
+ if (append) {
26748
+ if (body !== undefined || find !== undefined || edits !== undefined || section !== undefined || within !== undefined || mode !== undefined || closure !== undefined)
26749
+ return err("conflicting_params", "append= is a one-call row append and combines with none of body=/find=/edits=/section=/within=/mode=/closure=.", 'e.g. revise(noteId, append={table: "Backlog", row: "Ship v13 | Claude | 2026-10-15"}).');
26750
+ if (!append.table.trim() || !append.row.split("|").some((c) => c.trim()))
26751
+ return err("missing_param", 'append needs a table heading and one row of cells separated by "|".', 'e.g. append={table: "Backlog", row: "Ship v13 | Claude | 2026-10-15"}.');
26752
+ }
26310
26753
  const d = checkedDate(date);
26311
26754
  const note = await trilium.getNote(noteId);
26312
26755
  const noteKind = labelOf2(note, "noteType");
@@ -26337,6 +26780,40 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26337
26780
  }
26338
26781
  return { retitled: cleanTitle, cascaded };
26339
26782
  };
26783
+ if (append) {
26784
+ const cells = append.row.split("|").map((c) => c.trim());
26785
+ const current = await trilium.getNoteContent(noteId);
26786
+ const spot = findTable(current, append.table);
26787
+ if (!spot) {
26788
+ const section = getSection(current, append.table);
26789
+ const available = section.available ?? [];
26790
+ const near = !section.matched && available.length ? nearestHeading(append.table, available) : null;
26791
+ const suggestion = near && near.distance <= Math.max(2, Math.floor(append.table.length / 5)) ? near : null;
26792
+ return err("table_not_found", section.matched ? `"${append.table}" holds no table with a <tbody> to append to \u2014 nothing was written.` : `No "${append.table}" heading (h2/h3/h4) with a table under it \u2014 nothing was written.`, section.matched ? `Point append.table at the heading a table already sits under, or write the table's skeleton first (mode="append" with a <table> body).` : `Available headings: ${available.length ? available.join(" \xB7 ") : "(none)"}${suggestion ? ` \u2014 did you mean "${suggestion.heading}"?` : "."} outline(noteId) lists them.`);
26793
+ }
26794
+ await trilium.createRevision(noteId).catch(() => null);
26795
+ const stamped = bumpLastUpdated(appendRowToTable(current, spot, cells.map((c) => escapeHtml(c))), d);
26796
+ await trilium.updateNoteContent(noteId, stamped.html);
26797
+ const titledAppend = await applyTitle();
26798
+ const iconAppend = await applyIcon(noteId, icon);
26799
+ await trilium.updateLabelValue(noteId, "updated", d);
26800
+ if (labelOf2(note, "status") === "dormant")
26801
+ await trilium.updateLabelValue(noteId, "status", "active");
26802
+ const relsAppend = relationSnippet(note);
26803
+ return txt3({
26804
+ ok: true,
26805
+ noteId,
26806
+ mode: `append-row:${append.table}`,
26807
+ date: d,
26808
+ row: cells.join(" | "),
26809
+ cells: cells.length,
26810
+ ...titledAppend.retitled ? { retitled: titledAppend.retitled } : {},
26811
+ ...iconAppend ? { icon: iconAppend } : {},
26812
+ ...relsAppend ? { relations: relsAppend } : {},
26813
+ ...structuralFindings(noteKind, stamped.html),
26814
+ ...stamped.bumped ? { lastUpdated: "bumped to today \u2014 the line is server-owned" } : {}
26815
+ });
26816
+ }
26340
26817
  if (find !== undefined || edits !== undefined) {
26341
26818
  if (find !== undefined && edits !== undefined)
26342
26819
  return err("conflicting_params", "Pass either find= or edits=, not both.", "Fold the single find/body pair into the edits array.");
@@ -26395,31 +26872,89 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26395
26872
  date: d,
26396
26873
  reason: res.reason,
26397
26874
  hint,
26875
+ ...dryRun === true ? { dryRun: true } : {},
26398
26876
  ...res.elements ? { elements: res.elements } : {},
26399
26877
  ...res.reason === "no_match" ? missContext(find, current) : {}
26400
26878
  });
26401
26879
  }
26402
- if (res.html === current)
26880
+ if (res.html === current && dryRun !== true)
26403
26881
  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." });
26404
26882
  let closureThread = null;
26405
26883
  let closureBlock = "";
26884
+ let closureTarget = null;
26885
+ let closureCells = [];
26886
+ let closurePadded = 0;
26406
26887
  if (closure) {
26407
- closureThread = /^[A-Za-z0-9]{12}$/.test(closure.thread) ? await trilium.getNote(closure.thread).catch(() => null) : await findExisting("thread", closure.thread);
26408
- if (!closureThread || ownedLabel(closureThread, "noteType") !== "thread")
26409
- return err("not_found", `closure.thread "${closure.thread}" is not a thread; nothing was written.`, `Pass the thread's id or exact title, e.g. "Escalations".`);
26410
- if (isCollectionThread(closureThread))
26411
- return collectionAppendRefusal(closureThread);
26412
- const rendered = renderBody(closure.body).html;
26413
- const removedText = toText(res.element, 400);
26414
- const closureHtml = `<p><strong>Closed:</strong> ${escapeHtml(removedText)}</p>
26888
+ if (closure.note !== undefined) {
26889
+ const ref = closure.note;
26890
+ let target = /^[A-Za-z0-9]{12}$/.test(ref) ? await trilium.getNote(ref).catch(() => null) : null;
26891
+ if (!target) {
26892
+ const hits = await trilium.searchNotes(`note.title = '${escapeQueryValue(ref)}'`, { fastSearch: true, limit: 10 }).catch(() => ({ results: [] }));
26893
+ const exact = hits.results.filter((n) => titleKey(n.title) === titleKey(ref));
26894
+ if (exact.length > 1)
26895
+ return err("ambiguous", `closure.note "${ref}" matches ${exact.length} notes \u2014 nothing was written.`, `Pass the note's id: ${exact.slice(0, 5).map((n) => `${n.title} [${n.noteId}]`).join(" \xB7 ")}`);
26896
+ target = exact[0] ?? null;
26897
+ }
26898
+ if (!target)
26899
+ return err("not_found", `closure.note "${ref}" is not a note; nothing was written.`, "Pass the register note's id or its exact title.");
26900
+ if (isContainer(b(), target.noteId))
26901
+ return err("protected_note", `closure.note "${target.title}" is a container \u2014 its content cannot be edited directly.`, "Target a register note (a maintained table).");
26902
+ if (isRecordNote(target))
26903
+ return err("record_note", `closure.note "${target.title}" is a record \u2014 records are never rewritten.`, "Use the {thread, body} variant for prose closures, or target the register that should hold the row.");
26904
+ const targetContent = await trilium.getNoteContent(target.noteId);
26905
+ const spot = findTable(targetContent, closure.table);
26906
+ if (!spot)
26907
+ return err("no_table", `closure.note "${target.title}" ${closure.table ? `has no table under "${closure.table}"` : "holds no table with a <tbody> to append to"} \u2014 nothing was written.`, closure.table ? "Pass the heading of the table that should receive the row (outline its headings first)." : 'Pass table="<heading>" to name the table, or give the note a table to receive the row.');
26908
+ const removedCells = rowCells(res.element);
26909
+ const fitted = fitRowCells(removedCells, spot.columns);
26910
+ if (!fitted)
26911
+ return err("row_shape", `The row has ${removedCells.length} cell(s) and "${target.title}"'s table has ${spot.columns} column(s): appending it would drop data, so nothing was written.`, "Reshape the row first (revise its cells), or target a register whose table matches the row.");
26912
+ closureTarget = { note: target, ...closure.table !== undefined ? { heading: closure.table } : {}, content: targetContent, spot };
26913
+ closureCells = fitted;
26914
+ closurePadded = fitted.length - removedCells.length;
26915
+ } else {
26916
+ closureThread = /^[A-Za-z0-9]{12}$/.test(closure.thread) ? await trilium.getNote(closure.thread).catch(() => null) : await findExisting("thread", closure.thread);
26917
+ if (!closureThread || ownedLabel(closureThread, "noteType") !== "thread")
26918
+ return err("not_found", `closure.thread "${closure.thread}" is not a thread; nothing was written.`, `Pass the thread's id or exact title, e.g. "Escalations".`);
26919
+ if (isCollectionThread(closureThread))
26920
+ return collectionAppendRefusal(closureThread);
26921
+ const rendered = renderBody(closure.body ?? "").html;
26922
+ const removedText = toText(res.element, 400);
26923
+ const closureHtml = `<p><strong>Closed:</strong> ${escapeHtml(removedText)}</p>
26415
26924
  ${rendered}`;
26416
- if (!identity)
26417
- return err("missing_identity", "closure= appends to a dated thread, which needs identity=; nothing was written.", 'Pass identity="LLM \xB7 environment \xB7 agent/mode".');
26418
- const badIdentity = identityRefusal(identity, closureHtml);
26419
- if (badIdentity)
26420
- return badIdentity;
26421
- closureBlock = `<h3>${escapeHtml(identity)}</h3>
26925
+ if (!identity)
26926
+ return err("missing_identity", "closure= appends to a dated thread, which needs identity=; nothing was written.", 'Pass identity="LLM \xB7 environment \xB7 agent/mode".');
26927
+ const badIdentity = identityRefusal(identity, closureHtml);
26928
+ if (badIdentity)
26929
+ return badIdentity;
26930
+ closureBlock = `<h3>${escapeHtml(identity)}</h3>
26422
26931
  ${closureHtml}`;
26932
+ }
26933
+ }
26934
+ if (dryRun === true) {
26935
+ const elementText = toText(res.element, 1200);
26936
+ const fullText = toText(res.element, Number.MAX_SAFE_INTEGER);
26937
+ const unchanged = res.html === current;
26938
+ return txt3({
26939
+ ok: true,
26940
+ noteId,
26941
+ mode: `within:${within}:${action}`,
26942
+ dryRun: true,
26943
+ date: d,
26944
+ element: {
26945
+ tag: within,
26946
+ text: elementText,
26947
+ bytes: Buffer.byteLength(res.element, "utf8"),
26948
+ ...elementText.length < fullText.length ? { textTruncated: true } : {}
26949
+ },
26950
+ ...unchanged ? { unchanged: true, note: "body= equals the element it would replace, so a real run would write nothing and take no revision \u2014 this is the element as it stands." } : {
26951
+ ...action === "remove" ? { before: boundPreview(res.element), after: "(removed \u2014 the surrounding content closes up)" } : action === "replace" ? { before: boundPreview(res.element), after: boundPreview(body ?? "") } : { inserts: { position: action, body: boundPreview(body ?? "") } },
26952
+ ...closureThread ? { closure: { threadId: closureThread.noteId, thread: closureThread.title, wouldAppend: toText(closureBlock, 300) } } : {},
26953
+ ...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", ...closurePadded ? { padded: `${closurePadded} empty cell(s) added so the row matches the table's shape` } : {} } } : {}
26954
+ },
26955
+ matchedElements: res.matches,
26956
+ next: "Nothing was written and no revision was taken. Re-run with dryRun=false to apply exactly this change."
26957
+ });
26423
26958
  }
26424
26959
  await trilium.createRevision(noteId).catch(() => null);
26425
26960
  const withinResult = sanitizeHtml(res.html);
@@ -26437,14 +26972,47 @@ ${closureHtml}`;
26437
26972
  await trilium.updateLabelValue(closureThread.noteId, "updated", d);
26438
26973
  if (labelOf2(closureThread, "status") === "dormant")
26439
26974
  await trilium.updateLabelValue(closureThread.noteId, "status", "active");
26975
+ const closureHeading = leadingIdentityText(closureBlock);
26440
26976
  closureReceipt = {
26441
26977
  threadId: closureThread.noteId,
26442
26978
  thread: closureThread.title,
26443
26979
  entryId: entry.noteId,
26444
26980
  action: entry.action,
26981
+ ...entry.marker ? { entryMarker: entry.marker } : {},
26982
+ ...closureHeading ? { runHeading: closureHeading } : {},
26445
26983
  next: "Run consistency() on any fact the closed row asserted elsewhere, so no stale sentence survives it."
26446
26984
  };
26447
26985
  }
26986
+ if (closureTarget) {
26987
+ const sameNote = closureTarget.note.noteId === noteId;
26988
+ const base = sameNote ? await trilium.getNoteContent(noteId) : closureTarget.content;
26989
+ const targetSpot = sameNote ? findTable(base, closureTarget.heading) : closureTarget.spot;
26990
+ if (targetSpot) {
26991
+ await trilium.createRevision(closureTarget.note.noteId).catch(() => null);
26992
+ const regStamped = bumpLastUpdated(appendRowToTable(base, targetSpot, closureCells), d);
26993
+ await trilium.updateNoteContent(closureTarget.note.noteId, regStamped.html);
26994
+ await trilium.updateLabelValue(closureTarget.note.noteId, "updated", d);
26995
+ if (labelOf2(closureTarget.note, "status") === "dormant")
26996
+ await trilium.updateLabelValue(closureTarget.note.noteId, "status", "active");
26997
+ const marked = hasLabel(closureTarget.note, "register") || ownedLabel(closureTarget.note, "noteType") === "thread";
26998
+ closureReceipt = {
26999
+ noteId: closureTarget.note.noteId,
27000
+ note: closureTarget.note.title,
27001
+ table: closureTarget.heading ?? "(first table)",
27002
+ row: closureCells.map((c) => toText(c, 80)).join(" | "),
27003
+ action: "appended",
27004
+ ...closurePadded ? { padded: `${closurePadded} empty cell(s) added so the row matches the table's shape` } : {},
27005
+ ...marked ? {} : { registerHint: `The target is not marked #register \u2014 label(noteId, "register", value="") exempts its table's evidence dates from maintain's dated-prose lint.` }
27006
+ };
27007
+ } else {
27008
+ closureReceipt = {
27009
+ noteId: closureTarget.note.noteId,
27010
+ note: closureTarget.note.title,
27011
+ action: "missed",
27012
+ hint: "The row was removed, but the table could no longer be found in the target note \u2014 nothing was appended there. The source note has a revision from before the removal."
27013
+ };
27014
+ }
27015
+ }
26448
27016
  return txt3({
26449
27017
  ok: true,
26450
27018
  noteId,
@@ -26468,11 +27036,12 @@ ${closureHtml}`;
26468
27036
  const applied = applyFindEdit(working, e.find, e.body, e.nth);
26469
27037
  if (!applied) {
26470
27038
  const alreadyConsumed = consumed.has(e.find);
27039
+ const nthOvershot = !alreadyConsumed && e.nth !== undefined && working.includes(e.find);
26471
27040
  results.push({
26472
27041
  find: e.find,
26473
27042
  replaced: 0,
26474
- hint: missHint(e.find, current, alreadyConsumed),
26475
- ...alreadyConsumed ? {} : missContext(e.find, working)
27043
+ hint: nthOvershot ? `nth=${e.nth} is past the last occurrence of this text, so nothing was replaced. The text itself is present \u2014 check how many occurrences exist, then re-target, or omit nth= to replace every one.` : missHint(e.find, current, alreadyConsumed),
27044
+ ...alreadyConsumed || nthOvershot ? {} : missContext(e.find, working)
26476
27045
  });
26477
27046
  continue;
26478
27047
  }
@@ -26488,7 +27057,7 @@ ${closureHtml}`;
26488
27057
  mode: edits ? "edits" : "find-replace",
26489
27058
  replaced: 0,
26490
27059
  date: d,
26491
- ...edits ? { results } : {
27060
+ ...edits ? { results, hint: `None of the ${plan.length} edit(s) matched anything \u2014 see results[].hint for the cause of each; nothing was written.` } : {
26492
27061
  hint: results[0].hint,
26493
27062
  ...results[0].matchedUpTo ? { matchedUpTo: results[0].matchedUpTo } : {},
26494
27063
  ...results[0].storedNearby ? { storedNearby: results[0].storedNearby } : {}
@@ -26584,6 +27153,7 @@ ${closureHtml}`;
26584
27153
  let finalContent = null;
26585
27154
  let sectionMiss = {};
26586
27155
  let threadEntryResult = null;
27156
+ let threadRunHeading = null;
26587
27157
  if (body) {
26588
27158
  const sanitized = renderBody(body);
26589
27159
  const html = sanitized.html;
@@ -26634,6 +27204,7 @@ ${closureHtml}`;
26634
27204
  return badIdentity;
26635
27205
  const block = identity && !leadingIdentification(html) ? `<h3>${escapeHtml(identity)}</h3>
26636
27206
  ${html}` : html;
27207
+ threadRunHeading = leadingIdentityText(block);
26637
27208
  threadEntryResult = await appendThreadEntry(noteId, block, d);
26638
27209
  if (threadEntryResult.action === "already_written") {
26639
27210
  return txt3({ ok: true, noteId, mode: "already_written", entryId: threadEntryResult.noteId, date: d });
@@ -26670,7 +27241,7 @@ ${block}`), d).html;
26670
27241
  } : {},
26671
27242
  ...sectionMiss,
26672
27243
  ...sectionHint ? { hint: sectionHint } : {},
26673
- ...threadEntryResult ? { entryId: threadEntryResult.noteId, entryAction: threadEntryResult.action, entry: `today's [${d}] entry in this thread; revise(entryId) edits it` } : {},
27244
+ ...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` } : {},
26674
27245
  ...titled.retitled ? { retitled: titled.retitled } : {},
26675
27246
  ...titled.cascaded ? { domainCascade: `#domain updated on ${titled.cascaded} descendant note(s)` } : {},
26676
27247
  ...structuralFindings(noteKind, finalContent),
@@ -26942,7 +27513,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
26942
27513
  }
26943
27514
  }
26944
27515
  });
26945
- 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). Matching ignores case unless caseSensitive=true (a leading (?i) is accepted). Without a capture group the whole match is the value compared. patterns=[\u2026] checks several facts in one call over one read of each note. Run it after correcting any fact recorded in more than one place.`, {
27516
+ 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. Multiple values co-asserted within one note \u2014 two true figures in one cell \u2014 are that note's compound fact, not a contradiction; only values no note asserts together flag as a 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). Matching ignores case unless caseSensitive=true (a leading (?i) is accepted). Without a capture group the whole match is the value compared. patterns=[\u2026] checks several facts in one call over one read of each note. Run it after correcting any fact recorded in more than one place.`, {
26946
27517
  pattern: stringType().optional().describe("Regex over note bodies. One capture group = the value that should agree across notes; with none, the whole match is the value. Omit when using subject= (prose mode)."),
26947
27518
  patterns: arrayType(stringType()).max(10).optional().describe("Several patterns checked in one call (each reported as its own result, in order); every note is read once"),
26948
27519
  caseSensitive: booleanType().optional().describe('Match case exactly (default false: "Blueprints" and "blueprints" both match)'),
@@ -27047,6 +27618,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
27047
27618
  clauses.push(`#domain='${slugify(domain)}'`);
27048
27619
  const notes = await trilium.searchNotes(clauses.join(" AND "), { ancestorNoteId: b().root, limit: wantsRecords ? max : max * 4, includeArchivedNotes: includeArchived ?? false }).then((r) => r.results).catch(() => []);
27049
27620
  const scoped = notes.filter(inScope).slice(0, max);
27621
+ const noteValues = new Map;
27050
27622
  const byValue = new Map;
27051
27623
  const noCapture = [];
27052
27624
  let hasCaptureGroup = false;
@@ -27079,15 +27651,19 @@ calling twice is safe. Use remove=true to delete an edge.`, {
27079
27651
  const seen = new Set;
27080
27652
  for (const value of [...raw.values, ...projected.values]) {
27081
27653
  hasCaptureGroup = true;
27082
- if (seen.has(value))
27083
- continue;
27084
27654
  seen.add(value);
27655
+ }
27656
+ if (seen.size)
27657
+ noteValues.set(n.noteId, { stub, values: seen });
27658
+ if ((raw.matched || projected.matched) && !seen.size)
27659
+ noCapture.push(stub);
27660
+ }
27661
+ for (const { stub, values } of noteValues.values()) {
27662
+ for (const value of values) {
27085
27663
  if (!byValue.has(value))
27086
27664
  byValue.set(value, []);
27087
27665
  byValue.get(value).push(stub);
27088
27666
  }
27089
- if ((raw.matched || projected.matched) && !seen.size)
27090
- noCapture.push(stub);
27091
27667
  }
27092
27668
  if (!hasCaptureGroup) {
27093
27669
  return txt3({
@@ -27103,7 +27679,18 @@ calling twice is safe. Use remove=true to delete an edge.`, {
27103
27679
  }
27104
27680
  const groups = [...byValue.entries()].map(([value, notes]) => ({ value, count: notes.length, notes })).sort((a, b) => b.count - a.count);
27105
27681
  const staleSingles = staleAfterDays ? groups.filter((g) => g.count === 1 && g.notes[0].idleDays >= staleAfterDays).map((g) => ({ value: g.value, note: { id: g.notes[0].id, title: g.notes[0].title, kind: g.notes[0].kind }, idleDays: g.notes[0].idleDays })) : [];
27106
- const agrees = groups.length <= 1;
27682
+ const valueNotes = [...byValue.values()];
27683
+ let crossNoteConflict = false;
27684
+ for (let i = 0;i < valueNotes.length && !crossNoteConflict; i++) {
27685
+ const ids = new Set(valueNotes[i].map((s) => s.id));
27686
+ for (let j = i + 1;j < valueNotes.length; j++) {
27687
+ if (!valueNotes[j].some((s) => ids.has(s.id))) {
27688
+ crossNoteConflict = true;
27689
+ break;
27690
+ }
27691
+ }
27692
+ }
27693
+ const agrees = !crossNoteConflict;
27107
27694
  return txt3({
27108
27695
  mode: "consistency",
27109
27696
  pattern,
@@ -27120,7 +27707,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
27120
27707
  ...staleAfterDays ? { staleAfterDays } : {},
27121
27708
  ...staleSingles.length ? { staleSingles, staleSinglesNote: `${staleSingles.length} figure(s) appear in exactly one note, untouched ${staleAfterDays}d+ \u2014 a single copy cannot disagree with itself, so re-measure these against the world rather than against other notes.` } : {},
27122
27709
  ...noCapture.length ? { matchedWithoutValue: noCapture } : {},
27123
- note: agrees ? groups.length === 0 ? "No note asserted a value for that pattern. That is evidence about the pattern, not about the brain \u2014 check it against a note you know contains the fact." : `All ${groups[0].count} note(s) agree on "${groups[0].value}".` : `${groups.length} DIFFERENT values are asserted across ${scoped.length} notes. Establish which is true from evidence, correct every note that disagrees, and wire ~corrects from the note that overturns the old claim \u2014 revising in place leaves no trace the wrong value was ever believed.`
27710
+ note: agrees ? groups.length === 0 ? "No note asserted a value for that pattern. That is evidence about the pattern, not about the brain \u2014 check it against a note you know contains the fact." : groups.length === 1 ? `All ${groups[0].count} note(s) agree on "${groups[0].value}".` : `${groups.length} different values were captured, but every pair of them is asserted together inside a single note \u2014 one note's compound fact (two true figures in one cell), not a contradiction. Re-measure if any value should be the only one.` : `${groups.length} DIFFERENT values are asserted across ${scoped.length} notes. Establish which is true from evidence, correct every note that disagrees, and wire ~corrects from the note that overturns the old claim \u2014 revising in place leaves no trace the wrong value was ever believed.`
27124
27711
  });
27125
27712
  }
27126
27713
  });
@@ -27314,7 +27901,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
27314
27901
  };
27315
27902
  server.tool("claim", `Does the brain still agree with the world? Register a checkable assertion and record whether it holds. BrainLLM never runs the check \u2014 it stores it as inert text; you run it and report.
27316
27903
 
27317
- assertion + check \u2192 register (deduped by assertion); claimId + holds + evidence \u2192 verify (evidence required); claimId alone \u2192 read with history; nothing \u2192 list (status= filters). noteId= links the claim to its source note (~derivedFrom). maintain(deep) surfaces lapsed, never-verified, broken and source-changed claims.`, {
27904
+ assertion + check \u2192 register (deduped by assertion); claimId + holds + evidence \u2192 verify (evidence required); claimId + holds + evidence + retire \u2192 verify and archive in one call; claimId alone \u2192 read with history; nothing \u2192 list (status= filters). noteId= links the claim to its source note (~derivedFrom). maintain(deep) surfaces lapsed, never-verified, broken and source-changed claims.`, {
27318
27905
  assertion: stringType().optional().describe('The claim in plain words, e.g. "the parse pipeline runs before validation" \u2014 also the dedup key'),
27319
27906
  check: stringType().optional().describe("How to verify it, as INERT text a human or agent runs: a command, a query, a file path, a URL. Never executed by BrainLLM"),
27320
27907
  noteId: stringType().optional().describe("The note this claim is made in \u2014 wired ~derivedFrom so the claim and its source stay linked"),
@@ -27323,7 +27910,7 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
27323
27910
  holds: booleanType().optional().describe("With claimId: did the check pass? false marks the claim broken and surfaces it in maintain()"),
27324
27911
  evidence: stringType().optional().describe("With holds: what you actually observed \u2014 the output, the count, the response. A verification without evidence is an assertion about an assertion"),
27325
27912
  status: enumType(["holding", "broken", "lapsed", "all"]).optional().describe("List filter (default: all)"),
27326
- retire: booleanType().optional().describe("With claimId: archive this claim \u2014 the thing it described is gone")
27913
+ retire: booleanType().optional().describe("With claimId: archive this claim \u2014 the thing it described is gone. On the same call as holds= and evidence=: record that final verification (e.g. the failure) and archive it in one call")
27327
27914
  }, async ({ assertion, check, noteId, intervalDays, claimId, holds, evidence, status, retire }) => {
27328
27915
  const cfg = b();
27329
27916
  if (!cfg.root)
@@ -27349,7 +27936,7 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
27349
27936
  const note = await trilium.getNote(claimId).catch(() => null);
27350
27937
  if (!note || labelOf2(note, "noteType") !== "claim")
27351
27938
  return err("not_found", `${claimId} is not a claim.`, "List them with claim() and no arguments.");
27352
- if (retire) {
27939
+ if (retire && holds === undefined) {
27353
27940
  await trilium.addLabel(claimId, "archived", "").catch(() => null);
27354
27941
  return txt3({ ok: true, claimId, action: "retired", assertion: note.title });
27355
27942
  }
@@ -27367,11 +27954,14 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
27367
27954
  await trilium.updateLabelValue(claimId, "verified", d).catch(() => null);
27368
27955
  await trilium.updateLabelValue(claimId, "claimState", holds ? "holding" : "broken").catch(() => null);
27369
27956
  await trilium.updateLabelValue(claimId, "updated", d).catch(() => null);
27957
+ if (retire)
27958
+ await trilium.addLabel(claimId, "archived", "").catch(() => null);
27370
27959
  const refreshed = await trilium.getNote(claimId).catch(() => note);
27371
27960
  return txt3({
27372
27961
  ok: true,
27373
27962
  action: holds ? "verified" : "marked-broken",
27374
27963
  ...row(refreshed),
27964
+ ...retire ? { retired: true, note: "The verification was recorded and the claim archived in this one call \u2014 its history stays in the Verifications section, and the register no longer lists it." } : {},
27375
27965
  ...holds ? {} : { hint: "This claim is now BROKEN. Correct every note that asserts it \u2014 consistency() will find the others \u2014 then re-verify. A broken claim left in the register is a known-wrong fact with a timestamp on it." }
27376
27966
  });
27377
27967
  }
@@ -27912,7 +28502,8 @@ and every thread day-child, in time order with its identification line.`, {
27912
28502
  const todaySess = await trilium.searchNotes(`#noteType=session #created='${todayStr}'`, { ancestorNoteId: cfg.memory.sessions, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
27913
28503
  if (todaySess.results[0]) {
27914
28504
  const content = await trilium.getNoteContent(todaySess.results[0].noteId).catch(() => "");
27915
- newDay = !/<h2(?:\s[^>]*)?>\s*Addendum/i.test(content);
28505
+ const note = await trilium.getNote(todaySess.results[0].noteId).catch(() => null);
28506
+ newDay = note ? !hasLabel(note, "interactive") : !/<h2(?:\s[^>]*)?>\s*Addendum/i.test(content);
27916
28507
  }
27917
28508
  const sessions = await trilium.searchNotes("#noteType=session", {
27918
28509
  ancestorNoteId: cfg.memory.sessions,
@@ -27932,6 +28523,7 @@ and every thread day-child, in time order with its identification line.`, {
27932
28523
  previousLog = { id: log.results[0].noteId, content: await trilium.getNoteContent(log.results[0].noteId).catch(() => "") };
27933
28524
  }
27934
28525
  let changes = [];
28526
+ let changesTruncated;
27935
28527
  if (lastSession) {
27936
28528
  try {
27937
28529
  const history = await trilium.getNoteHistory(cfg.root);
@@ -27941,12 +28533,14 @@ and every thread day-child, in time order with its identification line.`, {
27941
28533
  if (!prevEntry || h.current_isDeleted && !prevEntry.current_isDeleted)
27942
28534
  deduped.set(h.noteId, h);
27943
28535
  }
27944
- changes = [...deduped.values()].slice(0, 25).map((h) => ({
28536
+ const capped = capChanges([...deduped.values()].map((h) => ({
27945
28537
  id: h.noteId,
27946
28538
  title: h.current_title,
27947
28539
  changed: h.date.slice(0, 10),
27948
28540
  ...h.current_isDeleted ? { deleted: true } : {}
27949
- }));
28541
+ })));
28542
+ changes = capped.changes;
28543
+ changesTruncated = capped.changesTruncated;
27950
28544
  } catch {}
27951
28545
  }
27952
28546
  return txt3({
@@ -27955,6 +28549,7 @@ and every thread day-child, in time order with its identification line.`, {
27955
28549
  lastSession,
27956
28550
  previousLog,
27957
28551
  changes: changes.length ? changes : undefined,
28552
+ ...changesTruncated ? { changesTruncated } : {},
27958
28553
  next: [
27959
28554
  "Skim lastSession, previousLog and changes for evidenced outputs \u2014 never plausibility.",
27960
28555
  "Present what moved in the first message."
@@ -28222,8 +28817,8 @@ and every thread day-child, in time order with its identification line.`, {
28222
28817
  tool.handler = async (...args) => {
28223
28818
  const result = await inner(...args);
28224
28819
  if (!wroteSinceClose && wroteContent(result)) {
28225
- await markStep("write").catch(() => null);
28226
28820
  wroteSinceClose = true;
28821
+ await markWriteStep();
28227
28822
  }
28228
28823
  return result;
28229
28824
  };
@@ -28236,7 +28831,7 @@ function wroteContent(result) {
28236
28831
  return true;
28237
28832
  try {
28238
28833
  const r = JSON.parse(text);
28239
- if (r.error || r.ok === false || r.unchanged === true || r.action === "already_written" || r.mode === "already_written")
28834
+ if (r.error || r.ok === false || r.unchanged === true || r.action === "already_written" || r.mode === "already_written" || r.dryRun === true)
28240
28835
  return false;
28241
28836
  if (r.replaced === 0 && !r.closure)
28242
28837
  return false;
@@ -30205,7 +30800,7 @@ function createServer(origin = null) {
30205
30800
  const s = new McpServer({
30206
30801
  name: BRAND2.name,
30207
30802
  title: BRAND2.name,
30208
- version: "12.9.0",
30803
+ version: "13.0.0",
30209
30804
  description: BRAND2.description,
30210
30805
  websiteUrl: BRAND2.website,
30211
30806
  icons: brandIcons(origin)