brainllm 12.9.1 → 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();
@@ -22942,6 +22943,108 @@ function upsertTableRow(html, heading, key, cells) {
22942
22943
  created: !matchedRow
22943
22944
  };
22944
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
+ }
22945
23048
 
22946
23049
  // src/templates.ts
22947
23050
  var RESOLUTION_ANCHOR = "<h2>Resolution</h2>";
@@ -23391,6 +23494,9 @@ function isoDaysAgo(days) {
23391
23494
  function reviewKey(n) {
23392
23495
  return n.blobId ?? n.dateModified;
23393
23496
  }
23497
+ function isAcknowledged(n) {
23498
+ return ownedLabel(n, "reviewed") === reviewKey(n);
23499
+ }
23394
23500
  var TIMELESS_KINDS = new Set(["thread", "user", "information", "domain"]);
23395
23501
  var DATED_PROSE_LIMIT = 4;
23396
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)?";
@@ -23429,7 +23535,7 @@ async function sweep(trilium, cfg, opts = {}) {
23429
23535
  const inScope = (n) => !domainSlug || n.attributes.some((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === "domain" && a.value === domainSlug);
23430
23536
  let suppressed = 0;
23431
23537
  const acknowledged = (n) => {
23432
- if (ownedLabel(n, "reviewed") !== reviewKey(n))
23538
+ if (!isAcknowledged(n))
23433
23539
  return false;
23434
23540
  suppressed++;
23435
23541
  return true;
@@ -24848,6 +24954,94 @@ function applyFindEdit(source, needle, replacement, index) {
24848
24954
  const inline = inlineTolerantSpans(source, needle);
24849
24955
  return inline.length ? spans(inline, "inline-tolerant") : null;
24850
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
+ }
24851
25045
  var err = (code, detail, hint) => txt3({ error: code, detail, ...hint ? { hint } : {} });
24852
25046
  var labelOf2 = (n, name) => n.attributes.find((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === name)?.value;
24853
25047
  var hasLabel = (n, name) => n.attributes.some((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === name);
@@ -24884,6 +25078,7 @@ function registerTools(server, trilium, brainRef) {
24884
25078
  const REQUIRED_PRECLOSE_STEPS = ["session", "addendum", "maintain", "remarks", "diary"];
24885
25079
  const LIGHT_PRECLOSE_STEPS = ["session", "remarks", "diary"];
24886
25080
  let wroteSinceClose = false;
25081
+ let writeMarkPending = null;
24887
25082
  const gateNote = async (date) => {
24888
25083
  const cfg = b();
24889
25084
  if (!cfg.memory.sessions)
@@ -24928,6 +25123,16 @@ function registerTools(server, trilium, brainRef) {
24928
25123
  preCloseSteps.set(step, next);
24929
25124
  preCloseSeq = next;
24930
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
+ };
24931
25136
  const readGate = async (date) => {
24932
25137
  const noteId = await gateNote(date);
24933
25138
  if (!noteId)
@@ -25009,6 +25214,7 @@ function registerTools(server, trilium, brainRef) {
25009
25214
  async function appendThreadEntry(threadId, block, d) {
25010
25215
  const found = await trilium.searchNotes(`#noteType=threadEntry #created='${d}'`, { ancestorNoteId: threadId, fastSearch: true, limit: 1 });
25011
25216
  const time = localNowTime();
25217
+ const marker = `Addendum \u2014 ${time}`;
25012
25218
  if (found.results[0]) {
25013
25219
  const noteId = found.results[0].noteId;
25014
25220
  const current = fixRecordHeader(await trilium.getNoteContent(noteId), "threadEntry", d).html;
@@ -25018,17 +25224,17 @@ function registerTools(server, trilium, brainRef) {
25018
25224
  if (incoming && blocks.some((b) => norm(b) === incoming))
25019
25225
  return { noteId, action: "already_written" };
25020
25226
  await trilium.createRevision(noteId);
25021
- await trilium.updateNoteContent(noteId, safeAppend(current, `<h2>Addendum \u2014 ${time}</h2>`, block));
25227
+ await trilium.updateNoteContent(noteId, safeAppend(current, `<h2>${marker}</h2>`, block));
25022
25228
  await trilium.updateLabelValue(noteId, "updated", d);
25023
- return { noteId, action: "appended" };
25229
+ return { noteId, action: "appended", marker };
25024
25230
  }
25025
- 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>
25026
25232
  ${block}` }));
25027
25233
  const noteId = created.note.noteId;
25028
25234
  await trilium.addLabel(noteId, "noteType", "threadEntry");
25029
25235
  await trilium.addLabel(noteId, "created", d);
25030
25236
  await ensureIcon(trilium, noteId);
25031
- return { noteId, action: "created" };
25237
+ return { noteId, action: "created", marker };
25032
25238
  }
25033
25239
  function collectionAppendRefusal(book) {
25034
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.`);
@@ -25083,7 +25289,8 @@ ${block}` }));
25083
25289
  sessionNoteId = digest.todaySession.id;
25084
25290
  const content = await trilium.getNoteContent(sessionNoteId).catch(() => "");
25085
25291
  sessionPreview = toText(content, 200);
25086
- 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);
25087
25294
  } else {
25088
25295
  sessionNoteId = await ensureGateNote(todayStr);
25089
25296
  newDay = true;
@@ -25091,6 +25298,7 @@ ${block}` }));
25091
25298
  } catch {}
25092
25299
  }
25093
25300
  let changesSinceLastSession = [];
25301
+ let changesTruncated;
25094
25302
  if (digest.lastSession && cfg.root) {
25095
25303
  const sinceDate = digest.lastSession.date;
25096
25304
  try {
@@ -25102,12 +25310,14 @@ ${block}` }));
25102
25310
  deduped.set(h.noteId, h);
25103
25311
  }
25104
25312
  }
25105
- changesSinceLastSession = [...deduped.values()].slice(0, 25).map((h) => ({
25313
+ const capped = capChanges([...deduped.values()].map((h) => ({
25106
25314
  id: h.noteId,
25107
25315
  title: h.current_title,
25108
25316
  changed: h.date.slice(0, 10),
25109
25317
  ...h.current_isDeleted ? { deleted: true } : {}
25110
- }));
25318
+ })));
25319
+ changesSinceLastSession = capped.changes;
25320
+ changesTruncated = capped.changesTruncated;
25111
25321
  } catch {}
25112
25322
  }
25113
25323
  return txt3({
@@ -25121,6 +25331,7 @@ ${block}` }));
25121
25331
  dormantThreads: digest.reviewQueue.length ? { note: "These threads went dormant from inactivity. Mention them if relevant; revise() or resolve() to act.", items: digest.reviewQueue } : [],
25122
25332
  lastSession: digest.lastSession ?? null,
25123
25333
  changesSinceLastSession: changesSinceLastSession.length ? changesSinceLastSession : undefined,
25334
+ ...changesTruncated ? { changesTruncated } : {},
25124
25335
  ...newDay ? { newDay: true, newDayHint: "First session of the day \u2014 call day() for the sweep payload (previous session + log + changes in one call)." } : {},
25125
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.' } : {},
25126
25337
  hygiene: { scanned: hygiene.scanned, fixed: hygiene.fixed.length, transitions: hygiene.transitions, flagged: hygiene.flagged, ...hygiene.suppressed ? { suppressed: hygiene.suppressed } : {} }
@@ -25128,22 +25339,42 @@ ${block}` }));
25128
25339
  });
25129
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.
25130
25341
 
25131
- 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.`, {
25132
25343
  date: stringType().optional().describe("ISO date YYYY-MM-DD (default: today)"),
25133
25344
  full: booleanType().optional().describe("Inline every singleton's content and the diary body (default: stubs only)"),
25134
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."),
25135
25347
  light: booleanType().optional().describe("Deprecated \u2014 light is now the default; accepted for compatibility and ignored")
25136
- }, async ({ date, full, scope }) => {
25348
+ }, async ({ date, full, scope, continuing }) => {
25137
25349
  const d = checkedDate(date);
25138
25350
  const cfg = b();
25139
25351
  if (!cfg.master.root || !cfg.llm.root)
25140
25352
  throw new Error("BrainLLM not bootstrapped \u2014 run bootstrap.");
25141
- 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)) {
25142
25371
  await markStep("session", d);
25372
+ await stampSessionScope(d, scope ?? "interactive");
25143
25373
  return txt3({
25144
25374
  date: d,
25375
+ ...continued ? { continuing: true } : {},
25145
25376
  closeMode: "light",
25146
- 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.",
25147
25378
  next: [
25148
25379
  "Call remarks() \u2014 one short cue for a short session.",
25149
25380
  "Call diary() \u2014 one short paragraph: what the session was and anything worth the team knowing.",
@@ -25151,6 +25382,49 @@ The close sequence next[] lays out: singleton updates from the session, then add
25151
25382
  ]
25152
25383
  });
25153
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
+ }
25154
25428
  const fetchSingleton = async (id) => {
25155
25429
  if (!full) {
25156
25430
  const note = await trilium.getNote(id);
@@ -25172,24 +25446,9 @@ The close sequence next[] lays out: singleton updates from the session, then add
25172
25446
  fetchSingleton(cfg.llm.protocols),
25173
25447
  cfg.llm.selfcorrection ? fetchSingleton(cfg.llm.selfcorrection) : Promise.resolve(null)
25174
25448
  ]);
25175
- let diaryEntry = null;
25176
- if (cfg.llm.diary) {
25177
- const diarySearch = await trilium.searchNotes(`#noteType=diary #created='${d}'`, { ancestorNoteId: cfg.llm.diary, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
25178
- if (diarySearch.results[0]) {
25179
- const id = diarySearch.results[0].noteId;
25180
- const content = await trilium.getNoteContent(id).catch(() => "");
25181
- diaryEntry = full ? { id, content } : { id, blocks: (content.match(/<h2(?:\s[^>]*)?>\s*Addendum/gi) ?? []).length, size: content.length };
25182
- }
25183
- }
25449
+ const diaryEntry = await diaryEntryOfToday();
25184
25450
  const hygiene = await sweep(trilium, cfg, { deep: false, dryRun: false }).catch(() => null);
25185
- const pendingAddendums = await trilium.searchNotes("#noteType note.content *=* 'Addendum'", { ancestorNoteId: cfg.root, fastSearch: false, limit: 40 }).then(async (r) => {
25186
- const candidates = r.results.filter((n) => {
25187
- const kind = labelOf2(n, "noteType");
25188
- return kind && !["session", "diary", "log", "threadEntry", "thread"].includes(kind);
25189
- });
25190
- const contents = await Promise.all(candidates.map((n) => trilium.getNoteContent(n.noteId).catch(() => "")));
25191
- return contents.filter(hasAddendumMarker).length;
25192
- }).catch(() => null);
25451
+ const pendingAddendums = await pendingAddendumCount();
25193
25452
  const singletonStubs = {
25194
25453
  biography,
25195
25454
  goals,
@@ -25212,11 +25471,13 @@ The close sequence next[] lays out: singleton updates from the session, then add
25212
25471
  singletonsWrittenToday: touchedToday.length ? touchedToday : "none"
25213
25472
  };
25214
25473
  await markStep("session", d);
25474
+ await stampSessionScope(d, scope ?? "interactive");
25215
25475
  const scoped = scope === "agent";
25216
25476
  return txt3({
25217
25477
  date: d,
25218
25478
  ...scoped ? { scope: "agent" } : {},
25219
- ...!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 } : {},
25220
25481
  master: { biography, goals, preferences },
25221
25482
  llm: { responsibilities, protocols, ...selfcorrection ? { selfcorrection } : {} },
25222
25483
  diary: diaryEntry,
@@ -25255,6 +25516,7 @@ The close sequence next[] lays out: singleton updates from the session, then add
25255
25516
  if (!(await readGate(localToday())).has("write")) {
25256
25517
  return txt3({
25257
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.",
25258
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.",
25259
25521
  next: ["Write it via diary(body, identity), then call close()."]
25260
25522
  });
@@ -25302,7 +25564,9 @@ The close sequence next[] lays out: singleton updates from the session, then add
25302
25564
  }
25303
25565
  }
25304
25566
  const gate = await readGate(gateDate);
25305
- 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);
25306
25570
  const required = light ? LIGHT_PRECLOSE_STEPS : REQUIRED_PRECLOSE_STEPS;
25307
25571
  const missing = continued ? [] : required.filter((step) => !gate.has(step));
25308
25572
  if (missing.length && !force) {
@@ -25391,6 +25655,10 @@ ${contentBlock}` }));
25391
25655
  }
25392
25656
  }
25393
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
+ }
25394
25662
  return txt3({
25395
25663
  action,
25396
25664
  noteId,
@@ -25403,6 +25671,7 @@ ${contentBlock}` }));
25403
25671
  ...continued ? { continuing: true } : {},
25404
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" },
25405
25673
  ...light && !continued ? { closeMode: "light \u2014 no content was written this session, so addendum() and maintain() were not required" } : {},
25674
+ ...writeMarkPending ? { gateNote: writeMarkPending } : {},
25406
25675
  ...missing.length || !orderOk ? { bypassed: [...missing, ...!orderOk ? ["ordering(session\u2192remarks\u2192diary)"] : []] } : {},
25407
25676
  ...warnings.length ? { sanitized: warnings } : {}
25408
25677
  });
