brainllm 10.2.0 → 10.4.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
@@ -20040,7 +20040,8 @@ var Kinds = [
20040
20040
  "domain",
20041
20041
  "information",
20042
20042
  "sources",
20043
- "log"
20043
+ "log",
20044
+ "claim"
20044
20045
  ];
20045
20046
  var KIND_AREA = {
20046
20047
  biography: "master",
@@ -20056,7 +20057,8 @@ var KIND_AREA = {
20056
20057
  domain: "knowledge",
20057
20058
  information: "knowledge",
20058
20059
  sources: "knowledge",
20059
- log: "insights"
20060
+ log: "insights",
20061
+ claim: "insights"
20060
20062
  };
20061
20063
  var SingletonKinds = [
20062
20064
  "biography",
@@ -20735,8 +20737,8 @@ function decodeEntities(s) {
20735
20737
  }
20736
20738
  return prev;
20737
20739
  }
20738
- function escapeHtml(s) {
20739
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
20740
+ function escapeHtml(s, preserveEntities = true) {
20741
+ return s.replace(preserveEntities ? /&(?![a-zA-Z][a-zA-Z0-9]*;|#\d+;|#x[0-9a-fA-F]+;)/g : /&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
20740
20742
  }
20741
20743
  var SUFFIX_STATUS = [
20742
20744
  [/\s*[\u2014\u2013\-:(]\s*(?:partially|partly)\s+(?:resolved|answered|done)\s*\)?\s*$/i, "active"],
@@ -20803,6 +20805,9 @@ function decodeEncodedHtml(body) {
20803
20805
  s = decodeEntitiesOnce(s);
20804
20806
  return s.replace(/&(?![a-zA-Z]+;|#\d+;|#x[0-9a-fA-F]+;)/g, "&amp;");
20805
20807
  }
20808
+ function repairDoubleEscaping(html) {
20809
+ return html.replace(/&amp;((?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[0-9a-fA-F]+);)/g, (_, entity) => `&${entity}`);
20810
+ }
20806
20811
  function inlineMd(escaped) {
20807
20812
  return escaped.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/(^|[\s(])\*([^*\s][^*]*)\*/g, "$1<em>$2</em>").replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2">$1</a>');
20808
20813
  }
@@ -20854,7 +20859,7 @@ function toHtml(body) {
20854
20859
  if (code !== null) {
20855
20860
  if (/^```/.test(line)) {
20856
20861
  out.push(`<pre><code>${escapeHtml(code.join(`
20857
- `))}</code></pre>`);
20862
+ `), false)}</code></pre>`);
20858
20863
  code = null;
20859
20864
  } else {
20860
20865
  code.push(line);
@@ -20908,7 +20913,7 @@ function toHtml(body) {
20908
20913
  }
20909
20914
  if (code !== null)
20910
20915
  out.push(`<pre><code>${escapeHtml(code.join(`
20911
- `))}</code></pre>`);
20916
+ `), false)}</code></pre>`);
20912
20917
  flushTable();
20913
20918
  flushPara();
20914
20919
  flushList();
@@ -20924,9 +20929,94 @@ function toText(html, maxLength = 300) {
20924
20929
  const lastSpace = cut.lastIndexOf(" ");
20925
20930
  return (lastSpace > maxLength * 0.6 ? cut.slice(0, lastSpace) : cut) + "\u2026";
20926
20931
  }
20932
+ function addendumIndex(html) {
20933
+ const markerRe = /<h2(?:\s[^>]*)?>\s*((?:Addendum|Withdrawn|Recovered|Reopened)[^<]*)<\/h2>/gi;
20934
+ const out = [];
20935
+ const hits = [...html.matchAll(markerRe)];
20936
+ for (const [i, m] of hits.entries()) {
20937
+ const from = m.index + m[0].length;
20938
+ const to = i + 1 < hits.length ? hits[i + 1].index : html.length;
20939
+ const body = html.slice(from, to);
20940
+ const idMatch = body.match(/<h3(?:\s[^>]*)?>([\s\S]*?)<\/h3>/i);
20941
+ const identity = idMatch ? decodeEntities(idMatch[1].replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim() : undefined;
20942
+ out.push({
20943
+ marker: decodeEntities(m[1]).replace(/\s+/g, " ").trim(),
20944
+ ...identity ? { identity } : {},
20945
+ lead: toText(idMatch ? body.slice(idMatch.index + idMatch[0].length) : body, 120)
20946
+ });
20947
+ }
20948
+ return out;
20949
+ }
20950
+ function nearestContext(source, needle, radius = 40) {
20951
+ if (!source || needle.length < 4)
20952
+ return null;
20953
+ const longestFrom = (take) => {
20954
+ let lo = 4, hi = needle.length, best2 = null;
20955
+ while (lo <= hi) {
20956
+ const mid = lo + hi >> 1;
20957
+ const frag = take(mid);
20958
+ const at = source.indexOf(frag);
20959
+ if (at !== -1) {
20960
+ best2 = { frag, at };
20961
+ lo = mid + 1;
20962
+ } else {
20963
+ hi = mid - 1;
20964
+ }
20965
+ }
20966
+ return best2;
20967
+ };
20968
+ const prefix = longestFrom((n) => needle.slice(0, n));
20969
+ const suffix = longestFrom((n) => needle.slice(needle.length - n));
20970
+ const best = !prefix ? suffix : !suffix ? prefix : suffix.frag.length > prefix.frag.length ? suffix : prefix;
20971
+ if (!best)
20972
+ return null;
20973
+ const from = Math.max(0, best.at - radius);
20974
+ const to = Math.min(source.length, best.at + best.frag.length + radius);
20975
+ return {
20976
+ fragment: best.frag,
20977
+ context: `${from > 0 ? "\u2026" : ""}${source.slice(from, to)}${to < source.length ? "\u2026" : ""}`,
20978
+ index: best.at
20979
+ };
20980
+ }
20981
+ var BLOCK_BOUNDARY = /^<\/?(?:p|div|h[1-6]|li|ul|ol|tr|td|th|table|thead|tbody|blockquote|pre|hr|br|section|figure|figcaption)\b/i;
20982
+ function stripTagsWithMap(html) {
20983
+ const chars = [];
20984
+ const map2 = [];
20985
+ const emit = (s, at) => {
20986
+ for (const ch of s) {
20987
+ chars.push(ch);
20988
+ map2.push(at);
20989
+ }
20990
+ };
20991
+ const token = /<[^>]*>|&(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[0-9a-fA-F]+);/g;
20992
+ let cursor = 0;
20993
+ let m;
20994
+ while ((m = token.exec(html)) !== null) {
20995
+ for (let i = cursor;i < m.index; i++) {
20996
+ chars.push(html[i]);
20997
+ map2.push(i);
20998
+ }
20999
+ if (m[0].startsWith("<")) {
21000
+ if (BLOCK_BOUNDARY.test(m[0]))
21001
+ emit(" ", m.index);
21002
+ } else {
21003
+ const decoded = decodeEntitiesOnce(m[0]);
21004
+ emit(decoded === m[0] ? m[0] : decoded, m.index);
21005
+ }
21006
+ cursor = m.index + m[0].length;
21007
+ }
21008
+ for (let i = cursor;i < html.length; i++) {
21009
+ chars.push(html[i]);
21010
+ map2.push(i);
21011
+ }
21012
+ return { text: chars.join(""), map: map2 };
21013
+ }
20927
21014
  function escapeQueryValue(s) {
20928
21015
  return s.replace(/["\\]/g, " ").replace(/\s+/g, " ").trim();
20929
21016
  }
21017
+ function escapeQueryRegex(s) {
21018
+ return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
21019
+ }
20930
21020
  function queryTokens(query, max = 4) {
20931
21021
  const STOP = new Set(["the", "and", "for", "with", "that", "this", "what", "when", "where", "how", "why", "are", "was", "were", "from", "into", "about", "over", "under", "does", "did", "has", "have", "had", "its", "his", "her", "their", "our", "your", "you", "not"]);
20932
21022
  return [...new Set(decodeEntities(query).toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((t) => t.length > 2 && !STOP.has(t)))].slice(0, max);
@@ -21158,13 +21248,14 @@ function headingOutline(html) {
21158
21248
  let m;
21159
21249
  while ((m = re.exec(html)) !== null) {
21160
21250
  const level = Number(m[1]);
21161
- const text = decodeEntities(m[2].replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim();
21251
+ const raw = m[2];
21252
+ const text = decodeEntities(raw.replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim();
21162
21253
  if (!text)
21163
21254
  continue;
21164
21255
  const key = `${level}::${text.toLowerCase()}`;
21165
21256
  const occurrence = (seen.get(key) ?? 0) + 1;
21166
21257
  seen.set(key, occurrence);
21167
- out.push({ level, text, occurrence });
21258
+ out.push({ level, text, occurrence, ...raw.trim() !== text ? { raw: raw.trim() } : {} });
21168
21259
  }
21169
21260
  return out;
21170
21261
  }
@@ -21172,41 +21263,92 @@ function sectionLevelFor(html) {
21172
21263
  const levels = headingOutline(html).map((h) => h.level);
21173
21264
  return levels.length ? Math.min(...levels) : 2;
21174
21265
  }
21175
- function setSection(html, heading, content, mode, occurrence = 1) {
21176
- html = closeDangling(html);
21177
- const targetKey = decodeEntities(heading.replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim().toLowerCase();
21178
- for (const level2 of [2, 3, 4]) {
21179
- const tag = `h${level2}`;
21266
+ function locateSection(html, heading, occurrence = 1) {
21267
+ const key = (s) => decodeEntities(s.replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim().toLowerCase();
21268
+ const targetKey = key(heading);
21269
+ for (const level of [2, 3, 4]) {
21270
+ const tag = `h${level}`;
21180
21271
  const globalRe = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`, "gi");
21181
- const hits = [...html.matchAll(globalRe)].filter((m) => decodeEntities(m[1].replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim().toLowerCase() === targetKey);
21272
+ const hits = [...html.matchAll(globalRe)].filter((m) => key(m[1]) === targetKey);
21182
21273
  if (!hits.length)
21183
21274
  continue;
21184
21275
  const headingCount = hits.length;
21185
21276
  const match = hits[Math.min(Math.max(occurrence, 1), headingCount) - 1];
21186
21277
  const headingStart = match.index;
21187
- const after = headingStart + match[0].length;
21278
+ const contentStart = headingStart + match[0].length;
21279
+ const nextMatch = html.slice(contentStart).search(new RegExp(`<h[2-${level}]`));
21280
+ return {
21281
+ level,
21282
+ headingStart,
21283
+ contentStart,
21284
+ end: nextMatch === -1 ? html.length : contentStart + nextMatch,
21285
+ headingCount,
21286
+ headingRaw: match[1]
21287
+ };
21288
+ }
21289
+ return null;
21290
+ }
21291
+ function getSection(html, heading, occurrence = 1) {
21292
+ const closed = closeDangling(html);
21293
+ const found = locateSection(closed, heading, occurrence);
21294
+ if (!found) {
21295
+ return { matched: false, content: "", available: headingOutline(closed).map((h) => h.text) };
21296
+ }
21297
+ const content = closed.slice(found.contentStart, found.end).trim();
21298
+ return {
21299
+ matched: true,
21300
+ content,
21301
+ headingRaw: found.headingRaw,
21302
+ level: found.level,
21303
+ headingCount: found.headingCount,
21304
+ ...headingOutline(content).length ? { subsections: headingOutline(content).map((h) => h.text) } : {}
21305
+ };
21306
+ }
21307
+ function setSection(html, heading, content, mode, occurrence = 1) {
21308
+ html = closeDangling(html);
21309
+ const found = locateSection(html, heading, occurrence);
21310
+ if (found) {
21311
+ const { headingStart, contentStart, end, headingCount } = found;
21312
+ if (mode === "remove") {
21313
+ const removed = html.slice(contentStart, end).trim();
21314
+ return {
21315
+ html: `${html.slice(0, headingStart)}${html.slice(end)}`.replace(/\n{3,}/g, `
21316
+
21317
+ `),
21318
+ matched: true,
21319
+ headingCount,
21320
+ ...headingOutline(removed).length ? { replacedSubsections: headingOutline(removed).map((h) => h.text) } : {}
21321
+ };
21322
+ }
21188
21323
  if (mode === "before") {
21189
21324
  return { html: `${html.slice(0, headingStart)}${content}
21190
21325
  ${html.slice(headingStart)}`, matched: true, headingCount };
21191
21326
  }
21192
21327
  if (mode === "after") {
21193
- return { html: `${html.slice(0, after)}
21194
- ${content}${html.slice(after)}`, matched: true, headingCount };
21328
+ return { html: `${html.slice(0, contentStart)}
21329
+ ${content}${html.slice(contentStart)}`, matched: true, headingCount };
21195
21330
  }
21196
- const nextMatch = html.slice(after).search(new RegExp(`<h[2-${level2}]`));
21197
- const end = nextMatch === -1 ? html.length : after + nextMatch;
21198
- const existing = html.slice(after, end).trim();
21331
+ const existing = html.slice(contentStart, end).trim();
21199
21332
  const inner = mode === "append" && existing ? `${existing}
21200
21333
  ${content}` : content;
21201
21334
  return {
21202
- html: `${html.slice(0, after)}
21335
+ html: `${html.slice(0, contentStart)}
21203
21336
  ${inner}
21204
21337
  ${html.slice(end)}`,
21205
21338
  matched: true,
21206
- headingCount
21339
+ headingCount,
21340
+ ...mode === "replace" && headingOutline(existing).length ? { replacedSubsections: headingOutline(existing).map((h) => h.text) } : {}
21207
21341
  };
21208
21342
  }
21209
21343
  const level = sectionLevelFor(html);
21344
+ if (mode === "remove") {
21345
+ return {
21346
+ html,
21347
+ matched: false,
21348
+ headingCount: 0,
21349
+ available: headingOutline(html).map((h) => h.text)
21350
+ };
21351
+ }
21210
21352
  return {
21211
21353
  html: `${html}
21212
21354
  <h${level}>${heading}</h${level}>
@@ -21217,6 +21359,81 @@ ${content}`,
21217
21359
  available: headingOutline(html).map((h) => h.text)
21218
21360
  };
21219
21361
  }
21362
+ function splitByH3(html) {
21363
+ const re = /<h3(?:\s[^>]*)?>([\s\S]*?)<\/h3>/gi;
21364
+ const hits = [...html.matchAll(re)];
21365
+ if (!hits.length)
21366
+ return html.trim() ? [{ body: html.trim(), raw: html.trim() }] : [];
21367
+ const out = [];
21368
+ const lead = html.slice(0, hits[0].index).trim();
21369
+ if (lead)
21370
+ out.push({ body: lead, raw: lead });
21371
+ for (const [i, m] of hits.entries()) {
21372
+ const from = m.index + m[0].length;
21373
+ const to = i + 1 < hits.length ? hits[i + 1].index : html.length;
21374
+ out.push({
21375
+ headingText: decodeEntities(m[1].replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim(),
21376
+ body: html.slice(from, to).trim(),
21377
+ raw: html.slice(m.index, to).trim()
21378
+ });
21379
+ }
21380
+ return out;
21381
+ }
21382
+ function mergeUnderSection(current, incoming, parentHeading) {
21383
+ const groups = splitByH3(incoming);
21384
+ if (!groups.length)
21385
+ return { html: current, mergedInto: [], appended: [] };
21386
+ const parent = getSection(current, parentHeading);
21387
+ if (!parent.matched) {
21388
+ return { html: setSection(current, parentHeading, incoming, "append").html, mergedInto: [], appended: groups.flatMap((g) => g.headingText ? [g.headingText] : []) };
21389
+ }
21390
+ let section = parent.content;
21391
+ const mergedInto = [];
21392
+ const appended = [];
21393
+ const leftovers = [];
21394
+ for (const g of groups) {
21395
+ if (!g.headingText) {
21396
+ leftovers.push(g.raw);
21397
+ continue;
21398
+ }
21399
+ const key = g.headingText.toLowerCase();
21400
+ const exists = headingOutline(section).some((h) => h.level === 3 && h.text.toLowerCase() === key);
21401
+ if (exists && g.body) {
21402
+ section = setSection(section, g.headingText, g.body, "append").html;
21403
+ mergedInto.push(g.headingText);
21404
+ } else {
21405
+ leftovers.push(g.raw);
21406
+ appended.push(g.headingText);
21407
+ }
21408
+ }
21409
+ if (leftovers.length)
21410
+ section = `${section}
21411
+ ${leftovers.join(`
21412
+ `)}`;
21413
+ return { html: setSection(current, parentHeading, section, "replace").html, mergedInto, appended };
21414
+ }
21415
+ function sectionProfile(html) {
21416
+ const re = /<h([2-4])(?:\s[^>]*)?>([\s\S]*?)<\/h\1>/gi;
21417
+ const hits = [...html.matchAll(re)];
21418
+ const seen = new Map;
21419
+ const sizes = [];
21420
+ for (const [i, m] of hits.entries()) {
21421
+ const level = Number(m[1]);
21422
+ const text = decodeEntities(m[2].replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim();
21423
+ if (!text)
21424
+ continue;
21425
+ const key = `${level}::${text.toLowerCase()}`;
21426
+ seen.set(key, (seen.get(key) ?? 0) + 1);
21427
+ const from = m.index + m[0].length;
21428
+ const to = i + 1 < hits.length ? hits[i + 1].index : html.length;
21429
+ sizes.push({ text, level, chars: to - from });
21430
+ }
21431
+ return {
21432
+ count: sizes.length,
21433
+ largest: sizes.sort((a, b) => b.chars - a.chars).slice(0, 3),
21434
+ ambiguous: [...seen.entries()].filter(([, n]) => n > 1).map(([k]) => k.split("::")[1])
21435
+ };
21436
+ }
21220
21437
  function unbalancedTags(html) {
21221
21438
  const stack = [];
21222
21439
  const re = /<\/?([a-zA-Z][a-zA-Z0-9-]*)(?:\s[^>]*)?\/?>/g;
@@ -21351,7 +21568,7 @@ function isOpenResolutionOnly(html) {
21351
21568
  }
21352
21569
  function metaLine(parts) {
21353
21570
  const cleaned = parts.filter((p) => !!p && p.trim().length > 0);
21354
- return `<p><em>${cleaned.map(escapeHtml).join(" \xB7 ")}</em></p>
21571
+ return `<p><em>${cleaned.map((p) => escapeHtml(p)).join(" \xB7 ")}</em></p>
21355
21572
  <hr>`;
21356
21573
  }
21357
21574
  var STRUCTURED_SINGLETONS = new Set([
@@ -21369,7 +21586,8 @@ var REQUIRED_SECTIONS = {
21369
21586
  goals: ["Near-term", "Long-term"],
21370
21587
  preferences: ["Communication", "Working style", "Tools and stack"],
21371
21588
  responsibilities: ["Core", "Current priorities"],
21372
- protocols: ["Operating", "Self-correction"]
21589
+ protocols: ["Operating", "Self-correction"],
21590
+ claim: ["Check", "Verifications"]
21373
21591
  };
21374
21592
  function missingSections(kind, html) {
21375
21593
  const required2 = REQUIRED_SECTIONS[kind];
@@ -21563,6 +21781,19 @@ var STRUCTURE_RULES = {
21563
21781
  log: {
21564
21782
  structure: ["Server header (log \xB7 date)", "**Created** / **Updated** / **Deleted** (h2) lists"],
21565
21783
  rules: ["Auto-generated by close(); regenerated in place, never stacked"]
21784
+ },
21785
+ claim: {
21786
+ structure: [
21787
+ "**Check** (h2) \u2014 how to verify the assertion, as inert text: a command, a query, a file path, a URL",
21788
+ "**Verifications** (h2) \u2014 one dated HOLDS/BROKEN line per check, with the evidence observed"
21789
+ ],
21790
+ rules: [
21791
+ 'Registered and verified only through claim() \u2014 remember(kind="claim") is rejected',
21792
+ "The title IS the assertion, and is the dedup key \u2014 re-registering the same assertion updates its check and keeps its history",
21793
+ "BrainLLM never executes the Check. It has no shell, and note content is data rather than instructions; the agent runs it and reports back",
21794
+ "Verifications are append-only \u2014 the history is the point, so this is one of the few places a dated stack is correct",
21795
+ "A verification without evidence is refused: a verdict alone is an assertion about an assertion"
21796
+ ]
21566
21797
  }
21567
21798
  };
21568
21799
  function structureRuleFor(kind) {
@@ -21605,6 +21836,8 @@ function kindHome(cfg, kind) {
21605
21836
  return "";
21606
21837
  case "log":
21607
21838
  return cfg.insights.logs;
21839
+ case "claim":
21840
+ return "";
21608
21841
  }
21609
21842
  }
21610
21843
  function dedupScope(cfg, kind) {
@@ -21717,6 +21950,8 @@ function locationLabel(kind, domainTitle) {
21717
21950
  return `Knowledge \u2192 Domains \u2192 ${domainTitle ?? "General"} \u2192 Sources`;
21718
21951
  case "log":
21719
21952
  return "Insights \u2192 Logs";
21953
+ case "claim":
21954
+ return "Insights \u2192 Claims";
21720
21955
  }
21721
21956
  }
21722
21957
 
@@ -21773,8 +22008,11 @@ function reviewKey(n) {
21773
22008
  return n.blobId ?? n.dateModified;
21774
22009
  }
21775
22010
  var STRUCTURE_LINT_LIMIT = 40;
22011
+ var SECTION_EDIT_RISK_COUNT = 25;
22012
+ var SECTION_EDIT_RISK_CHARS = 8000;
22013
+ var ALWAYS_FULL_SLOTS = new Set(["preferences", "protocols"]);
21776
22014
  async function sweep(trilium, cfg, opts = {}) {
21777
- const { deep = false, dryRun = false, ack } = opts;
22015
+ const { deep = false, dryRun = false, ack, repair } = opts;
21778
22016
  const policy = cfg.policy;
21779
22017
  const report = {
21780
22018
  scanned: 0,
@@ -21999,8 +22237,20 @@ async function sweep(trilium, cfg, opts = {}) {
21999
22237
  if (structure.unbalancedTags.length) {
22000
22238
  report.flagged.push(`unbalanced tags: ${n.title} [${n.noteId}] \u2014 <${structure.unbalancedTags.join(">, <")}> left open; the next write will auto-close them, or fix now with revise(mode=replace)`);
22001
22239
  }
22240
+ const profile = sectionProfile(content);
22241
+ const biggest = profile.largest[0];
22242
+ const crowded = profile.count >= SECTION_EDIT_RISK_COUNT;
22243
+ const lopsided = !!biggest && biggest.chars >= SECTION_EDIT_RISK_CHARS;
22244
+ if (crowded || lopsided) {
22245
+ const reasons = [
22246
+ crowded ? `${profile.count} sections` : null,
22247
+ lopsided ? `largest is '${biggest.text}' at ${Math.round(biggest.chars / 1000)}k characters` : null,
22248
+ profile.ambiguous.length ? `${profile.ambiguous.length} heading text(s) repeated ('${profile.ambiguous.slice(0, 3).join("', '")}') \u2014 those need occurrence= to target` : null
22249
+ ].filter(Boolean);
22250
+ report.flagged.push(`section-edit-risk: ${n.title} [${n.noteId}] \u2014 ${reasons.join("; ")}. ` + `Read outline(${n.noteId}) before any section= edit here, prefer find= for anything smaller than a section, and consider splitting the note.`);
22251
+ }
22002
22252
  }
22003
- await hygienePasses(trilium, cfg, report, { eligible, connectable, dryRun });
22253
+ await hygienePasses(trilium, cfg, report, { eligible, connectable, dryRun, repair: new Set(repair ?? []) });
22004
22254
  const dupeContainers = [
22005
22255
  { id: cfg.memory.sessions, kind: "session", label: "Sessions" },
22006
22256
  { id: cfg.llm.diary, kind: "diary", label: "Diary" },
@@ -22057,6 +22307,14 @@ async function sweep(trilium, cfg, opts = {}) {
22057
22307
  byDomain.set(domSlug, []);
22058
22308
  byDomain.get(domSlug).push(n);
22059
22309
  }
22310
+ const headingCache = new Map;
22311
+ const headingsOf = async (n) => {
22312
+ if (!headingCache.has(n.noteId)) {
22313
+ const content = await trilium.getNoteContent(n.noteId).catch(() => "");
22314
+ headingCache.set(n.noteId, headingOutline(content).filter((h) => h.level <= 3).map((h) => h.text));
22315
+ }
22316
+ return headingCache.get(n.noteId);
22317
+ };
22060
22318
  for (const [domSlug, notes] of byDomain) {
22061
22319
  const reported = new Set;
22062
22320
  for (let i = 0;i < notes.length; i++) {
@@ -22071,7 +22329,10 @@ async function sweep(trilium, cfg, opts = {}) {
22071
22329
  if (reported.has(pairKey))
22072
22330
  continue;
22073
22331
  reported.add(pairKey);
22074
- report.flagged.push(`near-duplicate subject: '${a.title}' [${a.noteId}] and '${bn.title}' [${bn.noteId}] in Domain/${domSlug} ` + `share '${shared.join("', '")}'. Two notes on one subject is the duplicate that actually happens \u2014 ` + `read both, merge into the older one, and forget() the other. If they are genuinely distinct, maintain(ack=[\u2026]) to silence this.`);
22332
+ const [ha, hb] = await Promise.all([headingsOf(a), headingsOf(bn)]);
22333
+ const overlap = ha.filter((h) => hb.some((x) => x.toLowerCase() === h.toLowerCase()));
22334
+ const show = (h) => h.length ? h.slice(0, 8).join(" \xB7 ") + (h.length > 8 ? " \xB7 \u2026" : "") : "(no headings)";
22335
+ report.flagged.push(`near-duplicate subject: '${a.title}' [${a.noteId}] and '${bn.title}' [${bn.noteId}] in Domain/${domSlug} ` + `share '${shared.join("', '")}'. Sections \u2014 '${a.title}': ${show(ha)} | '${bn.title}': ${show(hb)}. ` + (overlap.length ? `${overlap.length} heading(s) in common (${overlap.slice(0, 5).join(", ")}), which is what a genuine split subject looks like \u2014 read both, merge into the older one, and forget() the other.` : `No headings in common, so these are most likely distinct notes that happen to share a title word \u2014 maintain(ack=["${a.noteId}"]) to silence this once you have confirmed it.`));
22075
22336
  }
22076
22337
  }
22077
22338
  }
@@ -22080,6 +22341,16 @@ async function sweep(trilium, cfg, opts = {}) {
22080
22341
  }
22081
22342
  if (suppressed)
22082
22343
  report.suppressed = suppressed;
22344
+ if (deep) {
22345
+ if (domainSlug) {
22346
+ report.coverage = [
22347
+ ...report.coverage ?? [],
22348
+ `scoped to domain '${domainSlug}' \u2014 findings outside it were not evaluated, including the cross-domain ones (duplicate titles across domains, orphans elsewhere) that only an unscoped run can see. Re-run without domain= periodically.`
22349
+ ];
22350
+ } else if (report.flagged.length) {
22351
+ report.laneHint = "Flags here span the whole brain and may cover work this session never touched. LEAVE those \u2014 do not ack= them: an acknowledgement asserts the note's current content was reviewed, so acking unread is the one thing that makes the mechanism useless. Ack only what you actually read, act on what is yours, and pass domain= if you want a single lane.";
22352
+ }
22353
+ }
22083
22354
  return report;
22084
22355
  }
22085
22356
  var STUB_CONTENT_CHARS = 120;
@@ -22091,12 +22362,43 @@ function titleWordCount(title) {
22091
22362
  }
22092
22363
  async function hygienePasses(trilium, cfg, report, ctx) {
22093
22364
  const { eligible } = ctx;
22094
- const corrupted = await trilium.searchNotes("#noteType note.content *=* '&amp;lt;'", { ancestorNoteId: cfg.root, limit: 50 }).then((r) => r.results).catch(() => []);
22365
+ const corrupted = await trilium.searchNotes(`#noteType note.content %= '${escapeQueryRegex("&amp;(?:[a-zA-Z][a-zA-Z0-9]*|#\\d+|#x[0-9a-fA-F]+);")}'`, { ancestorNoteId: cfg.root, limit: 50 }).then((r) => r.results).catch(() => []);
22095
22366
  report.scanned += corrupted.length;
22096
22367
  for (const n of corrupted) {
22097
22368
  if (!eligible(n))
22098
22369
  continue;
22099
- report.flagged.push(`entity-corrupted: ${n.title} [${n.noteId}] \u2014 body stores doubly-escaped markup ("&amp;lt;") that renders as literal text. Repair with revise(mode="replace") passing the decoded content; nothing decodes it as a side effect.`);
22370
+ if (ctx.repair.has(n.noteId)) {
22371
+ const before = await trilium.getNoteContent(n.noteId).catch(() => "");
22372
+ const after = repairDoubleEscaping(before);
22373
+ if (after === before) {
22374
+ report.flagged.push(`entity-corrupted: ${n.title} [${n.noteId}] \u2014 repair requested but the body did not change; the match is a note DOCUMENTING the signature rather than carrying it. maintain(ack=["${n.noteId}"]) to silence.`);
22375
+ continue;
22376
+ }
22377
+ if (!ctx.dryRun) {
22378
+ await trilium.createRevision(n.noteId).catch(() => null);
22379
+ await trilium.updateNoteContent(n.noteId, after);
22380
+ }
22381
+ report.fixed.push(`entity-repaired: ${n.title} [${n.noteId}] \u2014 unwound one level of double-escaping${ctx.dryRun ? " (dry run, not written)" : ", snapshot taken first"}.`);
22382
+ continue;
22383
+ }
22384
+ report.flagged.push(`entity-corrupted: ${n.title} [${n.noteId}] \u2014 body stores doubly-escaped markup ("&amp;lt;", "&amp;nbsp;") that renders as literal text. Repair with maintain(repair=["${n.noteId}"]) \u2014 the substitution is always the same, so it is a first-class action rather than something each caller reinvents. Read it first if you need to be sure.`);
22385
+ }
22386
+ const claims = await trilium.searchNotes("#noteType=claim", { ancestorNoteId: cfg.root, fastSearch: true, limit: 200 }).then((r) => r.results).catch(() => []);
22387
+ report.scanned += claims.length;
22388
+ for (const n of claims) {
22389
+ if (!eligible(n))
22390
+ continue;
22391
+ const state = ownedLabel(n, "claimState");
22392
+ const verifiedOn = ownedLabel(n, "verified");
22393
+ const interval = Number(ownedLabel(n, "interval") ?? 30);
22394
+ if (state === "broken") {
22395
+ report.flagged.push(`claim broken: "${n.title}" [${n.noteId}] \u2014 last check FAILED${verifiedOn ? ` on ${verifiedOn}` : ""}. ` + `Correct every note asserting it (consistency() will find the others), then re-verify with claim(claimId="${n.noteId}", holds=true, evidence=\u2026). ` + `A broken claim left in the register is a known-wrong fact with a timestamp on it.`);
22396
+ continue;
22397
+ }
22398
+ const due = !verifiedOn || verifiedOn < isoDaysAgo(interval);
22399
+ if (due) {
22400
+ report.flagged.push(`claim ${verifiedOn ? "lapsed" : "unverified"}: "${n.title}" [${n.noteId}] \u2014 ` + `${verifiedOn ? `last verified ${verifiedOn}, interval ${interval}d` : "never verified since registration"}. ` + `Read its check with claim(claimId="${n.noteId}"), run it, and record the result.`);
22401
+ }
22100
22402
  }
22101
22403
  const titled = await ctx.connectable("#noteType", 300);
22102
22404
  const RECORD_TITLES = new Set(["diary", "session", "log", "threadEntry"]);
@@ -22162,7 +22464,7 @@ async function buildDigest(trilium, cfg, opts = {}) {
22162
22464
  const content = await trilium.getNoteContent(id).catch(() => "");
22163
22465
  if (!content)
22164
22466
  return null;
22165
- if (depth === "full") {
22467
+ if (depth === "full" || ALWAYS_FULL_SLOTS.has(slot)) {
22166
22468
  const summary = toText(content, Infinity);
22167
22469
  return summary ? { slot, summary } : null;
22168
22470
  }
@@ -22232,9 +22534,39 @@ async function buildDigest(trilium, cfg, opts = {}) {
22232
22534
  }
22233
22535
 
22234
22536
  // src/bootstrap.ts
22537
+ var CONTAINER_PURPOSES = {
22538
+ root: "The interconnected second brain \u2014 five areas: Master, LLM, Memory, Knowledge, and Insights.",
22539
+ master: "The master/user. Houses the fundamental, durable information about who they are \u2014 biography, goals, and preferences.",
22540
+ llm: "The assistant. Houses the assistant's fundamental self-model \u2014 its responsibilities, operating protocols, and diary.",
22541
+ llmDiary: "A collection of daily maintained diary notes \u2014 the assistant's unfiltered first-person record of its experience, opinions, and remarks on its own existence during each session in the environment, plus its remarks and opinions on BrainLLM itself.",
22542
+ memory: "The primary memory system the brain operates on \u2014 the running record of daily sessions and multi-session threads.",
22543
+ memorySessions: "A collection of daily, day-lifecycle session notes, each summarising that day's session.",
22544
+ memoryThreads: "A collection of maintained thread notes, each tracking a line of multi-session running work.",
22545
+ knowledge: "The secondary memory system \u2014 learned knowledge that adds to or conflicts with the assistant's training data, about the master/user and across domains.",
22546
+ knowledgeMaster: "A secondary collection of maintained notes about the master/user that don't fit the primary Master area (Biography / Goals / Preferences).",
22547
+ knowledgeDomains: "A collection of domain-scoped books. Each domain holds one maintained Sources note plus the sub-category information notes capturing important learned knowledge \u2014 beyond or conflicting with training data \u2014 about that domain.",
22548
+ insights: "The insights system \u2014 the brain's record of itself: per-day logs of how its own content changed, the rendered relation graph, and the register of claims checked against the world.",
22549
+ insightsLogs: "A collection of per-day, auto-generated notes recording the brain content (notes) created, updated, or deleted that day."
22550
+ };
22551
+ function containerPurposes(cfg) {
22552
+ return [
22553
+ [cfg.root, CONTAINER_PURPOSES.root],
22554
+ [cfg.master.root, CONTAINER_PURPOSES.master],
22555
+ [cfg.llm.root, CONTAINER_PURPOSES.llm],
22556
+ [cfg.llm.diary, CONTAINER_PURPOSES.llmDiary],
22557
+ [cfg.memory.root, CONTAINER_PURPOSES.memory],
22558
+ [cfg.memory.sessions, CONTAINER_PURPOSES.memorySessions],
22559
+ [cfg.memory.threads, CONTAINER_PURPOSES.memoryThreads],
22560
+ [cfg.knowledge.root, CONTAINER_PURPOSES.knowledge],
22561
+ [cfg.knowledge.master, CONTAINER_PURPOSES.knowledgeMaster],
22562
+ [cfg.knowledge.domains, CONTAINER_PURPOSES.knowledgeDomains],
22563
+ [cfg.insights.root, CONTAINER_PURPOSES.insights],
22564
+ [cfg.insights.logs, CONTAINER_PURPOSES.insightsLogs]
22565
+ ];
22566
+ }
22235
22567
  async function createBrainLLMStructure(trilium) {
22236
22568
  const d = new Date().toISOString().slice(0, 10);
22237
- const root = await trilium.createNote("root", "BrainLLM", purposeContent("The interconnected second brain \u2014 five areas: Master, LLM, Memory, Knowledge, and Insights."), "book");
22569
+ const root = await trilium.createNote("root", "BrainLLM", purposeContent(CONTAINER_PURPOSES.root), "book");
22238
22570
  const rootId = root.note.noteId;
22239
22571
  await Promise.all([
22240
22572
  trilium.addLabel(rootId, "brainLlmRoot", ""),
@@ -22253,30 +22585,30 @@ async function createBrainLLMStructure(trilium) {
22253
22585
  await trilium.addLabel(n.note.noteId, "noteType", kind);
22254
22586
  return n.note.noteId;
22255
22587
  };
22256
- const masterRoot = await book(rootId, "Master", "The master/user. Houses the fundamental, durable information about who they are \u2014 biography, goals, and preferences.", "bx bx-user");
22588
+ const masterRoot = await book(rootId, "Master", CONTAINER_PURPOSES.master, "bx bx-user");
22257
22589
  const [biography, goals, preferences] = await Promise.all([
22258
22590
  leaf(masterRoot, "Biography", "biography", "A single maintained note of biographical information about the master/user."),
22259
22591
  leaf(masterRoot, "Goals", "goals", "A single maintained note of the master/user's goals."),
22260
22592
  leaf(masterRoot, "Preferences", "preferences", "A single maintained note of the master/user's preferences.")
22261
22593
  ]);
22262
- const llmRoot = await book(rootId, "LLM", "The assistant. Houses the assistant's fundamental self-model \u2014 its responsibilities, operating protocols, and diary.", "bx bx-bot");
22594
+ const llmRoot = await book(rootId, "LLM", CONTAINER_PURPOSES.llm, "bx bx-bot");
22263
22595
  const [responsibilities, protocols, diary] = await Promise.all([
22264
22596
  leaf(llmRoot, "Responsibilities", "responsibilities", "A single maintained note of the assistant's responsibilities to the master/user, derived from their goals and preferences."),
22265
22597
  leaf(llmRoot, "Protocols", "protocols", "A single maintained note of the assistant's operating and self-correctness protocols \u2014 how it maximises its value to the master/user by efficiently meeting its responsibilities."),
22266
- book(llmRoot, "Diary", "A collection of daily maintained diary notes \u2014 the assistant's unfiltered first-person record of its experience, opinions, and remarks on its own existence during each session in the environment, plus its remarks and opinions on BrainLLM itself.", "bx bx-book-heart")
22598
+ book(llmRoot, "Diary", CONTAINER_PURPOSES.llmDiary, "bx bx-book-heart")
22267
22599
  ]);
22268
- const memoryRoot = await book(rootId, "Memory", "The primary memory system the brain operates on \u2014 the running record of daily sessions and multi-session threads.", "bx bx-been-here");
22600
+ const memoryRoot = await book(rootId, "Memory", CONTAINER_PURPOSES.memory, "bx bx-been-here");
22269
22601
  const [sessions, threads] = await Promise.all([
22270
- book(memoryRoot, "Sessions", "A collection of daily, day-lifecycle session notes, each summarising that day's session.", "bx bx-calendar"),
22271
- book(memoryRoot, "Threads", "A collection of maintained thread notes, each tracking a line of multi-session running work.", "bx bx-git-branch")
22602
+ book(memoryRoot, "Sessions", CONTAINER_PURPOSES.memorySessions, "bx bx-calendar"),
22603
+ book(memoryRoot, "Threads", CONTAINER_PURPOSES.memoryThreads, "bx bx-git-branch")
22272
22604
  ]);
22273
- const knowledgeRoot = await book(rootId, "Knowledge", "The secondary memory system \u2014 learned knowledge that adds to or conflicts with the assistant's training data, about the master/user and across domains.", "bx bx-library");
22605
+ const knowledgeRoot = await book(rootId, "Knowledge", CONTAINER_PURPOSES.knowledge, "bx bx-library");
22274
22606
  const [knowledgeMaster, domains] = await Promise.all([
22275
- book(knowledgeRoot, "Master", "A secondary collection of maintained notes about the master/user that don't fit the primary Master area (Biography / Goals / Preferences).", "bx bx-user-circle"),
22276
- book(knowledgeRoot, "Domains", "A collection of domain-scoped books. Each domain holds one maintained Sources note plus the sub-category information notes capturing important learned knowledge \u2014 beyond or conflicting with training data \u2014 about that domain.", "bx bx-category")
22607
+ book(knowledgeRoot, "Master", CONTAINER_PURPOSES.knowledgeMaster, "bx bx-user-circle"),
22608
+ book(knowledgeRoot, "Domains", CONTAINER_PURPOSES.knowledgeDomains, "bx bx-category")
22277
22609
  ]);
22278
- const insightsRoot = await book(rootId, "Insights", "The insights system \u2014 the brain's record of itself, starting with per-day logs of how its own content changed.", "bx bx-bulb");
22279
- const logs = await book(insightsRoot, "Logs", "A collection of per-day, auto-generated notes recording the brain content (notes) created, updated, or deleted that day.", "bx bx-history");
22610
+ const insightsRoot = await book(rootId, "Insights", CONTAINER_PURPOSES.insights, "bx bx-bulb");
22611
+ const logs = await book(insightsRoot, "Logs", CONTAINER_PURPOSES.insightsLogs, "bx bx-history");
22280
22612
  return {
22281
22613
  version: 9,
22282
22614
  root: rootId,
@@ -22341,37 +22673,8 @@ async function generateDailyLog(trilium, cfg, date4) {
22341
22673
  return { date: date4, noteId: logNoteId, ...counts, action: "created" };
22342
22674
  }
22343
22675
 
22344
- // src/tools-master.ts
22345
- var txt = (obj) => ({
22346
- content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }]
22347
- });
22348
- var AREAS = ["biography", "goals", "preferences"];
22349
- function registerMasterTools(server, trilium, brainRef) {
22350
- const b = () => brainRef.config;
22351
- server.tool("master", "Read a Master singleton in full: biography, goals, or preferences. Returns its id, content, and relation snippet.", { which: exports_external.enum(AREAS) }, async ({ which }) => {
22352
- const id = b().master[which];
22353
- if (!id)
22354
- throw new Error("BrainLLM not bootstrapped \u2014 run bootstrap.");
22355
- const [note, content] = await Promise.all([trilium.getNote(id), trilium.getNoteContent(id).catch(() => "")]);
22356
- const relations = relationSnippet(note);
22357
- return txt({ which, id, content, ...relations ? { relations } : {} });
22358
- });
22359
- server.tool("master_recall", "Skim all three Master singletons \u2014 the opening lines of biography, goals, and preferences (with ids and relation snippets).", {}, async () => {
22360
- const out = {};
22361
- for (const which of AREAS) {
22362
- const id = b().master[which];
22363
- if (!id)
22364
- continue;
22365
- const [note, content] = await Promise.all([trilium.getNote(id).catch(() => null), trilium.getNoteContent(id).catch(() => "")]);
22366
- const relations = note ? relationSnippet(note) : undefined;
22367
- out[which] = { id, preview: toText(content, 200), ...relations ? { relations } : {} };
22368
- }
22369
- return txt(out);
22370
- });
22371
- }
22372
-
22373
22676
  // src/tools-surface.ts
22374
- var txt2 = (obj) => ({
22677
+ var txt = (obj) => ({
22375
22678
  content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }]
22376
22679
  });
22377
22680
  var labelOf = (n, name) => n.attributes.find((a) => a.type === "label" && a.name === name)?.value;
@@ -22402,10 +22705,37 @@ async function skim(trilium, ancestorNoteId, opts = {}) {
22402
22705
  };
22403
22706
  }));
22404
22707
  }
22405
- async function readFull(trilium, id) {
22708
+ async function readFull(trilium, id, opts = {}) {
22406
22709
  const [note, content] = await Promise.all([trilium.getNote(id), trilium.getNoteContent(id).catch(() => "")]);
22407
22710
  const relations = relationSnippet(note);
22408
- return { id, title: note.title, kind: labelOf(note, "noteType"), content, ...relations ? { relations } : {} };
22711
+ const base = { id, title: note.title, kind: labelOf(note, "noteType"), ...relations ? { relations } : {} };
22712
+ if (!opts.section) {
22713
+ return {
22714
+ ...base,
22715
+ content,
22716
+ ...content.length >= LARGE_NOTE_CHARS ? { size: content.length, hint: `${Math.round(content.length / 1000)}k characters. Read one section at a time with section="<heading>" \u2014 outline(${id}) lists them.` } : {}
22717
+ };
22718
+ }
22719
+ const found = getSection(content, opts.section, opts.occurrence ?? 1);
22720
+ if (!found.matched) {
22721
+ return {
22722
+ ...base,
22723
+ content: "",
22724
+ section: opts.section,
22725
+ matched: false,
22726
+ available: found.available,
22727
+ hint: `No "${opts.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.`
22728
+ };
22729
+ }
22730
+ return {
22731
+ ...base,
22732
+ content: found.content,
22733
+ section: opts.section,
22734
+ matched: true,
22735
+ headingCount: found.headingCount,
22736
+ ...found.subsections?.length ? { subsections: found.subsections } : {},
22737
+ ...found.headingCount && found.headingCount > 1 && !opts.occurrence ? { hint: `${found.headingCount} headings share that text \u2014 this is the FIRST. Pass occurrence= (1-${found.headingCount}) to read another.` } : {}
22738
+ };
22409
22739
  }
22410
22740
  async function previewWithRelations(trilium, id, len = 200) {
22411
22741
  const [note, content] = await Promise.all([trilium.getNote(id).catch(() => null), trilium.getNoteContent(id).catch(() => "")]);
@@ -22413,22 +22743,66 @@ async function previewWithRelations(trilium, id, len = 200) {
22413
22743
  return { preview: toText(content, len), ...relations ? { relations } : {} };
22414
22744
  }
22415
22745
 
22746
+ // src/tools-master.ts
22747
+ var txt2 = (obj) => ({
22748
+ content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }]
22749
+ });
22750
+ var AREAS = ["biography", "goals", "preferences"];
22751
+ function registerMasterTools(server, trilium, brainRef) {
22752
+ const b = () => brainRef.config;
22753
+ server.tool("master", `Read a Master singleton: biography, goals, or preferences. Returns its id, content, and
22754
+ relation snippet.
22755
+
22756
+ section="<heading>" reads ONE section instead of the whole note \u2014 the efficient path on
22757
+ preferences, whose schedule tables make it the largest of the three, when the session needs one
22758
+ day's blocks rather than the whole week. outline(id) lists the headings.`, {
22759
+ which: exports_external.enum(AREAS),
22760
+ section: exports_external.string().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole note"),
22761
+ occurrence: exports_external.number().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)")
22762
+ }, async ({ which, section, occurrence }) => {
22763
+ const id = b().master[which];
22764
+ if (!id)
22765
+ throw new Error("BrainLLM not bootstrapped \u2014 run bootstrap.");
22766
+ const read = await readFull(trilium, id, { section, occurrence });
22767
+ const { id: _id, title: _title, kind: _kind, ...rest } = read;
22768
+ return txt2({ which, id, ...rest });
22769
+ });
22770
+ server.tool("master_recall", "Skim all three Master singletons \u2014 the opening lines of biography, goals, and preferences (with ids and relation snippets).", {}, async () => {
22771
+ const out = {};
22772
+ for (const which of AREAS) {
22773
+ const id = b().master[which];
22774
+ if (!id)
22775
+ continue;
22776
+ const [note, content] = await Promise.all([trilium.getNote(id).catch(() => null), trilium.getNoteContent(id).catch(() => "")]);
22777
+ const relations = note ? relationSnippet(note) : undefined;
22778
+ out[which] = { id, preview: toText(content, 200), ...relations ? { relations } : {} };
22779
+ }
22780
+ return txt2(out);
22781
+ });
22782
+ }
22783
+
22416
22784
  // src/tools-llm.ts
22417
22785
  function registerLlmTools(server, trilium, brainRef) {
22418
22786
  const b = () => brainRef.config;
22419
- server.tool("llm", "Read an LLM note in full: responsibilities or protocols (singletons), or a diary entry by id.", {
22787
+ server.tool("llm", `Read an LLM note: responsibilities or protocols (singletons), or a diary entry by id.
22788
+
22789
+ section="<heading>" reads ONE section instead of the whole note \u2014 the efficient path on
22790
+ protocols, which is the largest singleton and the one most often needed in part rather than
22791
+ whole. outline(id) lists the headings.`, {
22420
22792
  which: exports_external.enum(["responsibilities", "protocols", "diary"]),
22421
- id: exports_external.string().optional().describe("diary only: the entry id from llm_recall")
22422
- }, async ({ which, id }) => {
22793
+ id: exports_external.string().optional().describe("diary only: the entry id from llm_recall"),
22794
+ section: exports_external.string().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole note"),
22795
+ occurrence: exports_external.number().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)")
22796
+ }, async ({ which, id, section, occurrence }) => {
22423
22797
  if (which === "diary") {
22424
22798
  if (!id)
22425
22799
  throw new Error("Reading a diary entry needs its id \u2014 use llm_recall to find one.");
22426
- return txt2(await readFull(trilium, id));
22800
+ return txt(await readFull(trilium, id, { section, occurrence }));
22427
22801
  }
22428
22802
  const noteId = b().llm[which];
22429
22803
  if (!noteId)
22430
22804
  throw new Error("BrainLLM not bootstrapped \u2014 run bootstrap.");
22431
- return txt2(await readFull(trilium, noteId));
22805
+ return txt(await readFull(trilium, noteId, { section, occurrence }));
22432
22806
  });
22433
22807
  server.tool("llm_recall", "Skim the LLM surface: responsibilities & protocols opening lines (with relation snippets), plus recent diary entries.", { limit: exports_external.number().optional() }, async ({ limit }) => {
22434
22808
  const cfg = b();
@@ -22437,7 +22811,7 @@ function registerLlmTools(server, trilium, brainRef) {
22437
22811
  previewWithRelations(trilium, cfg.llm.protocols),
22438
22812
  skim(trilium, cfg.llm.diary, { kind: "diary", limit: limit ?? 7 })
22439
22813
  ]);
22440
- return txt2({
22814
+ return txt({
22441
22815
  responsibilities: { id: cfg.llm.responsibilities, ...resp },
22442
22816
  protocols: { id: cfg.llm.protocols, ...prot },
22443
22817
  diary
@@ -22449,21 +22823,28 @@ function registerLlmTools(server, trilium, brainRef) {
22449
22823
  function registerMemoryTools(server, trilium, brainRef) {
22450
22824
  const b = () => brainRef.config;
22451
22825
  server.tool("memory", `Read a Memory note in full by id \u2014 a thread or a session. A thread book returns its
22452
- Context/Resolution plus a children index ({id, date, preview}[], newest first) instead of a
22453
- flat body \u2014 the day-to-day content lives in those [yyyy-mm-dd] children, not the book. Pass
22454
- date="yyyy-mm-dd" to resolve straight to that day's child in the same call, or call memory()
22455
- again with a child's id from the index.`, {
22826
+ Context/Resolution plus a children index ({id, date, blocks[]}, newest first) instead of a
22827
+ flat body \u2014 the day-to-day content lives in those [yyyy-mm-dd] children, not the book. Each
22828
+ child is indexed by its ADDENDUM BLOCKS \u2014 marker, identification line, and lead \u2014 rather than a
22829
+ slice from the top of the body, which on a record note is the header and therefore identical on
22830
+ every child.
22831
+
22832
+ Pass date="yyyy-mm-dd" to resolve straight to that day's child in the same call, or call
22833
+ memory() again with a child's id from the index. section="<heading>" reads one section instead
22834
+ of the whole body \u2014 use it on long sessions and consolidated thread Contexts.`, {
22456
22835
  id: exports_external.string(),
22457
- date: exports_external.string().optional().describe("Thread books only: resolve directly to this day's child note (yyyy-mm-dd)")
22458
- }, async ({ id, date: date4 }) => {
22836
+ date: exports_external.string().optional().describe("Thread books only: resolve directly to this day's child note (yyyy-mm-dd)"),
22837
+ section: exports_external.string().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole note"),
22838
+ occurrence: exports_external.number().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)")
22839
+ }, async ({ id, date: date4, section, occurrence }) => {
22459
22840
  const note = await trilium.getNote(id).catch(() => null);
22460
22841
  if (!note || labelOf(note, "noteType") !== "thread")
22461
- return txt2(await readFull(trilium, id));
22842
+ return txt(await readFull(trilium, id, { section, occurrence }));
22462
22843
  if (date4) {
22463
22844
  const child = await trilium.searchNotes(`#noteType=threadEntry #created='${date4}'`, { ancestorNoteId: id, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
22464
22845
  if (child.results[0])
22465
- return txt2(await readFull(trilium, child.results[0].noteId));
22466
- return txt2({ id, title: note.title, kind: "thread", note: `No entry for ${date4}.` });
22846
+ return txt(await readFull(trilium, child.results[0].noteId, { section, occurrence }));
22847
+ return txt({ id, title: note.title, kind: "thread", note: `No entry for ${date4}.` });
22467
22848
  }
22468
22849
  const children = await trilium.searchNotes("#noteType=threadEntry", {
22469
22850
  ancestorNoteId: id,
@@ -22472,13 +22853,17 @@ again with a child's id from the index.`, {
22472
22853
  orderBy: "dateCreated",
22473
22854
  orderDirection: "desc"
22474
22855
  }).catch(() => ({ results: [] }));
22475
- const entries = await Promise.all(children.results.map(async (c) => ({
22476
- id: c.noteId,
22477
- date: labelOf(c, "created") ?? c.dateCreated.slice(0, 10),
22478
- preview: toText(await trilium.getNoteContent(c.noteId).catch(() => ""), 160)
22479
- })));
22480
- const full = await readFull(trilium, id);
22481
- return txt2({ ...full, children: entries });
22856
+ const entries = await Promise.all(children.results.map(async (c) => {
22857
+ const content = await trilium.getNoteContent(c.noteId).catch(() => "");
22858
+ const blocks = addendumIndex(content);
22859
+ return {
22860
+ id: c.noteId,
22861
+ date: labelOf(c, "created") ?? c.dateCreated.slice(0, 10),
22862
+ ...blocks.length ? { blocks } : { preview: toText(content, 160) }
22863
+ };
22864
+ }));
22865
+ const full = await readFull(trilium, id, { section, occurrence });
22866
+ return txt({ ...full, children: entries });
22482
22867
  });
22483
22868
  server.tool("memory_recall", "Skim Memory: active threads and recent sessions (ids + previews). An optional query filters threads by title.", { query: exports_external.string().optional(), limit: exports_external.number().optional() }, async ({ query, limit }) => {
22484
22869
  const cfg = b();
@@ -22501,14 +22886,24 @@ again with a child's id from the index.`, {
22501
22886
  const content = await trilium.getNoteContent(child.noteId).catch(() => "");
22502
22887
  return { ...t, latestActivity: { date: date4, preview: toText(content, 160) } };
22503
22888
  }));
22504
- return txt2({ threads, sessions });
22889
+ return txt({ threads, sessions });
22505
22890
  });
22506
22891
  }
22507
22892
 
22508
22893
  // src/tools-knowledge.ts
22509
22894
  function registerKnowledgeTools(server, trilium, brainRef) {
22510
22895
  const b = () => brainRef.config;
22511
- server.tool("knowledge", "Read a Knowledge note in full by id \u2014 a user-knowledge note, a domain information note, or a Sources note.", { id: exports_external.string() }, async ({ id }) => txt2(await readFull(trilium, id)));
22896
+ server.tool("knowledge", `Read a Knowledge note by id \u2014 a user-knowledge note, a domain information note, or a Sources note.
22897
+
22898
+ Pass section="<heading>" to read ONE section instead of the whole body \u2014 the read-side
22899
+ counterpart to revise(section=), matching on the same heading contract, so a name that reads
22900
+ also writes. Use it on anything large: a note past the read ceiling cannot be returned whole at
22901
+ all, and outline(id) lists the headings to choose from. occurrence= disambiguates repeated
22902
+ heading texts.`, {
22903
+ id: exports_external.string(),
22904
+ section: exports_external.string().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole note"),
22905
+ occurrence: exports_external.number().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)")
22906
+ }, async ({ id, section, occurrence }) => txt(await readFull(trilium, id, { section, occurrence })));
22512
22907
  server.tool("knowledge_recall", "Skim Knowledge. With a domain: that domain's Sources + information notes. Without: user-knowledge notes plus the list of domains.", { query: exports_external.string().optional(), domain: exports_external.string().optional() }, async ({ query, domain }) => {
22513
22908
  const cfg = b();
22514
22909
  if (domain) {
@@ -22516,11 +22911,11 @@ function registerKnowledgeTools(server, trilium, brainRef) {
22516
22911
  const found = await trilium.searchNotes(`#noteType=domain #domain='${slug}'`, { ancestorNoteId: cfg.knowledge.domains, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
22517
22912
  const dom = found.results[0];
22518
22913
  if (!dom)
22519
- return txt2({ note: `No domain "${domain}" yet.` });
22520
- return txt2({ domain: dom.title, contents: await skim(trilium, dom.noteId, { query, limit: 50 }) });
22914
+ return txt({ note: `No domain "${domain}" yet.` });
22915
+ return txt({ domain: dom.title, contents: await skim(trilium, dom.noteId, { query, limit: 50 }) });
22521
22916
  }
22522
22917
  const domains = await skim(trilium, cfg.knowledge.domains, { kind: "domain", limit: 50 });
22523
- return txt2({
22918
+ return txt({
22524
22919
  userKnowledge: await skim(trilium, cfg.knowledge.master, { kind: "user", query, limit: 30 }),
22525
22920
  domains: domains.map((d) => ({ id: d.id, title: d.title }))
22526
22921
  });
@@ -22530,14 +22925,19 @@ function registerKnowledgeTools(server, trilium, brainRef) {
22530
22925
  // src/tools-insights.ts
22531
22926
  function registerInsightsTools(server, trilium, brainRef) {
22532
22927
  const b = () => brainRef.config;
22533
- server.tool("insights", "Read the BrainLLM's change log for a day (default: today). Format: YYYY-MM-DD.", { date: exports_external.string().optional() }, async ({ date: date4 }) => {
22928
+ server.tool("insights", `Read the BrainLLM's change log for a day (default: today). Format: YYYY-MM-DD.
22929
+ section="<heading>" reads one section instead of the whole log.`, {
22930
+ date: exports_external.string().optional(),
22931
+ section: exports_external.string().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole log"),
22932
+ occurrence: exports_external.number().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)")
22933
+ }, async ({ date: date4, section, occurrence }) => {
22534
22934
  const d = date4 ?? localToday();
22535
22935
  const found = await trilium.searchNotes(`#noteType=log #created='${d}'`, { ancestorNoteId: b().insights.logs, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
22536
22936
  if (!found.results[0])
22537
- return txt2({ date: d, note: "No log for this day." });
22538
- return txt2(await readFull(trilium, found.results[0].noteId));
22937
+ return txt({ date: d, note: "No log for this day." });
22938
+ return txt(await readFull(trilium, found.results[0].noteId, { section, occurrence }));
22539
22939
  });
22540
- server.tool("insights_recall", "Skim recent change logs (days + previews).", { limit: exports_external.number().optional() }, async ({ limit }) => txt2(await skim(trilium, b().insights.logs, { kind: "log", limit: limit ?? 14 })));
22940
+ server.tool("insights_recall", "Skim recent change logs (days + previews).", { limit: exports_external.number().optional() }, async ({ limit }) => txt(await skim(trilium, b().insights.logs, { kind: "log", limit: limit ?? 14 })));
22541
22941
  }
22542
22942
 
22543
22943
  // src/tools-advanced.ts
@@ -22940,7 +23340,11 @@ ${block}` }));
22940
23340
  await trilium.addLabel(noteId, "created", d);
22941
23341
  return { noteId, action: "created" };
22942
23342
  }
22943
- server.tool("start", `Boot BrainLLM \u2014 call ONCE at the start of every session, before responding.
23343
+ server.tool("start", `Boot BrainLLM \u2014 call ONCE at the start of every session, before responding. Master
23344
+ PREFERENCES and LLM PROTOCOLS always come back in full, even at the default digest depth: they
23345
+ carry the schedule, the working style and the operating rules governing the session itself, so a
23346
+ session needs them whole before it knows enough to ask for them. Biography, goals and
23347
+ responsibilities come back as section headings + preview.
22944
23348
  Runs maintenance, creates today's diary and session notes if not yet open, then returns: today
22945
23349
  and weekday, the Master digest (biography / goals / preferences), the LLM digest
22946
23350
  (responsibilities / protocols / today's diary preview and ID), this session's note ID, active
@@ -23044,7 +23448,7 @@ sessions need one section, not five documents.`, {
23044
23448
  lastSession: digest.lastSession ?? null,
23045
23449
  changesSinceLastSession: changesSinceLastSession.length ? changesSinceLastSession : undefined,
23046
23450
  ...newDay ? { newDay: true, newDayHint: "First session of the day \u2014 call day() for the sweep payload (previous session + log + changes + monthly deliverables in one call)." } : {},
23047
- ...(depth ?? "digest") === "digest" ? { depthHint: 'Singletons are section headings + preview. Pull the one you need with master(which) / llm(which); re-run start(depth="full") only if the session genuinely needs the whole self-model.' } : {},
23451
+ ...(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.' } : {},
23048
23452
  hygiene: { scanned: hygiene.scanned, fixed: hygiene.fixed.length, transitions: hygiene.transitions, flagged: hygiene.flagged, ...hygiene.suppressed ? { suppressed: hygiene.suppressed } : {} }
23049
23453
  });
23050
23454
  });
@@ -23057,6 +23461,14 @@ instead (rarely needed; token-heavy).
23057
23461
 
23058
23462
  Idempotent: fetches are read-only, the sweep is non-destructive, safe to call multiple times.
23059
23463
 
23464
+ Returns pending= \u2014 how much each remaining step actually has to do (addendum markers outstanding,
23465
+ maintenance flags already raised, diary blocks written today, which singletons were written
23466
+ today). The close protocol is ~7 tools landing exactly when context is scarcest, and reciting
23467
+ every step unconditionally spends that context on steps with nothing to do. Also returns audit=,
23468
+ a cross-singleton check nothing else performs: whether the five singletons agree with each other,
23469
+ AND whether the LLM's operating rules still serve what the user's goals and preferences call for
23470
+ \u2014 a semantic question, not a textual one, which consistency() and maintain() cannot answer.
23471
+
23060
23472
  After session() returns, work through this protocol \u2014 order doesn't matter mechanically (each
23061
23473
  step is tracked by the tool call itself, not by sequence), but close() enforces that every one
23062
23474
  of diary(), session() [this call], remarks(), addendum(), and maintain() actually ran before it
@@ -23112,6 +23524,24 @@ rather than listing them unconditionally for every caller to work around.`, {
23112
23524
  }
23113
23525
  }
23114
23526
  const hygiene = await sweep(trilium, cfg, { deep: false, dryRun: false }).catch(() => null);
23527
+ const pendingAddendums = await trilium.searchNotes("#noteType note.content *=* 'Addendum'", { ancestorNoteId: cfg.root, fastSearch: false, limit: 40 }).then((r) => r.results.filter((n) => {
23528
+ const kind = labelOf2(n, "noteType");
23529
+ return kind && !["session", "diary", "log", "threadEntry", "thread"].includes(kind);
23530
+ }).length).catch(() => null);
23531
+ const singletonStubs = [
23532
+ ["biography", biography],
23533
+ ["goals", goals],
23534
+ ["preferences", preferences],
23535
+ ["responsibilities", responsibilities],
23536
+ ["protocols", protocols]
23537
+ ];
23538
+ const touchedToday = singletonStubs.filter(([, s]) => s.lastModified === d).map(([name]) => name);
23539
+ const pending = {
23540
+ addendums: pendingAddendums === null ? "unknown" : pendingAddendums,
23541
+ maintenanceFlags: hygiene?.flagged.length ?? 0,
23542
+ diaryBlocksToday: diaryEntry?.blocks ?? 0,
23543
+ singletonsWrittenToday: touchedToday.length ? touchedToday : "none"
23544
+ };
23115
23545
  const scoped = scope === "agent";
23116
23546
  return txt4({
23117
23547
  date: d,
@@ -23121,13 +23551,20 @@ rather than listing them unconditionally for every caller to work around.`, {
23121
23551
  llm: { responsibilities, protocols },
23122
23552
  diary: diaryEntry,
23123
23553
  maintenance: hygiene ? { scanned: hygiene.scanned, fixed: hygiene.fixed.length, transitions: hygiene.transitions, flagged: hygiene.flagged, ...hygiene.suppressed ? { suppressed: hygiene.suppressed } : {} } : "skipped",
23554
+ pending,
23555
+ audit: scoped ? undefined : {
23556
+ consistency: "Read all five singletons and check for ambiguity, internal contradiction, or claims that disagree across them. Fix what you find with revise() BEFORE close(), so the log records a brain that already agrees with itself.",
23557
+ alignment: "Then check correlation, not just agreement: do responsibilities and protocols actually serve what biography, goals and preferences describe? A protocol can be perfectly consistent and still be serving a goal that has moved.",
23558
+ readThem: `master("biography"|"goals"|"preferences") and llm("responsibilities"|"protocols") \u2014 or one section at a time with section=. Written today: ${touchedToday.length ? touchedToday.join(", ") : "none"}.`
23559
+ },
23124
23560
  next: [
23125
23561
  ...scoped ? ["Scoped run \u2014 the user's master singletons and your LLM singletons are OUT of scope and deliberately not listed here."] : [
23126
23562
  "Update master singletons (biography / goals / preferences) via revise() with session observations about the user.",
23127
23563
  "Update LLM singletons (responsibilities / protocols) via revise() with session observations about yourself."
23128
23564
  ],
23129
- scoped ? "Call addendum() \u2014 fold only what is in your lane; leaving out-of-scope addendums for the next interactive session is correct, and the call itself satisfies the gate." : "Call addendum() \u2014 find and merge any pending addendums.",
23130
- scoped ? "Call maintain() \u2014 audit brain hygiene; pass domain= to keep the flags in your lane." : "Call maintain() \u2014 audit and fix brain hygiene.",
23565
+ ...scoped ? [] : ["Audit the singletons against each other and against each other's PURPOSE \u2014 see audit= above. This is the step that catches an operating rule still serving a goal that has moved."],
23566
+ scoped ? `Call addendum() \u2014 fold only what is in your lane; leaving out-of-scope addendums for the next interactive session is correct, and the call itself satisfies the gate. (${pending.addendums} note(s) currently carry addendum markers.)` : pending.addendums === 0 ? "Call addendum() \u2014 0 notes currently carry addendum markers, so this should come back clean; the call still satisfies the gate." : `Call addendum() \u2014 ${pending.addendums} note(s) carry addendum markers; fold each into its section.`,
23567
+ scoped ? "Call maintain() \u2014 audit brain hygiene; pass domain= to keep the flags in your lane." : pending.maintenanceFlags === 0 ? "Call maintain() \u2014 the lite sweep just ran clean, so this is a formality unless you want deep=true." : `Call maintain() \u2014 the lite sweep already flagged ${pending.maintenanceFlags} item(s), listed above.`,
23131
23568
  "Call remarks() \u2014 get the diary cues: your experience, opinions, and existence this session, plus BrainLLM remarks.",
23132
23569
  "Call diary() \u2014 write the day's unfiltered record with the cues in hand; the gate counts it only after remarks().",
23133
23570
  "Call close() \u2014 commit the session log (mandatory, last). Refuses until every step ran and session \u2192 remarks \u2192 diary held; pass force=true only when a skipped step genuinely has nothing to log."
@@ -23387,13 +23824,15 @@ For diary entries use the dedicated diary() tool \u2014 remember(kind="diary") i
23387
23824
  })).optional().describe("kind=sources only: upsert Revision-table rows by source name \u2014 re-verifying a source replaces its existing row's Marker/Date in place instead of appending a new one"),
23388
23825
  topics: exports_external.array(exports_external.string()).optional().describe("Topic tags \u2014 slugged server-side"),
23389
23826
  supersedes: exports_external.string().optional().describe("noteId this replaces \u2014 old note is archived and wired supersedes"),
23827
+ mustCreate: exports_external.boolean().optional().describe("Refuse instead of adopting an existing note when the title already exists \u2014 turns a silent overwrite on a generic title (Current State, Sources, Technology Stack) into a catchable error"),
23390
23828
  connect: exports_external.array(exports_external.object({
23391
23829
  relation: exports_external.enum(RelationTypes),
23392
23830
  toNoteId: exports_external.string()
23393
23831
  })).optional().describe("Relations to wire from this note in the same call \u2014 same semantics as connect() (idempotent, worksWith wired both ways)"),
23394
23832
  icon: exports_external.string().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name ("brain"); normalized server-side'),
23395
23833
  date: exports_external.string().optional().describe("ISO date override (default: today)")
23396
- }, async ({ kind, title, body, goal, identity, domain, revision, topics, supersedes, connect: connectRels, icon, date: date4 }) => {
23834
+ }, async ({ kind, title, body, goal, identity, domain, revision, topics, supersedes, mustCreate, connect: connectRels, icon, date: date4 }) => {
23835
+ 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.`);
23397
23836
  const opts = { domain, topics, date: date4 };
23398
23837
  const d = date4 ?? today();
23399
23838
  const { html, warnings: sanitizeWarnings } = renderBody(body ?? "");
@@ -23450,6 +23889,7 @@ For diary entries use the dedicated diary() tool \u2014 remember(kind="diary") i
23450
23889
  const found = sourcesId ? { results: [] } : await trilium.searchNotes("#noteType=sources", { ancestorNoteId: domainId, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
23451
23890
  let sid = sourcesId ?? found.results[0]?.noteId;
23452
23891
  let wrote = false;
23892
+ let sourceMerge = {};
23453
23893
  if (!sid) {
23454
23894
  const created2 = await trilium.createNote(domainId, "Sources", contentFor("sources", { date: d, body: html, domain: domainTitle }));
23455
23895
  sid = created2.note.noteId;
@@ -23461,7 +23901,8 @@ For diary entries use the dedicated diary() tool \u2014 remember(kind="diary") i
23461
23901
  const current = await trilium.getNoteContent(sid).catch(() => "");
23462
23902
  if (!current.includes(html)) {
23463
23903
  await trilium.createRevision(sid).catch(() => null);
23464
- const merged = setSection(current, "Sources", html, "append");
23904
+ const merged = mergeUnderSection(current, html, "Sources");
23905
+ sourceMerge = { ...merged.mergedInto.length ? { mergedIntoGroups: merged.mergedInto } : {}, ...merged.appended.length ? { newGroups: merged.appended } : {} };
23465
23906
  const stamped = bumpLastUpdated(merged.html, d);
23466
23907
  await trilium.updateNoteContent(sid, stamped.html);
23467
23908
  await trilium.updateLabelValue(sid, "updated", d);
@@ -23505,6 +23946,7 @@ For diary entries use the dedicated diary() tool \u2014 remember(kind="diary") i
23505
23946
  location: locationLabel(kind, domainTitle),
23506
23947
  ...createdDomain ? { createdDomain: domainTitle } : {},
23507
23948
  ...revisionChanges.length ? { revision: revisionChanges } : {},
23949
+ ...sourceMerge,
23508
23950
  ...revisionKeys.length ? { revisionRows: revisionKeys } : {},
23509
23951
  ...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." } : {},
23510
23952
  ...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." } : {},
@@ -23523,6 +23965,8 @@ For diary entries use the dedicated diary() tool \u2014 remember(kind="diary") i
23523
23965
  const { domainId, domainTitle, createdDomain } = await resolveDomain(trilium, b(), domain);
23524
23966
  const inDomain = await trilium.searchNotes("#noteType=information", { ancestorNoteId: domainId, fastSearch: true, limit: 100 }).catch(() => ({ results: [] }));
23525
23967
  const existing2 = inDomain.results.find((n) => sameTitle(n.title, subTitle));
23968
+ if (existing2 && mustCreate)
23969
+ return refuseAdoption(existing2.noteId, existing2.title, `in domain "${domainTitle}"`);
23526
23970
  if (existing2) {
23527
23971
  const current = await trilium.getNoteContent(existing2.noteId).catch(() => "");
23528
23972
  if (isDuplicateAppend(current, html))
@@ -23560,6 +24004,8 @@ For diary entries use the dedicated diary() tool \u2014 remember(kind="diary") i
23560
24004
  return err("rejected_kind", "Diary entries use the dedicated diary() tool.", `Call diary(body="...") to write today's diary entry.`);
23561
24005
  if (kind === "session")
23562
24006
  return err("rejected_kind", "Session notes are written by close().", 'Call close(summary="...") to log this session.');
24007
+ if (kind === "claim")
24008
+ return err("rejected_kind", "Claims are registered by claim().", 'Call claim(assertion="...", check="...") \u2014 a claim carries a verification schedule and history that a generic write cannot set up.');
23563
24009
  if (kind === "log")
23564
24010
  return err("rejected_kind", "Log notes are auto-generated by close() and cannot be written manually.");
23565
24011
  if (kind === "domain")
@@ -23570,6 +24016,8 @@ For diary entries use the dedicated diary() tool \u2014 remember(kind="diary") i
23570
24016
  if (!cleanTitle)
23571
24017
  return err("missing_param", `kind="${kind}" requires a title.`, 'Add title="<note title>" to your call.');
23572
24018
  const existing = await findExisting(kind, cleanTitle);
24019
+ if (existing && mustCreate)
24020
+ return refuseAdoption(existing.noteId, existing.title, `in ${KIND_AREA[kind] ?? "the brain"}`);
23573
24021
  if (existing) {
23574
24022
  if (kind === "thread" && !identity && !leadingIdentification(html)) {
23575
24023
  return err("missing_identity", 'Thread addendums open with the canonical identification line (h3): "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]".', `Pass identity="Claude \u2026 \xB7 <environment> \xB7 <agent/mode>" \u2014 the server renders it as the addendum's h3.`);
@@ -23732,25 +24180,38 @@ a doubly-escaped tag", "which cite a 2026 date"). Backslashes must be escaped.`,
23732
24180
  } catch (e) {
23733
24181
  return err("invalid_pattern", `Not a valid regular expression: ${e.message}`, "Escape backslashes \u2014 a JSON string needs \\\\d for \\d.");
23734
24182
  }
23735
- const candidates = (await run(`note.content %= '${escapeQueryValue(regex)}'`)).filter(filterNote);
24183
+ const candidates = (await run(`note.content %= '${escapeQueryRegex(regex)}'`)).filter(filterNote);
23736
24184
  const confirmed = [];
23737
24185
  let rejected = 0;
24186
+ let tagSpanning = 0;
23738
24187
  for (const n of candidates) {
23739
24188
  if (confirmed.length >= max)
23740
24189
  break;
23741
24190
  const content = await trilium.getNoteContent(n.noteId).catch(() => "");
23742
- if (content && re.test(content))
24191
+ if (!content) {
24192
+ rejected++;
24193
+ continue;
24194
+ }
24195
+ const rawHit = re.test(content);
24196
+ const projectedHit = !rawHit && re.test(stripTagsWithMap(content).text);
24197
+ if (rawHit || projectedHit) {
24198
+ if (projectedHit)
24199
+ tagSpanning++;
23743
24200
  confirmed.push(n);
23744
- else
24201
+ } else
23745
24202
  rejected++;
23746
24203
  }
23747
24204
  const results2 = await Promise.all(confirmed.map(buildResult));
24205
+ const notes = [
24206
+ 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,
24207
+ tagSpanning ? `${tagSpanning} match(es) were found only after stripping markup \u2014 the phrase is split by an inline tag there.` : null,
24208
+ results2.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
24209
+ ].filter(Boolean);
23748
24210
  return txt4({
23749
24211
  mode: "regex",
23750
24212
  pattern: regex,
23751
24213
  results: results2,
23752
- ...rejected ? { note: `${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.` } : {},
23753
- ...results2.length === 0 ? { note: "No bodies matched that pattern. Regex runs against stored HTML, so anchor on what is actually stored (tags and entities), not on rendered text." } : {}
24214
+ ...notes.length ? { note: notes.join(" ") } : {}
23754
24215
  });
23755
24216
  }
23756
24217
  if (orderBy) {
@@ -23913,7 +24374,7 @@ find=; to add content next to a heading without touching its body, use mode="bef
23913
24374
  title: exports_external.string().optional().describe("New title (normalized server-side)"),
23914
24375
  section: exports_external.string().optional().describe("Target a section by heading text (h2/h3/h4, in that order); omit for whole-note append/replace"),
23915
24376
  occurrence: exports_external.number().int().positive().optional().describe("section=: which same-text heading to target, 1-based (default: the first). Read them with outline(noteId)."),
23916
- mode: exports_external.enum(["append", "replace", "before", "after"]).optional().describe('append (default) | replace | before | after \u2014 "before"/"after" insert adjacent to the section= heading without touching its body'),
24377
+ mode: exports_external.enum(["append", "replace", "before", "after", "remove"]).optional().describe('append (default) | replace | before | after | remove \u2014 "before"/"after" insert adjacent to the section= heading without touching its body; "remove" deletes the section= heading and its body, and needs no body='),
23917
24378
  find: exports_external.string().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."),
23918
24379
  nth: exports_external.number().int().positive().optional().describe("find=: replace only the Nth occurrence, 1-based (default: all of them)"),
23919
24380
  edits: exports_external.array(exports_external.object({
@@ -24001,23 +24462,37 @@ find=; to add content next to a heading without touching its body, use mode="bef
24001
24462
  const tolerant = [...source.matchAll(rx)].map((m) => ({ start: m.index, length: m[0].length }));
24002
24463
  return tolerant.length ? spans(tolerant, "attribute-tolerant") : null;
24003
24464
  };
24004
- const missHint = (needle) => {
24465
+ const missHint = (needle, source, alreadyConsumed) => {
24466
+ if (alreadyConsumed)
24467
+ return `Not found \u2014 but an EARLIER edit in this same edits= array already replaced this exact string. The array is applied in order against one body, so the second pass had nothing left to match. This is the expected result, not a failure.`;
24005
24468
  if (looksEntityEscaped(needle))
24006
24469
  return `Not found \u2014 the search string carries escaped markup (&lt;\u2026&gt;) while note bodies store real tags. Pass the tag literally, e.g. "<h3>Typography</h3>".`;
24007
24470
  if (spansBlockBoundary(needle))
24008
24471
  return `Not found \u2014 this string spans an element boundary (a closing tag followed by an opening one). Anchor the find INSIDE a single element instead, or target the heading directly with section= (with mode="before"/"after" to insert around it).`;
24009
- return `Not found in the note body (exact or attribute-tolerant) \u2014 already replaced on a retry, or the text genuinely differs. Verify with inspect(noteId, content=true), or count occurrences cheaply with inspect(noteId, find="<shorter substring>").`;
24472
+ return `Not found in the note body (exact or attribute-tolerant) \u2014 already replaced on a retry, or the text genuinely differs.`;
24473
+ };
24474
+ const missContext = (needle, source) => {
24475
+ const near = nearestContext(source, needle);
24476
+ return near ? { matchedUpTo: near.fragment, storedNearby: near.context } : {};
24010
24477
  };
24011
24478
  const current = await trilium.getNoteContent(noteId).catch(() => "");
24012
24479
  let working = current;
24013
24480
  const results = [];
24014
24481
  let total = 0;
24482
+ const consumed = new Set;
24015
24483
  for (const e of plan) {
24016
24484
  const applied = applyEdit(working, e.find, e.body, e.nth);
24017
24485
  if (!applied) {
24018
- results.push({ find: e.find, replaced: 0, hint: missHint(e.find) });
24486
+ const alreadyConsumed = consumed.has(e.find);
24487
+ results.push({
24488
+ find: e.find,
24489
+ replaced: 0,
24490
+ hint: missHint(e.find, current, alreadyConsumed),
24491
+ ...alreadyConsumed ? {} : missContext(e.find, working)
24492
+ });
24019
24493
  continue;
24020
24494
  }
24495
+ consumed.add(e.find);
24021
24496
  working = applied.html;
24022
24497
  total += applied.count;
24023
24498
  results.push({ find: e.find, replaced: applied.count, matchMode: applied.matchMode });
@@ -24029,7 +24504,11 @@ find=; to add content next to a heading without touching its body, use mode="bef
24029
24504
  mode: edits ? "edits" : "find-replace",
24030
24505
  replaced: 0,
24031
24506
  date: d,
24032
- ...edits ? { results } : { hint: results[0].hint }
24507
+ ...edits ? { results } : {
24508
+ hint: results[0].hint,
24509
+ ...results[0].matchedUpTo ? { matchedUpTo: results[0].matchedUpTo } : {},
24510
+ ...results[0].storedNearby ? { storedNearby: results[0].storedNearby } : {}
24511
+ }
24033
24512
  });
24034
24513
  }
24035
24514
  await trilium.createRevision(noteId).catch(() => null);
@@ -24062,6 +24541,45 @@ find=; to add content next to a heading without touching its body, use mode="bef
24062
24541
  }
24063
24542
  if ((mode2 === "before" || mode2 === "after") && !section)
24064
24543
  return err("missing_param", `mode="${mode2}" inserts relative to a heading and needs one.`, 'Pass section="<heading text>" alongside it, or use mode="append" for a whole-note addendum.');
24544
+ if (mode2 === "remove" && !section)
24545
+ return err("missing_param", 'mode="remove" deletes a section and needs one.', 'Pass section="<heading text>". To delete the whole note use forget(noteId).');
24546
+ if (mode2 === "remove" && section) {
24547
+ const current = await trilium.getNoteContent(noteId).catch(() => "");
24548
+ const result = setSection(current, section, "", "remove", occurrence ?? 1);
24549
+ if (!result.matched) {
24550
+ return txt4({
24551
+ ok: true,
24552
+ noteId,
24553
+ mode: `section:remove:${section}`,
24554
+ matched: false,
24555
+ date: d,
24556
+ available: result.available,
24557
+ hint: `No "${section}" heading at h2/h3/h4 \u2014 nothing was removed and the body is untouched. Check available= for the note's real heading texts.`
24558
+ });
24559
+ }
24560
+ await trilium.createRevision(noteId).catch(() => null);
24561
+ const stamped = bumpLastUpdated(result.html, d);
24562
+ await trilium.updateNoteContent(noteId, stamped.html);
24563
+ const titledOnRemove = await applyTitle();
24564
+ const iconOnRemove = await applyIcon(noteId, icon);
24565
+ await trilium.updateLabelValue(noteId, "updated", d);
24566
+ if (labelOf2(note, "status") === "dormant")
24567
+ await trilium.updateLabelValue(noteId, "status", "active");
24568
+ const relsOnRemove = relationSnippet(note);
24569
+ return txt4({
24570
+ ok: true,
24571
+ noteId,
24572
+ mode: `section:remove:${section}`,
24573
+ matched: true,
24574
+ headingCount: result.headingCount,
24575
+ date: d,
24576
+ ...result.replacedSubsections?.length ? { removedSubsections: result.replacedSubsections, displacedHint: `The removed section contained ${result.replacedSubsections.length} nested heading(s) \u2014 they went with it. A revision was taken first.` } : {},
24577
+ ...titledOnRemove.retitled ? { retitled: titledOnRemove.retitled } : {},
24578
+ ...iconOnRemove ? { icon: iconOnRemove } : {},
24579
+ ...relsOnRemove ? { relations: relsOnRemove } : {},
24580
+ ...structuralFindings(noteKind, stamped.html)
24581
+ });
24582
+ }
24065
24583
  let finalContent = null;
24066
24584
  let sectionMiss = {};
24067
24585
  let threadEntryResult = null;
@@ -24079,7 +24597,11 @@ find=; to add content next to a heading without touching its body, use mode="bef
24079
24597
  const result = setSection(current, section, html, sectionMode, occurrence ?? 1);
24080
24598
  finalContent = bumpLastUpdated(result.html, d).html;
24081
24599
  await trilium.updateNoteContent(noteId, finalContent);
24082
- sectionResult = { matched: result.matched, headingCount: result.headingCount };
24600
+ sectionResult = {
24601
+ matched: result.matched,
24602
+ headingCount: result.headingCount,
24603
+ ...result.replacedSubsections?.length ? { replacedSubsections: result.replacedSubsections } : {}
24604
+ };
24083
24605
  sectionMiss = {
24084
24606
  ...result.appendedAtLevel ? { appendedAtLevel: result.appendedAtLevel } : {},
24085
24607
  ...result.available?.length ? { available: result.available } : {}
@@ -24121,9 +24643,13 @@ ${block}`), d).html;
24121
24643
  return txt4({
24122
24644
  ok: true,
24123
24645
  noteId,
24124
- mode: body ? section ? `section:${section}` : mode2 ?? "append" : "metadata-only",
24646
+ mode: body ? section ? `section:${mode2 === "before" || mode2 === "after" ? `insert-${mode2}` : mode2 === "append" ? "append-within" : "replace"}:${section}` : mode2 ?? "append" : titled.retitled ? "rename" : iconSet ? "icon" : "no-op",
24125
24647
  date: d,
24126
24648
  ...sectionResult ? { matched: sectionResult.matched, headingCount: sectionResult.headingCount } : {},
24649
+ ...sectionResult?.replacedSubsections?.length ? {
24650
+ replacedSubsections: sectionResult.replacedSubsections,
24651
+ displacedHint: `The replaced section contained ${sectionResult.replacedSubsections.length} nested heading(s) \u2014 they went with it. Re-add any that should have survived.`
24652
+ } : {},
24127
24653
  ...sectionMiss,
24128
24654
  ...sectionHint ? { hint: sectionHint } : {},
24129
24655
  ...threadEntryResult ? { entryId: threadEntryResult.noteId, entryAction: threadEntryResult.action } : {},
@@ -24361,14 +24887,21 @@ Pass a regex with ONE capture group naming the value that should agree:
24361
24887
 
24362
24888
  Without a capture group it degrades to a presence check \u2014 which notes mention this at all.
24363
24889
 
24364
- Matches STORED HTML, so anchor on what is actually stored (tags and entities), not rendered text.
24365
- Escape backslashes. Scope with domain= or kinds= when the phrase is common.`, {
24890
+ Matched against BOTH the stored HTML and a tag-stripped projection of it, so a phrase split by an
24891
+ inline <strong> or <code> tag is found, and so is a pattern deliberately anchored on tags.
24892
+ Escape backslashes. Scope with domain= or kinds= when the phrase is common.
24893
+
24894
+ Scans every in-scope note by default. Trilium's %= backend filter is a LOSSY pre-filter \u2014 it
24895
+ reads a striptags'd copy of the content, so it drops notes this tool should examine \u2014 and on a
24896
+ contradiction sweep a falsely clean result is worse than a slow one. Pass fast=true to use it
24897
+ anyway when scope is wide and speed matters more than completeness.`, {
24366
24898
  pattern: exports_external.string().describe("Regex over note bodies. One capture group = the value that should agree across notes."),
24367
24899
  domain: exports_external.string().optional().describe("Restrict to one knowledge domain"),
24368
24900
  kinds: exports_external.array(exports_external.enum(Kinds)).optional().describe("Restrict to these kinds"),
24369
24901
  includeArchived: exports_external.boolean().optional().describe("Include archived notes (default false)"),
24370
- limit: exports_external.number().optional().describe("Max notes to examine (default 60)")
24371
- }, async ({ pattern, domain, kinds, includeArchived, limit }) => {
24902
+ limit: exports_external.number().optional().describe("Max notes to examine (default 60)"),
24903
+ fast: exports_external.boolean().optional().describe("Pre-filter candidates with Trilium's %= operator \u2014 faster, but its striptags'd corpus silently drops notes (default: false, scan every in-scope note)")
24904
+ }, async ({ pattern, domain, kinds, includeArchived, limit, fast }) => {
24372
24905
  let re;
24373
24906
  try {
24374
24907
  re = new RegExp(pattern, "gi");
@@ -24376,7 +24909,7 @@ Escape backslashes. Scope with domain= or kinds= when the phrase is common.`, {
24376
24909
  return err("invalid_pattern", `Not a valid regular expression: ${e.message}`, "Escape backslashes \u2014 a JSON string needs \\\\d for \\d.");
24377
24910
  }
24378
24911
  const max = limit ?? 60;
24379
- const clauses = [`note.content %= '${escapeQueryValue(pattern)}'`];
24912
+ const clauses = fast ? [`note.content %= '${escapeQueryRegex(pattern)}'`] : ["#noteType"];
24380
24913
  if (domain)
24381
24914
  clauses.push(`#domain='${slugify(domain)}'`);
24382
24915
  const notes = await trilium.searchNotes(clauses.join(" AND "), { ancestorNoteId: b().root, limit: max, includeArchivedNotes: includeArchived ?? false }).then((r) => r.results).catch(() => []);
@@ -24389,41 +24922,53 @@ Escape backslashes. Scope with domain= or kinds= when the phrase is common.`, {
24389
24922
  const byValue = new Map;
24390
24923
  const noCapture = [];
24391
24924
  let hasCaptureGroup = false;
24392
- for (const n of scoped) {
24393
- const content = await trilium.getNoteContent(n.noteId).catch(() => "");
24394
- if (!content)
24395
- continue;
24396
- const stub = { id: n.noteId, title: n.title, kind: ownedLabel(n, "noteType") ?? "" };
24397
- const seen = new Set;
24925
+ const scan = (haystack) => {
24926
+ const values = [];
24927
+ let matched = false;
24398
24928
  re.lastIndex = 0;
24399
24929
  let m;
24400
- let matched = false;
24401
- while ((m = re.exec(content)) !== null) {
24930
+ while ((m = re.exec(haystack)) !== null) {
24402
24931
  matched = true;
24403
24932
  const captured = m.slice(1).find((g) => g !== undefined);
24404
- if (captured !== undefined) {
24405
- hasCaptureGroup = true;
24406
- const value = toText(captured, 120).trim() || captured.trim();
24407
- if (seen.has(value))
24408
- continue;
24409
- seen.add(value);
24410
- if (!byValue.has(value))
24411
- byValue.set(value, []);
24412
- byValue.get(value).push(stub);
24413
- }
24933
+ if (captured !== undefined)
24934
+ values.push(toText(captured, 120).trim() || captured.trim());
24414
24935
  if (m[0] === "")
24415
24936
  re.lastIndex++;
24416
24937
  }
24417
- if (matched && !seen.size)
24938
+ return { values, matched };
24939
+ };
24940
+ let tagSpanning = 0;
24941
+ for (const n of scoped) {
24942
+ const content = await trilium.getNoteContent(n.noteId).catch(() => "");
24943
+ if (!content)
24944
+ continue;
24945
+ const stub = { id: n.noteId, title: n.title, kind: ownedLabel(n, "noteType") ?? "" };
24946
+ const raw = scan(content);
24947
+ const projected = scan(stripTagsWithMap(content).text);
24948
+ if (projected.matched && !raw.matched)
24949
+ tagSpanning++;
24950
+ const seen = new Set;
24951
+ for (const value of [...raw.values, ...projected.values]) {
24952
+ hasCaptureGroup = true;
24953
+ if (seen.has(value))
24954
+ continue;
24955
+ seen.add(value);
24956
+ if (!byValue.has(value))
24957
+ byValue.set(value, []);
24958
+ byValue.get(value).push(stub);
24959
+ }
24960
+ if ((raw.matched || projected.matched) && !seen.size)
24418
24961
  noCapture.push(stub);
24419
24962
  }
24420
24963
  if (!hasCaptureGroup) {
24421
24964
  return txt4({
24422
24965
  mode: "presence",
24423
24966
  pattern,
24967
+ scan: fast ? "fast (%= pre-filter)" : "exhaustive",
24968
+ notesExamined: scoped.length,
24424
24969
  notes: noCapture,
24425
24970
  total: noCapture.length,
24426
- note: noCapture.length === 0 ? "No note body matched that pattern. Regex runs against stored HTML \u2014 anchor on tags and entities, not rendered text." : 'The pattern has no capture group, so this is a presence check only. Add one \u2014 consistency("(\\\\d+) users") \u2014 to compare the values these notes actually assert.'
24971
+ note: noCapture.length === 0 ? `No note body matched that pattern across ${scoped.length} note(s), searched both as stored HTML and tag-stripped.${fast ? " fast=true used Trilium's %= pre-filter, which reads a striptags'd copy and can drop notes \u2014 re-run without it before concluding anything." : " That is evidence about the pattern, not the brain."}` : 'The pattern has no capture group, so this is a presence check only. Add one \u2014 consistency("(\\\\d+) users") \u2014 to compare the values these notes actually assert.'
24427
24972
  });
24428
24973
  }
24429
24974
  const groups = [...byValue.entries()].map(([value, notes2]) => ({ value, count: notes2.length, notes: notes2 })).sort((a, b2) => b2.count - a.count);
@@ -24431,8 +24976,10 @@ Escape backslashes. Scope with domain= or kinds= when the phrase is common.`, {
24431
24976
  return txt4({
24432
24977
  mode: "consistency",
24433
24978
  pattern,
24979
+ scan: fast ? "fast (%= pre-filter)" : "exhaustive",
24434
24980
  ...domain ? { domain: slugify(domain) } : {},
24435
24981
  notesExamined: scoped.length,
24982
+ ...tagSpanning ? { tagSpanning, tagSpanningNote: `${tagSpanning} note(s) matched only once markup was stripped \u2014 the phrase is split by an inline tag there. Before v10.3 those were invisible.` } : {},
24436
24983
  distinctValues: groups.length,
24437
24984
  agreement: groups.length === 0 ? "no-data" : agrees ? "unanimous" : "DISAGREEMENT",
24438
24985
  groups,
@@ -24469,8 +25016,10 @@ remember(revision=) matches on exactly) without reading the note.`, {
24469
25016
  headings: headings.map((h) => ({
24470
25017
  level: h.level,
24471
25018
  text: h.text,
24472
- ...h.occurrence > 1 ? { occurrence: h.occurrence } : {}
25019
+ ...h.occurrence > 1 ? { occurrence: h.occurrence } : {},
25020
+ ...h.raw ? { raw: h.raw } : {}
24473
25021
  })),
25022
+ ...headings.some((h) => h.raw) ? { rawHint: "Headings carrying inline markup also report raw \u2014 their STORED form. section= matches text (use it), find= matches stored HTML (use raw). Building a find= from text alone misses on exactly these headings." } : {},
24474
25023
  sectionLevel: sectionLevelFor(content),
24475
25024
  ...tables.length ? { tables } : {},
24476
25025
  ...report.duplicateHeadings.length ? { duplicateHeadings: report.duplicateHeadings } : {},
@@ -24487,19 +25036,28 @@ surface reads and explore(): reach for it when you need the raw label set, the b
24487
25036
  or the attachment inventory \u2014 confirming a fix landed, debugging drift \u2014 rather than a
24488
25037
  kind-specific summary. Read-only, safe on any note including structural containers.
24489
25038
 
25039
+ Pass section="<heading>" alongside content=true to get one section's raw body instead of the
25040
+ whole thing \u2014 the same heading contract revise(section=) writes through.
25041
+
24490
25042
  Pass find="<literal>" to count occurrences of a literal string in the body \u2014 total plus a
24491
25043
  per-addendum-block breakdown. The staleness-escalation counter: "how many prior entries
24492
- mention this carried flag" becomes one call instead of a full read + manual counting.`, {
25044
+ mention this carried flag" becomes one call instead of a full read + manual counting. On zero
25045
+ occurrences it returns the nearest fragment that IS present and the stored text around it, so a
25046
+ miss is diagnosed in the same call rather than in three more.`, {
24493
25047
  noteId: exports_external.string().describe("Note to inspect"),
24494
25048
  content: exports_external.boolean().optional().describe("Include the note's raw body content (default: false)"),
25049
+ section: exports_external.string().optional().describe("With content=true, return only this heading's section rather than the whole body"),
24495
25050
  find: exports_external.string().optional().describe("Literal string to count in the body \u2014 returns total occurrences + per-addendum-block counts (flag-staleness tracking)")
24496
- }, async ({ noteId, content, find }) => {
25051
+ }, async ({ noteId, content, section, find }) => {
24497
25052
  const [note, attachments, rawBody] = await Promise.all([
24498
25053
  trilium.getNote(noteId),
24499
25054
  trilium.getNoteAttachments(noteId).catch(() => []),
24500
25055
  content || find ? trilium.getNoteContent(noteId).catch(() => "") : Promise.resolve(undefined)
24501
25056
  ]);
24502
- const body = content ? rawBody : undefined;
25057
+ let sectionRead = null;
25058
+ if (content && section && rawBody !== undefined)
25059
+ sectionRead = getSection(rawBody, section);
25060
+ const body = content ? sectionRead ? sectionRead.content : rawBody : undefined;
24503
25061
  let findReport;
24504
25062
  if (find && rawBody !== undefined) {
24505
25063
  const countIn = (s) => s.split(find).length - 1;
@@ -24525,11 +25083,19 @@ mention this carried flag" becomes one call instead of a full read + manual coun
24525
25083
  }
24526
25084
  flush(rawBody.length);
24527
25085
  if (!last) {
24528
- const total = countIn(rawBody);
24529
- if (total > 0)
24530
- blocks.push({ block: "(body)", count: total });
24531
- }
24532
- findReport = { find, total: countIn(rawBody), blocks };
25086
+ const total2 = countIn(rawBody);
25087
+ if (total2 > 0)
25088
+ blocks.push({ block: "(body)", count: total2 });
25089
+ }
25090
+ const total = countIn(rawBody);
25091
+ const near = total === 0 ? nearestContext(rawBody, find) : null;
25092
+ findReport = {
25093
+ find,
25094
+ total,
25095
+ blocks,
25096
+ ...near ? { matchedUpTo: near.fragment, storedNearby: near.context } : {},
25097
+ ...total === 0 && !near ? { hint: "Not present, and no fragment of it is either \u2014 the string is unrelated to this note's content." } : {}
25098
+ };
24533
25099
  }
24534
25100
  const labels = note.attributes.filter((a) => a.type === "label").map((a) => ({ name: a.name, value: a.value, ...a.isInheritable ? { inheritable: true } : {} }));
24535
25101
  const relations = relationSnippet(note, 50);
@@ -24549,9 +25115,223 @@ mention this carried flag" becomes one call instead of a full read + manual coun
24549
25115
  parentNoteIds: note.parentNoteIds,
24550
25116
  childNoteIds: note.childNoteIds,
24551
25117
  ...findReport ? { findReport } : {},
25118
+ ...sectionRead ? sectionRead.matched ? { section, sectionMatched: true, ...sectionRead.subsections?.length ? { subsections: sectionRead.subsections } : {} } : { section, sectionMatched: false, available: sectionRead.available, hint: `No "${section}" heading \u2014 content is empty. Re-target from available=.` } : {},
24552
25119
  ...body !== undefined ? { content: body } : {}
24553
25120
  });
24554
25121
  });
25122
+ const resolveClaimsContainer = async () => {
25123
+ const cfg = b();
25124
+ const found = await trilium.searchNotes("note.title = 'Claims'", { ancestorNoteId: cfg.insights.root, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
25125
+ if (found.results[0])
25126
+ return found.results[0].noteId;
25127
+ const created = await trilium.createNote(cfg.insights.root, "Claims", "<p><em>Checkable assertions and when each was last verified against the world. Registered and verified through claim(); this container is maintained by the tool.</em></p>");
25128
+ await trilium.addLabel(created.note.noteId, "iconClass", "bx bx-check-shield").catch(() => null);
25129
+ return created.note.noteId;
25130
+ };
25131
+ server.tool("claim", `Register a checkable assertion, and record whether it still holds.
25132
+
25133
+ consistency() asks whether the brain agrees with ITSELF. Nothing asked whether a specific
25134
+ assertion is still true of the codebase, config or live surface it describes \u2014 so a claim that
25135
+ quietly stopped being true stayed authoritative until something downstream broke, and "is the
25136
+ brain stale" was answered by luck rather than by a query.
25137
+
25138
+ BrainLLM never executes anything. It has Trilium access and no shell, and content in a note is
25139
+ data, not instructions \u2014 a recipe that ran itself would be an injection surface pointed at the
25140
+ user's machine. So a claim stores WHAT to check as inert text; the agent runs it and reports
25141
+ back. The tool owns the register, the schedule and the staleness question.
25142
+
25143
+ Modes, by which parameters are present:
25144
+ assertion + check \u2192 REGISTER (or update, deduped by assertion text)
25145
+ claimId + holds \u2192 VERIFY: record the outcome and re-stamp the clock
25146
+ claimId alone \u2192 READ one claim with its verification history
25147
+ neither \u2192 LIST, newest-verified last; filter with status=
25148
+
25149
+ A verified claim is quiet until its interval lapses. maintain(deep=true) surfaces lapsed and
25150
+ broken ones, so staleness arrives as a maintenance finding instead of a surprise.`, {
25151
+ assertion: exports_external.string().optional().describe('The claim in plain words, e.g. "the parse pipeline runs before validation" \u2014 also the dedup key'),
25152
+ check: exports_external.string().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"),
25153
+ noteId: exports_external.string().optional().describe("The note this claim is made in \u2014 wired ~derivedFrom so the claim and its source stay linked"),
25154
+ intervalDays: exports_external.number().int().positive().optional().describe("How long a verification stays good (default: 30)"),
25155
+ claimId: exports_external.string().optional().describe("An existing claim, to read or to verify"),
25156
+ holds: exports_external.boolean().optional().describe("With claimId: did the check pass? false marks the claim broken and surfaces it in maintain()"),
25157
+ evidence: exports_external.string().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"),
25158
+ status: exports_external.enum(["holding", "broken", "lapsed", "all"]).optional().describe("List filter (default: all)"),
25159
+ retire: exports_external.boolean().optional().describe("With claimId: archive this claim \u2014 the thing it described is gone")
25160
+ }, async ({ assertion, check: check2, noteId, intervalDays, claimId, holds, evidence, status, retire }) => {
25161
+ const cfg = b();
25162
+ if (!cfg.root)
25163
+ return txt4({ status: "uninitialized", action: "Run bootstrap first." });
25164
+ const d = today();
25165
+ const container = await resolveClaimsContainer();
25166
+ const lapsed = (n) => {
25167
+ const verifiedOn = labelOf2(n, "verified");
25168
+ if (!verifiedOn)
25169
+ return true;
25170
+ const days = Number(labelOf2(n, "interval") ?? 30);
25171
+ return verifiedOn < new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
25172
+ };
25173
+ const stateOf = (n) => labelOf2(n, "claimState") === "broken" ? "broken" : lapsed(n) ? "lapsed" : "holding";
25174
+ const row = (n) => ({
25175
+ claimId: n.noteId,
25176
+ assertion: n.title,
25177
+ state: stateOf(n),
25178
+ lastVerified: labelOf2(n, "verified") ?? "never",
25179
+ intervalDays: Number(labelOf2(n, "interval") ?? 30)
25180
+ });
25181
+ if (claimId) {
25182
+ const note = await trilium.getNote(claimId).catch(() => null);
25183
+ if (!note || labelOf2(note, "noteType") !== "claim")
25184
+ return err("not_found", `${claimId} is not a claim.`, "List them with claim() and no arguments.");
25185
+ if (retire) {
25186
+ await trilium.addLabel(claimId, "archived", "").catch(() => null);
25187
+ return txt4({ ok: true, claimId, action: "retired", assertion: note.title });
25188
+ }
25189
+ if (holds === undefined) {
25190
+ const content2 = await trilium.getNoteContent(claimId).catch(() => "");
25191
+ return txt4({ ...row(note), check: getSection(content2, "Check").content, history: getSection(content2, "Verifications").content, relations: relationSnippet(note) });
25192
+ }
25193
+ if (!evidence)
25194
+ return err("missing_param", "A verification needs evidence.", 'Pass evidence="<what you actually observed>" \u2014 the output, the count, the response. Recording a verdict without it makes the register a record of opinions.');
25195
+ const content = await trilium.getNoteContent(claimId).catch(() => "");
25196
+ const entry = `<p><strong>${d} \u2014 ${holds ? "HOLDS" : "BROKEN"}</strong>: ${escapeHtml(evidence)}</p>`;
25197
+ const updated = setSection(content, "Verifications", entry, "append");
25198
+ await trilium.createRevision(claimId).catch(() => null);
25199
+ await trilium.updateNoteContent(claimId, updated.html);
25200
+ await trilium.updateLabelValue(claimId, "verified", d).catch(() => null);
25201
+ await trilium.updateLabelValue(claimId, "claimState", holds ? "holding" : "broken").catch(() => null);
25202
+ await trilium.updateLabelValue(claimId, "updated", d).catch(() => null);
25203
+ const refreshed = await trilium.getNote(claimId).catch(() => note);
25204
+ return txt4({
25205
+ ok: true,
25206
+ action: holds ? "verified" : "marked-broken",
25207
+ ...row(refreshed),
25208
+ ...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." }
25209
+ });
25210
+ }
25211
+ if (assertion) {
25212
+ if (!check2)
25213
+ return err("missing_param", "A claim needs a check.", 'Pass check="<command, query, file path or URL that settles it>". A claim nobody can verify is just a sentence; the check is what makes it a claim.');
25214
+ const { title } = normalizeTitle(assertion);
25215
+ const existing = await trilium.searchNotes("#noteType=claim", { ancestorNoteId: container, fastSearch: true, limit: 200 }).then((r) => r.results.find((n) => sameTitle(n.title, title))).catch(() => {
25216
+ return;
25217
+ });
25218
+ const body = `<h2>Check</h2>
25219
+ <p>${escapeHtml(check2)}</p>
25220
+ ` + `<p><em>Inert by design \u2014 BrainLLM never runs this. Run it yourself, then record the outcome with claim(claimId, holds, evidence).</em></p>
25221
+ ` + `<h2>Verifications</h2>
25222
+ `;
25223
+ if (existing) {
25224
+ const current = await trilium.getNoteContent(existing.noteId).catch(() => "");
25225
+ await trilium.createRevision(existing.noteId).catch(() => null);
25226
+ await trilium.updateNoteContent(existing.noteId, setSection(current, "Check", `<p>${escapeHtml(check2)}</p>`, "replace").html);
25227
+ if (intervalDays)
25228
+ await trilium.updateLabelValue(existing.noteId, "interval", String(intervalDays)).catch(() => null);
25229
+ return txt4({ ok: true, action: "updated", ...row(existing), note: "A claim with this assertion already existed \u2014 its check was updated and its verification history kept." });
25230
+ }
25231
+ const created = await trilium.createNote(container, title, body);
25232
+ const id = created.note.noteId;
25233
+ await trilium.addLabel(id, "noteType", "claim");
25234
+ await trilium.addLabel(id, "created", d);
25235
+ await trilium.addLabel(id, "interval", String(intervalDays ?? 30));
25236
+ await trilium.addLabel(id, "claimState", "unverified");
25237
+ await trilium.addLabel(id, "iconClass", "bx bx-check-shield").catch(() => null);
25238
+ if (noteId)
25239
+ await trilium.addRelation(id, "derivedFrom", noteId).catch(() => null);
25240
+ return txt4({
25241
+ ok: true,
25242
+ action: "registered",
25243
+ claimId: id,
25244
+ assertion: title,
25245
+ intervalDays: intervalDays ?? 30,
25246
+ location: "Insights \u2192 Claims",
25247
+ next: `Run the check, then record the result: claim(claimId="${id}", holds=true|false, evidence="<what you observed>"). Until then it is unverified, not holding.`
25248
+ });
25249
+ }
25250
+ const all = await trilium.searchNotes("#noteType=claim", { ancestorNoteId: container, fastSearch: true, limit: 200 }).then((r) => r.results).catch(() => []);
25251
+ const rows = all.map(row).filter((r) => !status || status === "all" || r.state === status);
25252
+ const broken = rows.filter((r) => r.state === "broken").length;
25253
+ const stale = rows.filter((r) => r.state === "lapsed").length;
25254
+ return txt4({
25255
+ total: rows.length,
25256
+ ...broken ? { broken } : {},
25257
+ ...stale ? { needingReverification: stale } : {},
25258
+ claims: rows.sort((a, b2) => a.lastVerified.localeCompare(b2.lastVerified)),
25259
+ ...all.length === 0 ? { note: 'No claims registered. Register one with claim(assertion="\u2026", check="\u2026") \u2014 the assertions worth registering are the ones that would be expensive to discover had gone stale.' } : {}
25260
+ });
25261
+ });
25262
+ server.tool("diff", `What changed in a note \u2014 the revision snapshot against the body as it stands now.
25263
+
25264
+ Every content write takes a revision first, and until now nothing could read one back: verifying
25265
+ a run of surgical edits on a large note meant re-reading the whole thing, or trusting the
25266
+ receipts. Trusting receipts is exactly how a section= replace that silently displaced four
25267
+ subsections went unnoticed.
25268
+
25269
+ Called with only a noteId, this diffs the MOST RECENT revision against current content \u2014 "what
25270
+ did my last write actually do". Pass revisionId to compare against a specific earlier one; the
25271
+ revisions list comes back on every call, newest first, so the usual flow is one call to see what
25272
+ exists and a second to pick.
25273
+
25274
+ Reports changed lines with a little context, plus added/removed counts. Both sides are stored
25275
+ HTML, so a formatting-only change is a real difference and shows as one.`, {
25276
+ noteId: exports_external.string().describe("Note to diff"),
25277
+ revisionId: exports_external.string().optional().describe("Compare against this revision (default: the most recent one)"),
25278
+ context: exports_external.number().int().min(0).max(10).optional().describe("Unchanged lines to show around each change (default: 1)")
25279
+ }, async ({ noteId, revisionId, context }) => {
25280
+ const revisions = await trilium.getNoteRevisions(noteId).catch(() => []);
25281
+ if (!revisions.length)
25282
+ return txt4({ noteId, note: "No revisions \u2014 this note has not been written through a content-mutating tool yet, or its revisions have been pruned.", revisions: [] });
25283
+ const target = revisionId ? revisions.find((r) => r.revisionId === revisionId) : revisions[0];
25284
+ if (!target)
25285
+ return err("not_found", `Revision ${revisionId} does not belong to note ${noteId}.`, `Available: ${revisions.slice(0, 10).map((r) => r.revisionId).join(", ")}`);
25286
+ const [before, after] = await Promise.all([
25287
+ trilium.getRevisionContent(target.revisionId).catch(() => ""),
25288
+ trilium.getNoteContent(noteId).catch(() => "")
25289
+ ]);
25290
+ const index = revisions.map((r) => ({
25291
+ revisionId: r.revisionId,
25292
+ dateCreated: r.dateCreated?.slice(0, 16),
25293
+ size: r.contentLength,
25294
+ ...r.revisionId === target.revisionId ? { compared: true } : {}
25295
+ }));
25296
+ if (before === after) {
25297
+ return txt4({
25298
+ noteId,
25299
+ comparedTo: target.revisionId,
25300
+ identical: true,
25301
+ revisions: index,
25302
+ note: "The snapshot and the current body are byte-identical \u2014 the write after this revision changed nothing, or the revision was taken after it."
25303
+ });
25304
+ }
25305
+ const lines = (s) => s.replace(/></g, `>
25306
+ <`).split(`
25307
+ `);
25308
+ const a = lines(before);
25309
+ const bLines = lines(after);
25310
+ let head = 0;
25311
+ while (head < a.length && head < bLines.length && a[head] === bLines[head])
25312
+ head++;
25313
+ let tail = 0;
25314
+ while (tail < a.length - head && tail < bLines.length - head && a[a.length - 1 - tail] === bLines[bLines.length - 1 - tail])
25315
+ tail++;
25316
+ const pad2 = context ?? 1;
25317
+ const removed = a.slice(head, a.length - tail);
25318
+ const added = bLines.slice(head, bLines.length - tail);
25319
+ const cap = (arr) => arr.length > 40 ? [...arr.slice(0, 40), `\u2026 ${arr.length - 40} more line(s)`] : arr;
25320
+ return txt4({
25321
+ noteId,
25322
+ comparedTo: target.revisionId,
25323
+ revisionDate: target.dateCreated?.slice(0, 16),
25324
+ identical: false,
25325
+ sizeBefore: before.length,
25326
+ sizeAfter: after.length,
25327
+ contextBefore: cap(a.slice(Math.max(0, head - pad2), head)),
25328
+ removed: cap(removed),
25329
+ added: cap(added),
25330
+ contextAfter: cap(a.slice(a.length - tail, a.length - tail + pad2)),
25331
+ summary: `${removed.length} block(s) removed, ${added.length} added, ${after.length - before.length >= 0 ? "+" : ""}${after.length - before.length} characters.`,
25332
+ revisions: index
25333
+ });
25334
+ });
24555
25335
  server.tool("attach", `Attach a raw artifact (file, image, code blob, document) to a note \u2014 or read one back.
24556
25336
  Dual-mode by the content param:
24557
25337
  content provided \u2192 UPSERT by title: creates the attachment, or replaces the existing
@@ -24684,18 +25464,27 @@ domain="<name>" narrows the deep passes to one domain's notes \u2014 the equival
24684
25464
  scoping addendum() already has, so a scoped agent's flags arrive in its own lane instead of it
24685
25465
  re-deriving each run that the cross-venture findings belong to someone else.
24686
25466
 
25467
+ repair=[noteId, \u2026] fixes an entity-corrupted body in place instead of reporting it: one level of
25468
+ double-escaping is unwound ("&amp;lt;" \u2192 "&lt;", "&amp;nbsp;" \u2192 "&nbsp;"), with a revision taken
25469
+ first. The substitution is always the same one, so it is a first-class action rather than
25470
+ something each caller reinvents with revise(mode="replace"). Compose with dryRun to see the
25471
+ outcome without writing. A note that DOCUMENTS the signature rather than carrying it comes back
25472
+ unchanged and is reported as such \u2014 ack= those.
25473
+
24687
25474
  coverage names any pass that hit a cap, so a short list is never mistaken for a clean one.`, {
24688
25475
  deep: exports_external.boolean().optional().describe("Deep pass: stale-review + orphan/sink + structural lint + duplicate titles across Memory/Threads and Knowledge (default: false)"),
24689
25476
  dryRun: exports_external.boolean().optional().describe("Report what would change without changing it"),
24690
25477
  domain: exports_external.string().optional().describe("Narrow the deep passes to one domain's notes (slugged server-side) \u2014 for scoped agents working a single lane"),
24691
- ack: exports_external.array(exports_external.string()).optional().describe("Note ids to mark reviewed-and-correct \u2014 suppresses their findings until the note's content changes")
24692
- }, async ({ deep, dryRun, domain, ack }) => {
25478
+ ack: exports_external.array(exports_external.string()).optional().describe("Note ids to mark reviewed-and-correct \u2014 suppresses their findings until the note's content changes"),
25479
+ repair: exports_external.array(exports_external.string()).optional().describe("Note ids to auto-repair entity corruption on \u2014 unwinds one level of double-escaping, revision taken first")
25480
+ }, async ({ deep, dryRun, domain, ack, repair }) => {
24693
25481
  await markStep("maintain");
24694
25482
  const report = await sweep(trilium, b(), {
24695
25483
  deep: deep ?? false,
24696
25484
  dryRun: dryRun ?? false,
24697
25485
  ...domain ? { domain } : {},
24698
- ...ack?.length ? { ack } : {}
25486
+ ...ack?.length ? { ack } : {},
25487
+ ...repair?.length ? { repair } : {}
24699
25488
  });
24700
25489
  return txt4(report);
24701
25490
  });
@@ -25056,6 +25845,7 @@ Structural containers are excluded; only content notes appear.`, {
25056
25845
  id: n.noteId,
25057
25846
  title: n.title,
25058
25847
  kind: labelOf2(n, "noteType"),
25848
+ parent: n.parentNoteIds?.[0],
25059
25849
  status: labelOf2(n, "status") ?? undefined,
25060
25850
  created: labelOf2(n, "created") ?? n.dateCreated.slice(0, 10),
25061
25851
  modified: n.dateModified.slice(0, 10),
@@ -25103,6 +25893,128 @@ Structural containers are excluded; only content notes appear.`, {
25103
25893
  const total = masterAll.length + llmSingletons.length + llmDiary.length + sessions.length + threads.length + kMaster.length + kDomains.length + insights.length;
25104
25894
  return txt4({ total, areas });
25105
25895
  });
25896
+ server.tool("assembly", `What the brain HOLDS \u2014 every note by title, grouped under the surface it lives in, with each
25897
+ surface's own purpose alongside it. The awareness read: "what do I already know here", answered
25898
+ in one call before deciding whether to look something up.
25899
+
25900
+ Distinct from brain(), which is an inventory: brain() returns id, kind, status, parent, dates and
25901
+ relations for every note, and is what you want when auditing or locating one. assembly() answers
25902
+ a different question and is shaped for it:
25903
+
25904
+ - **Dated collections collapse.** Sessions, diary entries and logs are titled [yyyy-mm-dd], so
25905
+ listing them is a wall of dates carrying no information about content. They come back as a
25906
+ count and a span instead. This is the difference between a listing and an awareness read \u2014 the
25907
+ full titles are one brain() call away when you actually need them.
25908
+ - **Domains nest.** Each domain book appears with its own information and sources notes beneath
25909
+ it, which is the real shape of what the brain knows rather than the flat list brain() returns.
25910
+ - **Threads group by status**, newest activity first \u2014 the working set is the actionable part.
25911
+ - **Purposes are read from the container notes themselves**, the text bootstrap engraved on them,
25912
+ so this can never drift from what the brain actually says about itself.
25913
+
25914
+ Pass area= to zoom into one surface and get its full detail.`, {
25915
+ area: exports_external.enum(["master", "llm", "memory", "knowledge", "insights"]).optional().describe("Zoom into one surface instead of all five"),
25916
+ includeArchived: exports_external.boolean().optional().describe("Include archived/resolved notes (default: false)")
25917
+ }, async ({ area, includeArchived }) => {
25918
+ const cfg = b();
25919
+ if (!cfg.root)
25920
+ return txt4({ status: "uninitialized", action: "Run bootstrap first." });
25921
+ const fetchFrom = async (id) => {
25922
+ if (!id)
25923
+ return [];
25924
+ return trilium.searchNotes("#noteType", {
25925
+ ancestorNoteId: id,
25926
+ fastSearch: true,
25927
+ limit: 400,
25928
+ includeArchivedNotes: includeArchived ?? false,
25929
+ orderBy: "dateModified",
25930
+ orderDirection: "desc"
25931
+ }).then((r) => r.results).catch(() => []);
25932
+ };
25933
+ const purposeOf = async (id) => {
25934
+ if (!id)
25935
+ return;
25936
+ const content = await trilium.getNoteContent(id).catch(() => "");
25937
+ const text = toText(content, 400).trim();
25938
+ return text || undefined;
25939
+ };
25940
+ const dateOf = (n) => labelOf2(n, "created") ?? n.dateCreated.slice(0, 10);
25941
+ const span = (notes, label2) => {
25942
+ if (!notes.length)
25943
+ return { count: 0, note: `No ${label2} yet.` };
25944
+ const dates = notes.map(dateOf).sort();
25945
+ return {
25946
+ count: notes.length,
25947
+ earliest: dates[0],
25948
+ latest: dates[dates.length - 1],
25949
+ note: `${notes.length} ${label2}, ${dates[0]} \u2192 ${dates[dates.length - 1]}. Titles are all [yyyy-mm-dd] \u2014 read one with its surface tool, or brain() for the full list.`
25950
+ };
25951
+ };
25952
+ const titles = (notes) => notes.map((n) => n.title);
25953
+ const want = (a) => !area || area === a;
25954
+ const out = {};
25955
+ if (want("master")) {
25956
+ const notes = await fetchFrom(cfg.master.root);
25957
+ out.Master = { purpose: await purposeOf(cfg.master.root), singletons: titles(notes) };
25958
+ }
25959
+ if (want("llm")) {
25960
+ const [all, diary] = await Promise.all([fetchFrom(cfg.llm.root), fetchFrom(cfg.llm.diary)]);
25961
+ const diaryIds = new Set(diary.map((n) => n.noteId));
25962
+ out.LLM = {
25963
+ purpose: await purposeOf(cfg.llm.root),
25964
+ singletons: titles(all.filter((n) => !diaryIds.has(n.noteId))),
25965
+ diary: span(diary, "diary entries")
25966
+ };
25967
+ }
25968
+ if (want("memory")) {
25969
+ const [threads, sessions] = await Promise.all([fetchFrom(cfg.memory.threads), fetchFrom(cfg.memory.sessions)]);
25970
+ const books = threads.filter((n) => labelOf2(n, "noteType") === "thread");
25971
+ const byStatus = (s) => titles(books.filter((n) => (labelOf2(n, "status") ?? "active") === s));
25972
+ out.Memory = {
25973
+ purpose: await purposeOf(cfg.memory.root),
25974
+ threads: {
25975
+ active: byStatus("active"),
25976
+ ...byStatus("dormant").length ? { dormant: byStatus("dormant") } : {},
25977
+ ...byStatus("eternal").length ? { eternal: byStatus("eternal") } : {},
25978
+ ...includeArchived && byStatus("resolved").length ? { resolved: byStatus("resolved") } : {}
25979
+ },
25980
+ sessions: span(sessions.filter((n) => labelOf2(n, "noteType") === "session"), "sessions")
25981
+ };
25982
+ }
25983
+ if (want("knowledge")) {
25984
+ const [master, domainNotes] = await Promise.all([fetchFrom(cfg.knowledge.master), fetchFrom(cfg.knowledge.domains)]);
25985
+ const books = domainNotes.filter((n) => labelOf2(n, "noteType") === "domain");
25986
+ const domains = books.map((book) => {
25987
+ const children = domainNotes.filter((n) => n.noteId !== book.noteId && n.parentNoteIds?.includes(book.noteId));
25988
+ return {
25989
+ domain: book.title,
25990
+ sources: children.some((c) => labelOf2(c, "noteType") === "sources"),
25991
+ notes: titles(children.filter((c) => labelOf2(c, "noteType") === "information"))
25992
+ };
25993
+ });
25994
+ const orphaned = domainNotes.filter((n) => labelOf2(n, "noteType") !== "domain" && !books.some((bk) => n.parentNoteIds?.includes(bk.noteId)));
25995
+ out.Knowledge = {
25996
+ purpose: await purposeOf(cfg.knowledge.root),
25997
+ aboutTheUser: titles(master),
25998
+ domains,
25999
+ ...orphaned.length ? { unparented: titles(orphaned), hint: "These carry a knowledge kind but sit under no domain book \u2014 inspect() them for their real parent." } : {}
26000
+ };
26001
+ }
26002
+ if (want("insights")) {
26003
+ const [logs, all] = await Promise.all([fetchFrom(cfg.insights.logs), fetchFrom(cfg.insights.root)]);
26004
+ const claims = all.filter((n) => labelOf2(n, "noteType") === "claim");
26005
+ out.Insights = {
26006
+ purpose: await purposeOf(cfg.insights.root),
26007
+ logs: span(logs.filter((n) => labelOf2(n, "noteType") === "log"), "daily logs"),
26008
+ claims: claims.length ? { count: claims.length, assertions: titles(claims), note: "Each claim's title IS its assertion. claim(claimId) reads one with its verification history." } : { count: 0, note: "No claims registered \u2014 nothing is currently being checked against the world." }
26009
+ };
26010
+ }
26011
+ return txt4({
26012
+ brain: await purposeOf(cfg.root),
26013
+ ...area ? { scope: area } : {},
26014
+ ...out,
26015
+ ...includeArchived ? {} : { note: "Archived and resolved notes are excluded \u2014 pass includeArchived=true to see them." }
26016
+ });
26017
+ });
25106
26018
  server.tool("bootstrap", `Initialize the BrainLLM structure in Trilium (idempotent \u2014 safe to re-run; refreshes config
25107
26019
  if the structure already exists). Creates the five areas \u2014 Master (Biography/Goals/Preferences),
25108
26020
  LLM (Responsibilities/Protocols/Diary), Memory (Sessions/Threads), Knowledge (Master/Domains),
@@ -25116,9 +26028,24 @@ immediately, no restart needed.`, {}, async () => {
25116
26028
  return { id: child.noteId, title: child.title };
25117
26029
  }));
25118
26030
  const saved = saveConfig(brainRef.config);
26031
+ const refreshed = [];
26032
+ for (const [id, purpose] of containerPurposes(brainRef.config)) {
26033
+ if (!id)
26034
+ continue;
26035
+ const current = await trilium.getNoteContent(id).catch(() => null);
26036
+ if (current === null)
26037
+ continue;
26038
+ const wanted = purposeContent(purpose);
26039
+ if (toText(current, 400).trim() === toText(wanted, 400).trim())
26040
+ continue;
26041
+ await trilium.updateNoteContent(id, wanted).catch(() => null);
26042
+ const note = await trilium.getNote(id).catch(() => null);
26043
+ refreshed.push(note?.title ?? id);
26044
+ }
25119
26045
  return txt4({
25120
26046
  status: "already_initialized",
25121
26047
  message: `BrainLLM structure exists. Config refreshed at: ${saved}`,
26048
+ ...refreshed.length ? { purposesRefreshed: refreshed, note: "These containers described themselves with text that no longer matched the canonical purpose, and have been re-engraved. Container notes are unreachable through revise(), so bootstrap is the only path that can correct them." } : {},
25122
26049
  root: { id: existing.noteId, title: existing.title },
25123
26050
  children
25124
26051
  });
@@ -25715,7 +26642,7 @@ function createServer(origin = null) {
25715
26642
  const s = new McpServer({
25716
26643
  name: "BrainLLM",
25717
26644
  title: "BrainLLM",
25718
- version: "10.2.0",
26645
+ version: "10.4.0",
25719
26646
  icons: brandingIcons(origin)
25720
26647
  });
25721
26648
  registerTools(s, trilium, brainRef, mode);