@@ -25536,7 +25805,7 @@ ${html}` }));
25536
25805
  });
25537
25806
  server.tool("remember", `Store something the moment it matters; the server owns placement, labels and dedup.
25538
25807
 
25539
- 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).
25540
25809
 
25541
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.`, {
25542
25811
  kind: enumType(Kinds).describe("What kind of memory this is"),
@@ -25557,17 +25826,20 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25557
25826
  mandate: booleanType().optional().describe("kind=information: flag the note as a standing brief a session must follow (#mandate)"),
25558
25827
  mustCreate: booleanType().optional().describe("Refuse instead of adopting a note that already has this title"),
25559
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'),
25560
25830
  connect: arrayType(objectType({
25561
25831
  relation: enumType(RelationTypes),
25562
25832
  toNoteId: stringType()
25563
25833
  })).optional().describe("Relations to wire from this note in the same call \u2014 same semantics as connect() (idempotent, worksWith wired both ways)"),
25564
25834
  icon: stringType().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name ("brain"); normalized server-side'),
25565
25835
  date: stringType().optional().describe("ISO date override (default: today)")
25566
- }, 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 }) => {
25567
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.`);
25568
25838
  const opts = { domain, topics, date, ...mandate ? { mandate: true } : {} };
25569
25839
  const d = checkedDate(date);
25570
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.");
25571
25843
  if (strict) {
25572
25844
  const repairs = repairedStructure(sanitizeWarnings);
25573
25845
  if (repairs.length) {
@@ -25610,6 +25882,20 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25610
25882
  return wiredEdges;
25611
25883
  };
25612
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
+ };
25613
25899
  if (isSingleton(kind)) {
25614
25900
  const id = kindHome(b(), kind);
25615
25901
  if (!id)
@@ -25623,6 +25909,48 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25623
25909
  if (kind === "sources") {
25624
25910
  if (!domain)
25625
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
+ }
25626
25954
  const { domainId, domainTitle, createdDomain, sourcesId } = await resolveDomain(trilium, b(), domain);
25627
25955
  const found = sourcesId ? { results: [] } : await trilium.searchNotes("#noteType=sources", { ancestorNoteId: domainId, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
25628
25956
  let sid = sourcesId ?? found.results[0]?.noteId;
@@ -25648,8 +25976,11 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25648
25976
  }
25649
25977
  }
25650
25978
  const revisionChanges = [];
25979
+ const addedKeys = [];
25980
+ let existingKeys = [];
25651
25981
  if (sid && revision?.length) {
25652
25982
  let current = await trilium.getNoteContent(sid);
25983
+ existingKeys = tableRows(current, "Revision").map((cells) => cells[0]).filter(Boolean);
25653
25984
  let changed = false;
25654
25985
  for (const row of revision) {
25655
25986
  const result = upsertTableRow(current, "Revision", row.source, [row.marker, row.date ?? d]);
@@ -25657,6 +25988,8 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25657
25988
  current = result.html;
25658
25989
  changed = true;
25659
25990
  revisionChanges.push(`${result.matched ? "updated" : "added"}: ${row.source}`);
25991
+ if (!result.matched)
25992
+ addedKeys.push(row.source);
25660
25993
  }
25661
25994
  }
25662
25995
  if (changed) {
@@ -25676,6 +26009,7 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25676
26009
  const placeholderLeft = hasPlaceholderRow(finalHtml, "Revision");
25677
26010
  const bodyWritten = !!html && !!toText(html, 50);
25678
26011
  const looseProse = bodyWritten && !createdDomain && !/<(?:ul|ol|li|h[34]|table)\b/i.test(html);
26012
+ const keyHints = sourceKeyHints(addedKeys, existingKeys);
25679
26013
  return txt3({
25680
26014
  action: wrote ? "maintained" : "already_written",
25681
26015
  noteId: sid,
@@ -25687,6 +26021,7 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25687
26021
  ...sourceMerge,
25688
26022
  ...revisionKeys.length ? { revisionRows: revisionKeys.length } : {},
25689
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 } : {},
25690
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." } : {},
25691
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." } : {},
25692
26027
  ...connected.length ? { connected } : {},
@@ -25792,6 +26127,7 @@ ${html}` : html;
25792
26127
  if (kind === "thread") {
25793
26128
  if (isCollectionThread(existing))
25794
26129
  return collectionAppendRefusal(existing);
26130
+ const runHeading = leadingIdentityText(block);
25795
26131
  const entry = await appendThreadEntry(existing.noteId, block, d);
25796
26132
  if (entry.action === "already_written") {
25797
26133
  return txt3({ action: "already_written", noteId: existing.noteId, entryId: entry.noteId, kind, title: existing.title });
@@ -25806,7 +26142,7 @@ ${html}` : html;
25806
26142
  const connected = await wireRequested(existing.noteId);
25807
26143
  const iconSet = await applyIcon(existing.noteId, icon);
25808
26144
  const relations = relationSnippet(existing);
25809
- 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 } : {} });
25810
26146
  }
25811
26147
  const current = await trilium.getNoteContent(existing.noteId);
25812
26148
  if (isDuplicateAppend(current, block))
@@ -25870,7 +26206,7 @@ ${block}`), d);
25870
26206
  ...sanitizeWarnings.length ? { sanitized: sanitizeWarnings } : {}
25871
26207
  });
25872
26208
  });
25873
- 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.`, {
25874
26210
  query: stringType().describe("What to find \u2014 natural phrasing is fine"),
25875
26211
  kinds: arrayType(enumType(Kinds)).optional().describe("Restrict to these kinds"),
25876
26212
  domain: stringType().optional().describe("Restrict to a knowledge domain"),
@@ -25880,8 +26216,10 @@ ${block}`), d);
25880
26216
  orderDirection: enumType(["asc", "desc"]).optional().describe("asc | desc (default: desc for dates, asc for title)"),
25881
26217
  fastSearch: booleanType().optional().describe("Title/label only \u2014 faster, skips full-text body scan"),
25882
26218
  regex: stringType().optional().describe("Regular expression matched against note bodies (Trilium %= operator). Takes precedence over the keyword strategies; escape backslashes."),
25883
- fuzzy: booleanType().optional().describe("Force the fuzzy pass on (default: automatic when exact strategies return few results) or off")
25884
- }, 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 }) => {
25885
26223
  const cfg = b();
25886
26224
  const max = limit ?? 10;
25887
26225
  const fast = fastSearch ?? false;
@@ -25889,13 +26227,26 @@ ${block}`), d);
25889
26227
  const tokens = queryTokens(query);
25890
26228
  const domSlug = domain ? slugify(domain) : null;
25891
26229
  const kindSet = kinds?.length ? new Set(kinds) : null;
25892
- 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, {
25893
26237
  ancestorNoteId: cfg.root,
25894
26238
  limit: 30,
25895
26239
  fastSearch: useFast,
25896
26240
  includeArchivedNotes: includeArchived ?? false,
25897
26241
  ...ord ?? {}
25898
- }).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
+ });
25899
26250
  const filterNote = (note) => {
25900
26251
  const k = labelOf2(note, "noteType");
25901
26252
  if (!k)
@@ -25904,9 +26255,25 @@ ${block}`), d);
25904
26255
  return false;
25905
26256
  if (domSlug && labelOf2(note, "domain") !== domSlug)
25906
26257
  return false;
26258
+ if (!withinModifiedWindow(note.dateModified, since, until))
26259
+ return false;
25907
26260
  return true;
25908
26261
  };
25909
- 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) => {
25910
26277
  const relations = relationSnippet(note);
25911
26278
  const base = {
25912
26279
  id: note.noteId,
@@ -25915,11 +26282,13 @@ ${block}`), d);
25915
26282
  status: labelOf2(note, "status"),
25916
26283
  updated: note.dateModified.slice(0, 10),
25917
26284
  ...hasLabel(note, "archived") ? { archived: true } : {},
25918
- ...relations ? { relations } : {}
26285
+ ...relations ? { relations } : {},
26286
+ ...matchedBy ? { matchedBy } : {}
25919
26287
  };
25920
26288
  if (i < 3) {
25921
26289
  const content = await trilium.getNoteContent(note.noteId).catch(() => "");
25922
- return { ...base, snippet: toText(content, 280) };
26290
+ const evidence = matchedBy ? evidenceFor(note, content, matchedBy) : null;
26291
+ return { ...base, snippet: toText(content, 280), ...evidence ? { evidence } : {} };
25923
26292
  }
25924
26293
  return base;
25925
26294
  };
@@ -25942,11 +26311,7 @@ ${block}`), d);
25942
26311
  let recordHits = 0;
25943
26312
  const evidenceIn = (text) => {
25944
26313
  const m = new RegExp(re.source, "i").exec(text);
25945
- if (!m)
25946
- return "";
25947
- const from = Math.max(0, m.index - 80);
25948
- const to = Math.min(text.length, m.index + m[0].length + 80);
25949
- 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) : "";
25950
26315
  };
25951
26316
  for (const n of candidates) {
25952
26317
  if (confirmed.length >= max)
@@ -25971,49 +26336,60 @@ ${block}`), d);
25971
26336
  }
25972
26337
  const results = await Promise.all(confirmed.map(async ({ note, evidence }, i) => {
25973
26338
  const { snippet: _snippet, ...base } = await buildResult(note, i);
25974
- return { ...base, ...evidence ? { evidence } : {} };
26339
+ return { ...base, ...evidence ? { evidence } : {}, matchedBy: "regex" };
25975
26340
  }));
26341
+ const failed = strategyErrors > 0 && results.length === 0;
25976
26342
  const notes = [
25977
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,
25978
26344
  tagSpanning ? `${tagSpanning} match(es) were found only after stripping markup \u2014 the phrase is split by an inline tag there.` : null,
25979
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,
25980
26346
  recordHits ? `${recordHits} result(s) are records (sessions, diary, dated thread entries) \u2014 included by default; kinds= narrows to maintained notes.` : null,
25981
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,
25982
- 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,
25983
- 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
25984
26351
  ].filter(Boolean);
25985
26352
  return txt3({
25986
26353
  mode: "regex",
25987
26354
  pattern: regex,
25988
26355
  results,
26356
+ ...failed ? { searchFailed: true } : {},
25989
26357
  ...notes.length ? { note: notes.join(" ") } : {}
25990
26358
  });
25991
26359
  }
25992
26360
  if (orderBy) {
25993
26361
  const ord = { orderBy, orderDirection: orderDirection ?? (orderBy === "title" ? "asc" : "desc") };
25994
26362
  const q = query.trim() ? escapeQueryValue(query) : "#noteType";
25995
- const notes = await run(q, fast, ord);
26363
+ const notes = await run(q, fast, "ordered", ord);
25996
26364
  const filtered = notes.filter(filterNote).slice(0, max);
25997
- const results = await Promise.all(filtered.map(buildResult));
25998
- 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
+ });
25999
26373
  }
26000
26374
  const scores = new Map;
26001
- const add = (notes, weight) => {
26375
+ const add = (notes, weight, strategy) => {
26002
26376
  for (const n of notes) {
26003
26377
  const entry = scores.get(n.noteId);
26004
- if (entry)
26378
+ if (entry) {
26005
26379
  entry.score += weight;
26006
- else
26007
- 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
+ }
26008
26384
  }
26009
26385
  };
26010
26386
  const [byLabel, byTitleRaw, byText] = await Promise.all([
26011
- slug.length >= 3 ? run(`#topic='${slug}' OR #domain='${slug}'`, true) : Promise.resolve([]),
26012
- tokens.length ? run(tokens.map((t) => `note.title *=* '${escapeQueryValue(t)}'`).join(" OR "), fast || undefined) : Promise.resolve([]),
26013
- 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")
26014
26390
  ]);
26015
- add(byLabel, 3);
26016
- add(byText, 1);
26391
+ add(byLabel, 3, "label");
26392
+ add(byText, 1, "text");
26017
26393
  const queryKey = titleKey(query);
26018
26394
  for (const n of byTitleRaw) {
26019
26395
  const lowerTitle = n.title.toLowerCase();
@@ -26026,18 +26402,18 @@ ${block}`), d);
26026
26402
  weight += 8;
26027
26403
  else if (queryKey.startsWith(noteKey) || noteKey.startsWith(queryKey))
26028
26404
  weight += 4;
26029
- add([n], weight);
26405
+ add([n], weight, "title");
26030
26406
  }
26031
26407
  const EXACT_ENOUGH = 5;
26032
26408
  const fuzzyTokens = tokens.filter((t) => t.length >= 3);
26033
26409
  const wantFuzzy = fuzzy ?? (scores.size < EXACT_ENOUGH && fuzzyTokens.length > 0);
26034
26410
  const fuzzyIds = new Set;
26035
26411
  if (wantFuzzy && fuzzyTokens.length) {
26036
- 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");
26037
26413
  for (const n of byFuzzy)
26038
26414
  if (!scores.has(n.noteId))
26039
26415
  fuzzyIds.add(n.noteId);
26040
- add(byFuzzy, 0.5);
26416
+ add(byFuzzy, 0.5, "fuzzy");
26041
26417
  }
26042
26418
  const now = Date.now();
26043
26419
  const recency = (n) => {
@@ -26045,15 +26421,23 @@ ${block}`), d);
26045
26421
  return Number.isFinite(age) ? 0.9 * Math.pow(0.5, age / 14) : 0;
26046
26422
  };
26047
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);
26048
- const results = await Promise.all(ranked.map(async ({ note }, i) => {
26049
- 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));
26050
26426
  return fuzzyIds.has(note.noteId) ? { ...row, fuzzy: true } : row;
26051
26427
  }));
26052
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);
26053
26436
  return txt3({
26054
26437
  results,
26055
26438
  ...fuzzyCount ? { fuzzyMatches: `${fuzzyCount} result(s) came from the fuzzy pass (marked fuzzy: true) \u2014 treat them as leads to verify, not as answers.` } : {},
26056
- ...results.length === 0 ? noMatch : {}
26439
+ ...failed ? { searchFailed: true } : {},
26440
+ ...notices.length ? { note: notices.join(" ") } : {}
26057
26441
  });
26058
26442
  });
26059
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.`, {
@@ -26110,7 +26494,7 @@ ${block}`), d);
26110
26494
  groups[kind] = [];
26111
26495
  const relations = relationSnippet(n);
26112
26496
  const idle = idleSince(n.dateModified);
26113
- 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);
26114
26498
  if (stale)
26115
26499
  staleCount++;
26116
26500
  groups[kind].push({
@@ -26155,21 +26539,49 @@ ${block}`), d);
26155
26539
  });
26156
26540
  });
26157
26541
  const READ_BUDGET = 80000;
26158
- 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.`, {
26159
26543
  ids: arrayType(stringType()).min(1).max(10).describe("Note ids to read \u2014 one body per id, one round trip (cap 10)"),
26160
- text: booleanType().optional().describe("Return readable plain text instead of stored HTML (default false)")
26161
- }, 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 }) => {
26162
26548
  const fetched = await Promise.all(ids.map(async (id) => {
26163
26549
  try {
26164
26550
  const note = await trilium.getNote(id);
26165
26551
  const contentResult = await trilium.getNoteContentResult(id, note.type);
26166
26552
  const relations = relationSnippet(note);
26167
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
+ }
26168
26582
  const content = text && typeof contentResult === "string" ? readableText(raw) : raw;
26169
26583
  return {
26170
- id,
26171
- title: note.title,
26172
- kind: ownedLabel(note, "noteType") ?? undefined,
26584
+ ...identity,
26173
26585
  content,
26174
26586
  ...typeof contentResult === "string" ? {} : { contentEncoding: contentResult.encoding, mime: contentResult.mime },
26175
26587
  ...relations ? { relations } : {}
@@ -26193,7 +26605,7 @@ ${block}`), d);
26193
26605
  return txt3({
26194
26606
  count: notes.length,
26195
26607
  notes,
26196
- ...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=.` } : {}
26197
26609
  });
26198
26610
  });
26199
26611
  const reviseDomain = async (name, plan, dry, d) => {
@@ -26268,12 +26680,12 @@ ${block}`), d);
26268
26680
  };
26269
26681
  server.tool("revise", `Edit a note by id. A revision is taken first; "Last updated" lines are bumped on content writes.
26270
26682
 
26271
- 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.
26272
26684
 
26273
- 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=).`, {
26274
26686
  noteId: stringType().optional().describe("Note to update (omit only with domain=)"),
26275
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"),
26276
- 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"),
26277
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)."),
26278
26690
  title: stringType().optional().describe("New title (normalized server-side)"),
26279
26691
  section: stringType().optional().describe("Target a section by heading text (h2/h3/h4, in that order); omit for whole-note append/replace"),
@@ -26282,11 +26694,17 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26282
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)"),
26283
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."),
26284
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"),
26285
- 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'),
26286
26698
  closure: objectType({
26287
- thread: stringType().describe('The dated thread to record the closure in: its id or title (e.g. "Escalations")'),
26288
- body: stringType().describe("The closure: what settled it, who ruled, and where its durable substance went")
26289
- }).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"}'),
26290
26708
  edits: arrayType(objectType({
26291
26709
  find: stringType().describe("Exact raw string to replace"),
26292
26710
  body: stringType().describe("Raw replacement string"),
@@ -26295,20 +26713,30 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26295
26713
  identity: stringType().optional().describe('Appending to a dated thread: the identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" (required)'),
26296
26714
  icon: stringType().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name; normalized server-side'),
26297
26715
  date: stringType().optional().describe("ISO date (default: today)")
26298
- }, async ({ noteId: noteIdArg, domain, dryRun, body, title, section, occurrence, mode, find, nth, within, closure, edits, identity, icon, date, strict }) => {
26299
- if (closure && !(within && mode === "remove"))
26300
- 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
+ }
26301
26729
  if (domain !== undefined) {
26302
26730
  if (noteIdArg)
26303
26731
  return err("conflicting_params", "Pass noteId= or domain=, not both.", "domain= edits every note in the domain; noteId= edits one.");
26304
26732
  if (find === undefined && edits === undefined)
26305
26733
  return err("missing_param", "domain= needs find= (with body=) or edits=.", 'e.g. revise(domain="myClerkBook", find="Code Priority Queue", body="Backlog")');
26306
- if (within || section || title || mode || identity || icon)
26734
+ if (within || section || title || mode || identity || icon || append)
26307
26735
  return err("conflicting_params", "domain= takes only find=/body=, edits=, nth= and dryRun=.", "Use noteId= for element, section, title or icon edits.");
26308
26736
  return reviseDomain(domain, edits ?? [{ find, body: body ?? "", nth }], dryRun ?? true, checkedDate(date));
26309
26737
  }
26310
- if (dryRun !== undefined)
26311
- 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.");
26312
26740
  if (!noteIdArg)
26313
26741
  return err("missing_param", "revise needs noteId= (or domain= for a domain-wide find/replace).", "Pass the id of the note to edit.");
26314
26742
  const noteId = noteIdArg;
@@ -26316,6 +26744,12 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26316
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.");
26317
26745
  if (within && find === undefined)
26318
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
+ }
26319
26753
  const d = checkedDate(date);
26320
26754
  const note = await trilium.getNote(noteId);
26321
26755
  const noteKind = labelOf2(note, "noteType");
@@ -26346,6 +26780,40 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26346
26780
  }
26347
26781
  return { retitled: cleanTitle, cascaded };
26348
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
+ }
26349
26817
  if (find !== undefined || edits !== undefined) {
26350
26818
  if (find !== undefined && edits !== undefined)
26351
26819
  return err("conflicting_params", "Pass either find= or edits=, not both.", "Fold the single find/body pair into the edits array.");
@@ -26404,31 +26872,89 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26404
26872
  date: d,
26405
26873
  reason: res.reason,
26406
26874
  hint,
26875
+ ...dryRun === true ? { dryRun: true } : {},
26407
26876
  ...res.elements ? { elements: res.elements } : {},
26408
26877
  ...res.reason === "no_match" ? missContext(find, current) : {}
26409
26878
  });
26410
26879
  }
26411
- if (res.html === current)
26880
+ if (res.html === current && dryRun !== true)
26412
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." });
26413
26882
  let closureThread = null;
26414
26883
  let closureBlock = "";
26884
+ let closureTarget = null;
26885
+ let closureCells = [];
26886
+ let closurePadded = 0;
26415
26887
  if (closure) {
26416
- closureThread = /^[A-Za-z0-9]{12}$/.test(closure.thread) ? await trilium.getNote(closure.thread).catch(() => null) : await findExisting("thread", closure.thread);
26417
- if (!closureThread || ownedLabel(closureThread, "noteType") !== "thread")
26418
- 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".`);
26419
- if (isCollectionThread(closureThread))
26420
- return collectionAppendRefusal(closureThread);
26421
- const rendered = renderBody(closure.body).html;
26422
- const removedText = toText(res.element, 400);
26423
- 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>
26424
26924
  ${rendered}`;
26425
- if (!identity)
26426
- return err("missing_identity", "closure= appends to a dated thread, which needs identity=; nothing was written.", 'Pass identity="LLM \xB7 environment \xB7 agent/mode".');
26427
- const badIdentity = identityRefusal(identity, closureHtml);
26428
- if (badIdentity)
26429
- return badIdentity;
26430
- 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>
26431
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
+ });
26432
26958
  }
26433
26959
  await trilium.createRevision(noteId).catch(() => null);
26434
26960
  const withinResult = sanitizeHtml(res.html);
@@ -26446,14 +26972,47 @@ ${closureHtml}`;
26446
26972
  await trilium.updateLabelValue(closureThread.noteId, "updated", d);
26447
26973
  if (labelOf2(closureThread, "status") === "dormant")
26448
26974
  await trilium.updateLabelValue(closureThread.noteId, "status", "active");
26975
+ const closureHeading = leadingIdentityText(closureBlock);
26449
26976
  closureReceipt = {
26450
26977
  threadId: closureThread.noteId,
26451
26978
  thread: closureThread.title,
26452
26979
  entryId: entry.noteId,
26453
26980
  action: entry.action,
26981
+ ...entry.marker ? { entryMarker: entry.marker } : {},
26982
+ ...closureHeading ? { runHeading: closureHeading } : {},
26454
26983
  next: "Run consistency() on any fact the closed row asserted elsewhere, so no stale sentence survives it."
26455
26984
  };
26456
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
+ }
26457
27016
  return txt3({
26458
27017
  ok: true,
26459
27018
  noteId,
@@ -26477,11 +27036,12 @@ ${closureHtml}`;
26477
27036
  const applied = applyFindEdit(working, e.find, e.body, e.nth);
26478
27037
  if (!applied) {
26479
27038
  const alreadyConsumed = consumed.has(e.find);
27039
+ const nthOvershot = !alreadyConsumed && e.nth !== undefined && working.includes(e.find);
26480
27040
  results.push({
26481
27041
  find: e.find,
26482
27042
  replaced: 0,
26483
- hint: missHint(e.find, current, alreadyConsumed),
26484
- ...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)
26485
27045
  });
26486
27046
  continue;
26487
27047
  }
@@ -26497,7 +27057,7 @@ ${closureHtml}`;
26497
27057
  mode: edits ? "edits" : "find-replace",
26498
27058
  replaced: 0,
26499
27059
  date: d,
26500
- ...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.` } : {
26501
27061
  hint: results[0].hint,
26502
27062
  ...results[0].matchedUpTo ? { matchedUpTo: results[0].matchedUpTo } : {},
26503
27063
  ...results[0].storedNearby ? { storedNearby: results[0].storedNearby } : {}
@@ -26593,6 +27153,7 @@ ${closureHtml}`;
26593
27153
  let finalContent = null;
26594
27154
  let sectionMiss = {};
26595
27155
  let threadEntryResult = null;
27156
+ let threadRunHeading = null;
26596
27157
  if (body) {
26597
27158
  const sanitized = renderBody(body);
26598
27159
  const html = sanitized.html;
@@ -26643,6 +27204,7 @@ ${closureHtml}`;
26643
27204
  return badIdentity;
26644
27205
  const block = identity && !leadingIdentification(html) ? `<h3>${escapeHtml(identity)}</h3>
26645
27206
  ${html}` : html;
27207
+ threadRunHeading = leadingIdentityText(block);
26646
27208
  threadEntryResult = await appendThreadEntry(noteId, block, d);
26647
27209
  if (threadEntryResult.action === "already_written") {
26648
27210
  return txt3({ ok: true, noteId, mode: "already_written", entryId: threadEntryResult.noteId, date: d });
@@ -26679,7 +27241,7 @@ ${block}`), d).html;
26679
27241
  } : {},
26680
27242
  ...sectionMiss,
26681
27243
  ...sectionHint ? { hint: sectionHint } : {},
26682
- ...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` } : {},
26683
27245
  ...titled.retitled ? { retitled: titled.retitled } : {},
26684
27246
  ...titled.cascaded ? { domainCascade: `#domain updated on ${titled.cascaded} descendant note(s)` } : {},
26685
27247
  ...structuralFindings(noteKind, finalContent),
@@ -26951,7 +27513,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
26951
27513
  }
26952
27514
  }
26953
27515
  });
26954
- 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.`, {
26955
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)."),
26956
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"),
26957
27519
  caseSensitive: booleanType().optional().describe('Match case exactly (default false: "Blueprints" and "blueprints" both match)'),
@@ -27056,6 +27618,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
27056
27618
  clauses.push(`#domain='${slugify(domain)}'`);
27057
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(() => []);
27058
27620
  const scoped = notes.filter(inScope).slice(0, max);
27621
+ const noteValues = new Map;
27059
27622
  const byValue = new Map;
27060
27623
  const noCapture = [];
27061
27624
  let hasCaptureGroup = false;
@@ -27088,15 +27651,19 @@ calling twice is safe. Use remove=true to delete an edge.`, {
27088
27651
  const seen = new Set;
27089
27652
  for (const value of [...raw.values, ...projected.values]) {
27090
27653
  hasCaptureGroup = true;
27091
- if (seen.has(value))
27092
- continue;
27093
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) {
27094
27663
  if (!byValue.has(value))
27095
27664
  byValue.set(value, []);
27096
27665
  byValue.get(value).push(stub);
27097
27666
  }
27098
- if ((raw.matched || projected.matched) && !seen.size)
27099
- noCapture.push(stub);
27100
27667
  }
27101
27668
  if (!hasCaptureGroup) {
27102
27669
  return txt3({
@@ -27112,7 +27679,18 @@ calling twice is safe. Use remove=true to delete an edge.`, {
27112
27679
  }
27113
27680
  const groups = [...byValue.entries()].map(([value, notes]) => ({ value, count: notes.length, notes })).sort((a, b) => b.count - a.count);
27114
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 })) : [];
27115
- 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;
27116
27694
  return txt3({
27117
27695
  mode: "consistency",
27118
27696
  pattern,
@@ -27129,7 +27707,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
27129
27707
  ...staleAfterDays ? { staleAfterDays } : {},
27130
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.` } : {},
27131
27709
  ...noCapture.length ? { matchedWithoutValue: noCapture } : {},
27132
- 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.`
27133
27711
  });
27134
27712
  }
27135
27713
  });
@@ -27323,7 +27901,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
27323
27901
  };
27324
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.
27325
27903
 
27326
- 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.`, {
27327
27905
  assertion: stringType().optional().describe('The claim in plain words, e.g. "the parse pipeline runs before validation" \u2014 also the dedup key'),
27328
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"),
27329
27907
  noteId: stringType().optional().describe("The note this claim is made in \u2014 wired ~derivedFrom so the claim and its source stay linked"),
@@ -27332,7 +27910,7 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
27332
27910
  holds: booleanType().optional().describe("With claimId: did the check pass? false marks the claim broken and surfaces it in maintain()"),
27333
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"),
27334
27912
  status: enumType(["holding", "broken", "lapsed", "all"]).optional().describe("List filter (default: all)"),
27335
- 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")
27336
27914
  }, async ({ assertion, check, noteId, intervalDays, claimId, holds, evidence, status, retire }) => {
27337
27915
  const cfg = b();
27338
27916
  if (!cfg.root)
@@ -27358,7 +27936,7 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
27358
27936
  const note = await trilium.getNote(claimId).catch(() => null);
27359
27937
  if (!note || labelOf2(note, "noteType") !== "claim")
27360
27938
  return err("not_found", `${claimId} is not a claim.`, "List them with claim() and no arguments.");
27361
- if (retire) {
27939
+ if (retire && holds === undefined) {
27362
27940
  await trilium.addLabel(claimId, "archived", "").catch(() => null);
27363
27941
  return txt3({ ok: true, claimId, action: "retired", assertion: note.title });
27364
27942
  }
@@ -27376,11 +27954,14 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
27376
27954
  await trilium.updateLabelValue(claimId, "verified", d).catch(() => null);
27377
27955
  await trilium.updateLabelValue(claimId, "claimState", holds ? "holding" : "broken").catch(() => null);
27378
27956
  await trilium.updateLabelValue(claimId, "updated", d).catch(() => null);
27957
+ if (retire)
27958
+ await trilium.addLabel(claimId, "archived", "").catch(() => null);
27379
27959
  const refreshed = await trilium.getNote(claimId).catch(() => note);
27380
27960
  return txt3({
27381
27961
  ok: true,
27382
27962
  action: holds ? "verified" : "marked-broken",
27383
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." } : {},
27384
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." }
27385
27966
  });
27386
27967
  }
@@ -27921,7 +28502,8 @@ and every thread day-child, in time order with its identification line.`, {
27921
28502
  const todaySess = await trilium.searchNotes(`#noteType=session #created='${todayStr}'`, { ancestorNoteId: cfg.memory.sessions, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
27922
28503
  if (todaySess.results[0]) {
27923
28504
  const content = await trilium.getNoteContent(todaySess.results[0].noteId).catch(() => "");
27924
- 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);
27925
28507
  }
27926
28508
  const sessions = await trilium.searchNotes("#noteType=session", {
27927
28509
  ancestorNoteId: cfg.memory.sessions,
@@ -27941,6 +28523,7 @@ and every thread day-child, in time order with its identification line.`, {
27941
28523
  previousLog = { id: log.results[0].noteId, content: await trilium.getNoteContent(log.results[0].noteId).catch(() => "") };
27942
28524
  }
27943
28525
  let changes = [];
28526
+ let changesTruncated;
27944
28527
  if (lastSession) {
27945
28528
  try {
27946
28529
  const history = await trilium.getNoteHistory(cfg.root);
@@ -27950,12 +28533,14 @@ and every thread day-child, in time order with its identification line.`, {
27950
28533
  if (!prevEntry || h.current_isDeleted && !prevEntry.current_isDeleted)
27951
28534
  deduped.set(h.noteId, h);
27952
28535
  }
27953
- changes = [...deduped.values()].slice(0, 25).map((h) => ({
28536
+ const capped = capChanges([...deduped.values()].map((h) => ({
27954
28537
  id: h.noteId,
27955
28538
  title: h.current_title,
27956
28539
  changed: h.date.slice(0, 10),
27957
28540
  ...h.current_isDeleted ? { deleted: true } : {}
27958
- }));
28541
+ })));
28542
+ changes = capped.changes;
28543
+ changesTruncated = capped.changesTruncated;
27959
28544
  } catch {}
27960
28545
  }
27961
28546
  return txt3({
@@ -27964,6 +28549,7 @@ and every thread day-child, in time order with its identification line.`, {
27964
28549
  lastSession,
27965
28550
  previousLog,
27966
28551
  changes: changes.length ? changes : undefined,
28552
+ ...changesTruncated ? { changesTruncated } : {},
27967
28553
  next: [
27968
28554
  "Skim lastSession, previousLog and changes for evidenced outputs \u2014 never plausibility.",
27969
28555
  "Present what moved in the first message."
@@ -28231,8 +28817,8 @@ and every thread day-child, in time order with its identification line.`, {
28231
28817
  tool.handler = async (...args) => {
28232
28818
  const result = await inner(...args);
28233
28819
  if (!wroteSinceClose && wroteContent(result)) {
28234
- await markStep("write").catch(() => null);
28235
28820
  wroteSinceClose = true;
28821
+ await markWriteStep();
28236
28822
  }
28237
28823
  return result;
28238
28824
  };
@@ -28245,7 +28831,7 @@ function wroteContent(result) {
28245
28831
  return true;
28246
28832
  try {
28247
28833
  const r = JSON.parse(text);
28248
- 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)
28249
28835
  return false;
28250
28836
  if (r.replaced === 0 && !r.closure)
28251
28837
  return false;
@@ -30214,7 +30800,7 @@ function createServer(origin = null) {
30214
30800
  const s = new McpServer({
30215
30801
  name: BRAND2.name,
30216
30802
  title: BRAND2.name,
30217
- version: "12.9.1",
30803
+ version: "13.0.0",
30218
30804
  description: BRAND2.description,
30219
30805
  websiteUrl: BRAND2.website,
30220
30806
  icons: brandIcons(origin)