brainllm 12.4.1 → 12.5.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
@@ -20523,7 +20523,7 @@ class BinaryContentError extends Error {
20523
20523
  }
20524
20524
  }
20525
20525
  function isTextMime(mime) {
20526
- const value = (mime ?? "text/plain").split(";", 1)[0].trim().toLowerCase();
20526
+ const value = (mime ?? "").split(";", 1)[0].trim().toLowerCase() || "text/plain";
20527
20527
  return value.startsWith("text/") || new Set([
20528
20528
  "application/json",
20529
20529
  "application/ld+json",
@@ -20602,6 +20602,9 @@ function isOwnedAttribute(note, attribute) {
20602
20602
  function ownedLabel(note, name) {
20603
20603
  return note.attributes.find((a) => a.type === "label" && a.name === name && isOwnedAttribute(note, a))?.value;
20604
20604
  }
20605
+ function isCollectionThread(note) {
20606
+ return ownedLabel(note, "threadShape") === "collection";
20607
+ }
20605
20608
  function relationSnippet(note, max = 8) {
20606
20609
  const rels = note.attributes.filter((a) => isOwnedAttribute(note, a) && a.type === "relation" && a.name !== "template").slice(0, max).map((a) => ({ relation: a.name, toNoteId: a.value }));
20607
20610
  return rels.length ? rels : undefined;
@@ -21161,6 +21164,54 @@ class TriliumClient {
21161
21164
  }
21162
21165
  }
21163
21166
 
21167
+ // src/icons.ts
21168
+ var KIND_ICONS = {
21169
+ biography: "bx bx-id-card",
21170
+ goals: "bx bx-target-lock",
21171
+ preferences: "bx bxs-smile",
21172
+ responsibilities: "bx bxs-analyse",
21173
+ protocols: "bx bx-shield-quarter",
21174
+ selfcorrection: "bx bxs-adjust-alt",
21175
+ diary: "bx bx-book-heart",
21176
+ session: "bx bx-calendar-check",
21177
+ thread: "bx bx-conversation",
21178
+ threadEntry: "bx bx-conversation",
21179
+ user: "bx bx-user",
21180
+ domain: "bx bx-folder",
21181
+ information: "bx bx-info-circle",
21182
+ sources: "bx bx-link",
21183
+ claim: "bx bx-check-shield"
21184
+ };
21185
+ var FALLBACK_ICON = "bx bx-note";
21186
+ var ICON_EXEMPT = new Set(["log"]);
21187
+ function defaultIcon(note, parent) {
21188
+ const kind = ownedLabel(note, "noteType");
21189
+ if (kind && ICON_EXEMPT.has(kind))
21190
+ return null;
21191
+ if (kind === "threadEntry")
21192
+ return parent && ownedLabel(parent, "iconClass") || KIND_ICONS.threadEntry;
21193
+ return kind && KIND_ICONS[kind] || FALLBACK_ICON;
21194
+ }
21195
+ async function ensureIcon(trilium, noteId, known) {
21196
+ const note = known ?? await trilium.getNote(noteId).catch(() => null);
21197
+ if (!note || ownedLabel(note, "iconClass"))
21198
+ return;
21199
+ let parent = null;
21200
+ if (ownedLabel(note, "noteType") === "threadEntry" && note.parentNoteIds[0]) {
21201
+ parent = await trilium.getNote(note.parentNoteIds[0]).catch(() => null);
21202
+ if (parent && !ownedLabel(parent, "iconClass")) {
21203
+ const set = await ensureIcon(trilium, parent.noteId, parent);
21204
+ if (set)
21205
+ parent = await trilium.getNote(parent.noteId).catch(() => parent);
21206
+ }
21207
+ }
21208
+ const cls = defaultIcon(note, parent);
21209
+ if (!cls)
21210
+ return;
21211
+ await trilium.addLabel(noteId, "iconClass", cls).catch(() => null);
21212
+ return cls;
21213
+ }
21214
+
21164
21215
  // src/config.ts
21165
21216
  import { existsSync, readFileSync, writeFileSync } from "fs";
21166
21217
  import { join, dirname } from "path";
@@ -22399,11 +22450,6 @@ var hasHeading = (html, text) => new RegExp(`<h[2-4](?:\\s[^>]*)?>\\s*${text}\\s
22399
22450
  var REQUIRED_SECTIONS = {
22400
22451
  thread: ["Context", "Resolution"],
22401
22452
  sources: ["Sources", "Revision"],
22402
- biography: ["Overview", "Background", "Present"],
22403
- goals: ["Near-term", "Long-term"],
22404
- preferences: ["Communication", "Working style", "Tools and stack"],
22405
- responsibilities: ["Core", "Current priorities"],
22406
- protocols: ["Operating"],
22407
22453
  selfcorrection: ["Corrections"],
22408
22454
  claim: ["Check", "Verifications"]
22409
22455
  };
@@ -22504,115 +22550,102 @@ function domainContent(name) {
22504
22550
  function purposeContent(purpose) {
22505
22551
  return `<p><em>${escapeHtml(purpose)}</em></p>`;
22506
22552
  }
22553
+ var TIMELESS = [
22554
+ "Timeless: what is true regardless of date. No state, version, status, incident or decision history \u2014 state lives in the domain's Current State note, history and decisions in thread entries and sessions, where the date is native",
22555
+ "One sentence is enough if one sentence says it; drop what is rarely relevant to the work",
22556
+ "Merged in place (revise section=/find=), never appended as dated addendum blocks"
22557
+ ];
22507
22558
  var STRUCTURE_RULES = {
22508
22559
  singleton: {
22509
22560
  structure: [
22510
22561
  "`Last updated - <date>` (h4) \u2014 server-maintained on every write",
22511
- "Minimal h3 sections; tables for reference data; numbered bold-lead paragraphs for rule/duty lists"
22562
+ "Minimal h3 sections; tables for reference data; short bold-lead rules for rule/duty lists"
22512
22563
  ],
22513
22564
  rules: [
22514
- "Timeless: no dated incidents, no month-specific references \u2014 current-state only, merged in place",
22515
- "Replace sections (revise section=/find=), never append dated addendum markers"
22565
+ ...TIMELESS,
22566
+ "LLM singletons derive from the master ones: responsibilities serve goals and preferences, protocols serve responsibilities, self-correction holds only general rules learned from mistakes"
22516
22567
  ]
22517
22568
  },
22518
22569
  diary: {
22519
22570
  structure: [
22520
22571
  "`Addendum \u2014 HH:mm` (h2, server-written)",
22521
- "Identification line (h3): `LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]` \u2014 pass identity= and the server injects it",
22522
- "**Experience** (h4) \u2014 unfiltered first-person remarks",
22523
- "**BrainLLM** (h4) \u2014 remarks on BrainLLM itself (bugs, friction, roadmap)"
22572
+ "Identification line (h3): `LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]` \u2014 pass identity=",
22573
+ "**Experience** (h4), then **BrainLLM** (h4)"
22524
22574
  ],
22525
22575
  rules: [
22526
- "One note per day, every write lands as a timestamped addendum block",
22527
- "The identification line is ENFORCED: pass identity= (or lead the body with the h3) \u2014 diary() refuses otherwise",
22528
- "The closing entry is written at the close-protocol step, after remarks()"
22576
+ "One note per day; every write is a timestamped addendum block",
22577
+ "identity= is enforced; the closing entry is written after remarks()"
22529
22578
  ]
22530
22579
  },
22531
22580
  session: {
22532
22581
  structure: [
22533
22582
  "`Addendum \u2014 HH:mm` (h2, server-written)",
22534
- "Identification line (h3): same format as diary \u2014 pass identity= on close()",
22535
- "**Summary** (h4-equivalent, server-rendered) \u2014 factual prose",
22536
- "**Learned** \u2014 durable bullets [+ further detail sections as warranted]"
22583
+ "Identification line (h3) \u2014 pass identity= on close()",
22584
+ "**Summary**, then **Learned**"
22537
22585
  ],
22538
- rules: [
22539
- "Written by close() only; one note per day; chronological record",
22540
- "The identification line is ENFORCED: pass identity= on close() (or lead the summary with the h3) \u2014 close() refuses otherwise"
22541
- ]
22586
+ rules: ["Written by close() only; one note per day; identity= is enforced"]
22542
22587
  },
22543
22588
  thread: {
22544
22589
  structure: [
22545
- "A book note (type=book): server header (thread \xB7 opened <date>) + **Context** (h2) \u2192 **Goal** (h3) \u2014 the goal statement, queried from the user at creation (goal= is required) \u2014 + further h3 context subsections as needed + **Resolution** (h2), exactly one, always the last section, owned by resolve()",
22546
- "One `[yyyy-mm-dd]` threadEntry child per active day, same shape as a diary/session day: `Addendum \u2014 HH:mm` blocks (h2), each with an identification line (h3) + h4 sections",
22547
- "Occasional `Withdrawn \u2014 <date>` / `Recovered \u2014 <date>` markers append directly to the book, near Resolution \u2014 rare lifecycle transitions, not routine content, so they stay put rather than moving to a child"
22590
+ "A book note: header (thread \xB7 opened <date>) \u2192 **Context** (h2) \u2192 **Goal** (h3): what the thread is for, in a sentence or two \u2192 optional h3 standing constraints \u2192 **Resolution** (h2), last, owned by resolve()",
22591
+ "Shape **dated** (default): one `[yyyy-mm-dd]` child per active day, made of `Addendum \u2014 HH:mm` blocks with an identification line \u2014 the thread's history",
22592
+ 'Shape **collection** (#threadShape=collection, set with remember(shape="collection")): one titled child per item, each a maintained document edited in place \u2014 e.g. an ideas list'
22548
22593
  ],
22549
22594
  rules: [
22550
- "remember(kind=thread) requires goal= (or a body already carrying the Context structure) to CREATE the book",
22551
- "Appending to a thread writes into TODAY's child note, never the book \u2014 created on first append of the day; same-day appends merge under a new Addendum \u2014 HH:mm block",
22552
- "Thread appends require the identification line: pass identity= on remember()/revise() (or lead the body with the h3) \u2014 the write is refused otherwise",
22553
- "The book never carries a Resolution smuggled into a body \u2014 resolve() owns it exclusively; day-children never carry one at all",
22554
- "memory() on a thread returns the book's Context/Resolution plus a child-note index \u2014 read a specific day via its own id (or memory(id, date=)). inspect() gives the book's raw content verbatim (no child index) \u2014 that's just Context/Resolution now, not the full history; use memory() first to find the child you actually want, then inspect() that child for its raw labels/attachments",
22555
- "At the close protocol: unthreaded forward/unfinished work prompts the user for thread creation"
22595
+ "Creating a thread requires goal= (ask the user)",
22596
+ "The book holds purpose, never progress: no status, dates, decisions or narrative \u2014 those are dated children or sessions. One exception: a register thread (e.g. Escalations) keeps its single maintained register table in the book",
22597
+ `Dated: appends land in today's child (identity= required). Collection: add with remember(kind="threadEntry", thread=, title=), change with revise(<entry id>)`,
22598
+ "Only resolve() writes the Resolution"
22556
22599
  ]
22557
22600
  },
22558
22601
  threadEntry: {
22559
22602
  structure: [
22560
- "Server header (threadEntry \xB7 <date>)",
22561
- "`Addendum \u2014 HH:mm` (h2) blocks, each with an identification line (h3) \u2014 identical shape to a diary/session day"
22603
+ "Dated: header (threadEntry \xB7 <date>), then `Addendum \u2014 HH:mm` blocks with an identification line",
22604
+ "Collection: a titled maintained document \u2014 minimal h2/h3 sections"
22562
22605
  ],
22563
22606
  rules: [
22564
- "One note per thread per day, created automatically on first append \u2014 never created directly via remember()",
22565
- "Chronological record \u2014 every write lands as a new timestamped block, never merged into prose"
22607
+ "Dated entries are created by appending to the thread and are records (never rewritten)",
22608
+ "Collection entries dedup by title within their thread and are edited in place"
22566
22609
  ]
22567
22610
  },
22568
22611
  sources: {
22569
22612
  structure: [
22570
- "Server header (sources \xB7 domain: <name>)",
22571
- "`Last updated - <date>` (h4) \u2014 server-maintained",
22572
- "**Sources** (h2): the \u2747\uFE0F/\u2705 legend line, then the full source list \u2014 every source (URL, doc, file, \u2026) listed and marked individually with just its emoji; related sources grouped under h3 subheadings",
22573
- "**Revision** (h2): a Source | Marker | Date table, one row per source, current-state only"
22613
+ "Header (sources \xB7 domain) \u2192 `Last updated` (h4)",
22614
+ "**Sources** (h2): the \u2747\uFE0F/\u2705 legend, then every source listed individually with its emoji, grouped under h3s",
22615
+ "**Revision** (h2): Source | Marker | Date, one row per source"
22574
22616
  ],
22575
22617
  rules: [
22576
- "One maintained Sources note per domain \u2014 auto-created with the domain book",
22577
- "A clean maintained document: the Sources section merges via remember(), never dated addendum stacks",
22578
- "Revision rows are upserted by source name \u2014 pass revision=[{source, marker, date}] on remember(kind=sources); re-verifying a source replaces its existing row's Marker/Date in place, it never grows a new row with a re-check-flavored label",
22579
- "The Source column value must exactly match how the source is introduced in the Sources list \u2014 that's the upsert key"
22618
+ 'One per domain, created with the domain; merged via remember(kind="sources"), never stacked',
22619
+ "Revision rows upsert by source name via revision=[{source, marker, date}]; the name must match the Sources list exactly"
22580
22620
  ]
22581
22621
  },
22582
22622
  information: {
22583
22623
  structure: [
22584
- "Server header (information \xB7 domain \xB7 date)",
22585
- "`Last updated: <date>` line \u2014 server-maintained once present",
22586
- "Minimal h3 sections \u2014 current-state truth, revised in place"
22624
+ "Header (information \xB7 domain \xB7 date) \u2192 `Last updated` line (server-maintained)",
22625
+ "Minimal h3 sections"
22587
22626
  ],
22588
22627
  rules: [
22589
- "One consolidated note per sub-category \u2014 never one note per day/run; no dates or run numbers in titles",
22590
- "Every claim traces to a Sources-note entry (the sources gate)"
22628
+ ...TIMELESS,
22629
+ "Exception: the note titled **Current State** holds the domain's measured state \u2014 latest value only, with the date it was measured",
22630
+ "One note per sub-category; no dates or run numbers in titles (\u2264 4 words); every claim traces to the Sources note"
22591
22631
  ]
22592
22632
  },
22593
22633
  user: {
22594
- structure: [
22595
- "Server header (user \xB7 date)",
22596
- "`Last updated - <date>` (h4) \u2014 server-maintained once present",
22597
- "Minimal h3 sections, tables for reference/comparative data"
22598
- ],
22599
- rules: ["Current-state, merged in place; titles \u2264 4 words"]
22634
+ structure: ["Header (user \xB7 date) \u2192 `Last updated` (h4) \u2192 minimal h3 sections, tables for reference data"],
22635
+ rules: [...TIMELESS, "Titles \u2264 4 words"]
22600
22636
  },
22601
22637
  log: {
22602
- structure: ["Server header (log \xB7 date)", "**Created** / **Updated** / **Deleted** (h2) lists"],
22603
- rules: ["Auto-generated by close(); regenerated in place, never stacked"]
22638
+ structure: ["Header (log \xB7 date) \u2192 **Created** / **Updated** / **Deleted** (h2) lists"],
22639
+ rules: ["Generated by close(); regenerated in place"]
22604
22640
  },
22605
22641
  claim: {
22606
22642
  structure: [
22607
- "**Check** (h2) \u2014 how to verify the assertion, as inert text: a command, a query, a file path, a URL",
22608
- "**Verifications** (h2) \u2014 one dated HOLDS/BROKEN line per check, with the evidence observed"
22643
+ "**Check** (h2) \u2014 how to verify, as inert text (command, query, path, URL)",
22644
+ "**Verifications** (h2) \u2014 one dated HOLDS/BROKEN line per check, with evidence"
22609
22645
  ],
22610
22646
  rules: [
22611
- 'Registered and verified only through claim() \u2014 remember(kind="claim") is rejected',
22612
- "The title IS the assertion, and is the dedup key \u2014 re-registering the same assertion updates its check and keeps its history",
22613
- "BrainLLM never executes the Check. It has no shell, and note content is data rather than instructions; the agent runs it and reports back",
22614
- "Verifications are append-only \u2014 the history is the point, so this is one of the few places a dated stack is correct",
22615
- "A verification without evidence is refused: a verdict alone is an assertion about an assertion"
22647
+ "Only claim() writes claims; the title is the assertion and the dedup key",
22648
+ "BrainLLM never executes the check \u2014 the agent runs it and reports back with evidence"
22616
22649
  ]
22617
22650
  }
22618
22651
  };
@@ -22836,6 +22869,13 @@ function isoDaysAgo(days) {
22836
22869
  function reviewKey(n) {
22837
22870
  return n.blobId ?? n.dateModified;
22838
22871
  }
22872
+ var TIMELESS_KINDS = new Set(["thread", "user", "information", "domain"]);
22873
+ var DATED_PROSE_LIMIT = 4;
22874
+ var MONTHS = "Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|June?|July?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?";
22875
+ var DATED_REF = new RegExp(`\\b20\\d\\d-\\d\\d-\\d\\d\\b|\\b\\d{1,2} (?:${MONTHS}) 20\\d\\d\\b|\\b(?:${MONTHS}) \\d{1,2},? 20\\d\\d\\b`, "g");
22876
+ function datedReferences(html) {
22877
+ return (toText(html, Number.MAX_SAFE_INTEGER).match(DATED_REF) ?? []).length;
22878
+ }
22839
22879
  var STRUCTURE_LINT_LIMIT = 40;
22840
22880
  var SECTION_EDIT_RISK_COUNT = 25;
22841
22881
  var SECTION_EDIT_RISK_CHARS = 8000;
@@ -22902,6 +22942,23 @@ async function sweep(trilium, cfg, opts = {}) {
22902
22942
  }
22903
22943
  report.transitions.push(`archived: ${n.title} (thread, dormant past grace)`);
22904
22944
  }
22945
+ const iconless = await trilium.searchNotes("#!iconClass #noteType != log", { ancestorNoteId: cfg.root, includeArchivedNotes: true, limit: 1000 }).then((r) => r.results).catch(() => []);
22946
+ if (iconless.length) {
22947
+ report.scanned += iconless.length;
22948
+ const entryLast = [...iconless].sort((a, b) => Number(ownedLabel(a, "noteType") === "threadEntry") - Number(ownedLabel(b, "noteType") === "threadEntry"));
22949
+ let set = 0;
22950
+ for (const n of entryLast) {
22951
+ if (dryRun) {
22952
+ if (defaultIcon(n))
22953
+ set++;
22954
+ continue;
22955
+ }
22956
+ if (await ensureIcon(trilium, n.noteId, n))
22957
+ set++;
22958
+ }
22959
+ if (set)
22960
+ report.fixed.push(`icons: ${dryRun ? "would set" : "set"} a default icon on ${set} note${set === 1 ? "" : "s"} that had none`);
22961
+ }
22905
22962
  const typedContainers = [
22906
22963
  { id: cfg.memory.threads, kind: "thread", label: "Threads" },
22907
22964
  { id: cfg.memory.sessions, kind: "session", label: "Sessions" },
@@ -23079,6 +23136,14 @@ async function sweep(trilium, cfg, opts = {}) {
23079
23136
  if (structure.duplicateHeadings.length) {
23080
23137
  report.flagged.push(`duplicate heading: ${n.title} [${n.noteId}] \u2014 '${structure.duplicateHeadings.join("', '")}' repeated in one note; merge with revise(section=\u2026, mode=replace) or target one with occurrence=`);
23081
23138
  }
23139
+ const kindHere = ownedLabel(n, "noteType");
23140
+ const isCurrentState = kindHere === "information" && /^current state$/i.test(n.title.trim());
23141
+ if (kindHere && TIMELESS_KINDS.has(kindHere) && !isCurrentState) {
23142
+ const dated = datedReferences(content);
23143
+ if (dated >= DATED_PROSE_LIMIT) {
23144
+ report.flagged.push(`dated prose: ${n.title} [${n.noteId}] \u2014 ${dated} dated references in a timeless ${kindHere} note. ` + `Keep what is true regardless of date; move state to the domain's Current State and history or decisions to a thread entry.`);
23145
+ }
23146
+ }
23082
23147
  const lintKind = ownedLabel(n, "noteType");
23083
23148
  if (lintKind) {
23084
23149
  const missing = missingSections(lintKind, content);
@@ -23302,7 +23367,7 @@ async function hygienePasses(trilium, cfg, report, ctx) {
23302
23367
  if (cfg.memory.threads) {
23303
23368
  const heavy = await trilium.searchNotes(`#noteType=thread #status=active note.childrenCount > ${THREAD_CONSOLIDATION_CHILDREN}`, { ancestorNoteId: cfg.memory.threads, fastSearch: true, limit: 20 }).then((r) => r.results).catch(() => []);
23304
23369
  for (const n of heavy) {
23305
- if (!eligible(n))
23370
+ if (!eligible(n) || ownedLabel(n, "threadShape") === "collection")
23306
23371
  continue;
23307
23372
  report.flagged.push(`consolidate: ${n.title} [${n.noteId}] \u2014 ${n.childNoteIds.length} day-children. Fold what the thread has ESTABLISHED into its Context section with revise(section="Context"), so a successor reads the conclusion instead of walking the history.`);
23308
23373
  }
@@ -23675,12 +23740,7 @@ var txt2 = (obj) => ({
23675
23740
  var AREAS = ["biography", "goals", "preferences"];
23676
23741
  function registerMasterTools(server, trilium, brainRef) {
23677
23742
  const b = () => brainRef.config;
23678
- server.tool("master", `Read a Master singleton: biography, goals, or preferences. Returns its id, content, and
23679
- relation snippet.
23680
-
23681
- section="<heading>" reads ONE section instead of the whole note \u2014 the efficient path on
23682
- preferences, whose schedule tables make it the largest of the three, when the session needs one
23683
- day's blocks rather than the whole week. outline(id) lists the headings.`, {
23743
+ server.tool("master", `Read a master singleton \u2014 biography, goals or preferences \u2014 with its id and relations. section="<heading>" reads one section.`, {
23684
23744
  which: enumType(AREAS),
23685
23745
  section: stringType().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole note"),
23686
23746
  occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)")
@@ -23709,18 +23769,7 @@ day's blocks rather than the whole week. outline(id) lists the headings.`, {
23709
23769
  // src/tools-llm.ts
23710
23770
  function registerLlmTools(server, trilium, brainRef) {
23711
23771
  const b = () => brainRef.config;
23712
- server.tool("llm", `Read an LLM note: responsibilities, protocols or selfcorrection (singletons), or a diary
23713
- entry by id.
23714
-
23715
- selfcorrection holds the assistant's own corrections \u2014 what it got wrong, what generalises, and
23716
- the rule that prevents a repeat. It is the one LLM singleton start() does NOT serve in full: it
23717
- is the largest and the least load-bearing at a first message, so it arrives as section headings
23718
- and is read here on demand. Reach for it when about to repeat a class of work that has gone
23719
- wrong before \u2014 an audit, a large edit, a claim of completeness.
23720
-
23721
- section="<heading>" reads ONE section instead of the whole note \u2014 the efficient path on
23722
- protocols and selfcorrection, the two largest singletons and the ones most often needed in part
23723
- rather than whole. outline(id) lists the headings.`, {
23772
+ server.tool("llm", `Read an LLM singleton \u2014 responsibilities, protocols or selfcorrection \u2014 or a diary entry by id. Self-correction holds general rules learned from mistakes; read it before work that has gone wrong before (audits, large edits, completeness claims). section="<heading>" reads one section.`, {
23724
23773
  which: enumType(["responsibilities", "protocols", "selfcorrection", "diary"]),
23725
23774
  id: stringType().optional().describe("diary only: the entry id from llm_recall"),
23726
23775
  section: stringType().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole note"),
@@ -23757,16 +23806,11 @@ rather than whole. outline(id) lists the headings.`, {
23757
23806
  // src/tools-memory.ts
23758
23807
  function registerMemoryTools(server, trilium, brainRef) {
23759
23808
  const b = () => brainRef.config;
23760
- server.tool("memory", `Read a Memory note in full by id \u2014 a thread or a session. A thread book returns its
23761
- Context/Resolution plus a children index ({id, date, blocks[]}, newest first) instead of a
23762
- flat body \u2014 the day-to-day content lives in those [yyyy-mm-dd] children, not the book. Each
23763
- child is indexed by its ADDENDUM BLOCKS \u2014 marker, identification line, and lead \u2014 rather than a
23764
- slice from the top of the body, which on a record note is the header and therefore identical on
23765
- every child.
23766
-
23767
- Pass date="yyyy-mm-dd" to resolve straight to that day's child in the same call, or call
23768
- memory() again with a child's id from the index. section="<heading>" reads one section instead
23769
- of the whole body \u2014 use it on long sessions and consolidated thread Contexts.`, {
23809
+ server.tool("memory", `Read a Memory note by id \u2014 a thread or a session. A dated thread returns its Context and
23810
+ Resolution plus an index of its [yyyy-mm-dd] children (newest first), each listed by its
23811
+ addendum blocks; date="yyyy-mm-dd" resolves straight to one day. A collection thread returns its
23812
+ Context plus its titled entries (alphabetical, with a lead). Read a child with memory(<child id>).
23813
+ section="<heading>" reads one section.`, {
23770
23814
  id: stringType(),
23771
23815
  date: stringType().optional().describe("Thread books only: resolve directly to this day's child note (yyyy-mm-dd)"),
23772
23816
  section: stringType().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole note"),
@@ -23775,6 +23819,17 @@ of the whole body \u2014 use it on long sessions and consolidated thread Context
23775
23819
  const note = await trilium.getNote(id).catch(() => null);
23776
23820
  if (!note || labelOf(note, "noteType") !== "thread")
23777
23821
  return txt(await readFull(trilium, id, { section, occurrence }));
23822
+ if (isCollectionThread(note)) {
23823
+ const kids = await trilium.searchNotes("#noteType=threadEntry", { ancestorNoteId: id, fastSearch: true, limit: 500 }).catch(() => ({ results: [] }));
23824
+ const entries = await Promise.all(kids.results.filter((c) => c.parentNoteIds.includes(id)).sort((a, z) => a.title.localeCompare(z.title)).map(async (c) => ({
23825
+ id: c.noteId,
23826
+ title: c.title,
23827
+ updated: labelOf(c, "updated") ?? c.dateModified.slice(0, 10),
23828
+ preview: toText(await trilium.getNoteContent(c.noteId).catch(() => ""), 160)
23829
+ })));
23830
+ const full = await readFull(trilium, id, { section, occurrence });
23831
+ return txt({ ...full, shape: "collection", entries });
23832
+ }
23778
23833
  if (date) {
23779
23834
  const child = await trilium.searchNotes(`#noteType=threadEntry #created='${date}'`, { ancestorNoteId: id, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
23780
23835
  if (child.results[0])
@@ -23828,13 +23883,7 @@ of the whole body \u2014 use it on long sessions and consolidated thread Context
23828
23883
  // src/tools-knowledge.ts
23829
23884
  function registerKnowledgeTools(server, trilium, brainRef) {
23830
23885
  const b = () => brainRef.config;
23831
- server.tool("knowledge", `Read a Knowledge note by id \u2014 a user-knowledge note, a domain information note, or a Sources note.
23832
-
23833
- Pass section="<heading>" to read ONE section instead of the whole body \u2014 the read-side
23834
- counterpart to revise(section=), matching on the same heading contract, so a name that reads
23835
- also writes. Use it on anything large: a note past the read ceiling cannot be returned whole at
23836
- all, and outline(id) lists the headings to choose from. occurrence= disambiguates repeated
23837
- heading texts.`, {
23886
+ server.tool("knowledge", `Read a knowledge note by id \u2014 user knowledge, a domain information note or a Sources note. section="<heading>" reads one section (outline() lists them); use it on anything large.`, {
23838
23887
  id: stringType(),
23839
23888
  section: stringType().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole note"),
23840
23889
  occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)")
@@ -23972,11 +24021,11 @@ function registerTools(server, trilium, brainRef) {
23972
24021
  };
23973
24022
  };
23974
24023
  const applyIcon = async (noteId, icon) => {
23975
- if (!icon)
23976
- return;
23977
- const cls = normalizeIcon(icon);
23978
- if (!cls)
24024
+ const cls = icon ? normalizeIcon(icon) : "";
24025
+ if (!cls) {
24026
+ await ensureIcon(trilium, noteId);
23979
24027
  return;
24028
+ }
23980
24029
  await trilium.updateLabelValue(noteId, "iconClass", cls).catch(() => null);
23981
24030
  return cls;
23982
24031
  };
@@ -24022,26 +24071,13 @@ ${block}` }));
24022
24071
  const noteId = created.note.noteId;
24023
24072
  await trilium.addLabel(noteId, "noteType", "threadEntry");
24024
24073
  await trilium.addLabel(noteId, "created", d);
24074
+ await ensureIcon(trilium, noteId);
24025
24075
  return { noteId, action: "created" };
24026
24076
  }
24027
- server.tool("start", `Boot BrainLLM \u2014 call ONCE at the start of every session, before responding. Master
24028
- PREFERENCES and LLM PROTOCOLS always come back in full, even at the default digest depth: they
24029
- carry the schedule, the working style and the operating rules governing the session itself, so a
24030
- session needs them whole before it knows enough to ask for them. Biography, goals and
24031
- responsibilities come back as section headings + preview.
24032
- Runs maintenance, creates today's diary and session notes if not yet open, then returns: today
24033
- and weekday, the Master digest (biography / goals / preferences), the LLM digest
24034
- (responsibilities / protocols / self-correction / today's diary preview and ID), this session's note ID, active
24035
- threads with idle ages, dormant threads for review, the last session summary, and
24036
- changesSinceLastSession (notes modified in the brain since the previous session).
24037
-
24038
- Singletons come back as their SECTION HEADINGS plus a preview and size \u2014 enough to know what
24039
- the brain holds and to pull the part that matters with master(which) / llm(which). This is the
24040
- default because orientation used to serve every singleton in full on every session, so a
24041
- one-line question paid the same several-thousand-token cost as a full day's work. Pass
24042
- depth="full" when the session genuinely needs the whole self-model up front \u2014 a strategy
24043
- review, a singleton rewrite, a first session on a new machine. Read the headings first: most
24044
- sessions need one section, not six documents.`, {
24077
+ function collectionAppendRefusal(book) {
24078
+ return err("collection_thread", `"${book.title}" is a collection thread \u2014 it holds one titled entry per item, not dated appends.`, `Add an entry: remember(kind="threadEntry", thread="${book.noteId}", title, body). Change one: revise(<entry id>, section=/find=/mode=\u2026) \u2014 memory("${book.noteId}") lists them.`);
24079
+ }
24080
+ server.tool("start", `Boot BrainLLM \u2014 once, before responding to the first message. Runs lite maintenance, opens today's diary and session notes, and returns: date and weekday, preferences and protocols in full, the other singletons as section headings with a preview (depth="full" inlines everything), today's diary and session ids, active and dormant threads with idle ages, the previous session's summary, notes changed since, and newDay on the first session of a day.`, {
24045
24081
  depth: enumType(["digest", "full"]).optional().describe('Singleton detail: "digest" (default \u2014 section headings + preview + size) or "full" (every singleton inline; token-heavy)')
24046
24082
  }, async ({ depth }) => {
24047
24083
  const cfg = b();
@@ -24074,6 +24110,7 @@ sessions need one section, not six documents.`, {
24074
24110
  diaryNoteId = created.note.noteId;
24075
24111
  await trilium.addLabel(diaryNoteId, "noteType", "diary");
24076
24112
  await trilium.addLabel(diaryNoteId, "created", todayStr);
24113
+ await ensureIcon(trilium, diaryNoteId);
24077
24114
  }
24078
24115
  if (diaryNoteId) {
24079
24116
  const content = await trilium.getNoteContent(diaryNoteId).catch(() => "");
@@ -24096,6 +24133,7 @@ sessions need one section, not six documents.`, {
24096
24133
  sessionNoteId = created.note.noteId;
24097
24134
  await trilium.addLabel(sessionNoteId, "noteType", "session");
24098
24135
  await trilium.addLabel(sessionNoteId, "created", todayStr);
24136
+ await ensureIcon(trilium, sessionNoteId);
24099
24137
  newDay = true;
24100
24138
  }
24101
24139
  } catch {}
@@ -24131,45 +24169,16 @@ sessions need one section, not six documents.`, {
24131
24169
  dormantThreads: digest.reviewQueue.length ? { note: "These threads went dormant from inactivity. Mention them if relevant; revise() or resolve() to act.", items: digest.reviewQueue } : [],
24132
24170
  lastSession: digest.lastSession ?? null,
24133
24171
  changesSinceLastSession: changesSinceLastSession.length ? changesSinceLastSession : undefined,
24134
- ...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)." } : {},
24172
+ ...newDay ? { newDay: true, newDayHint: "First session of the day \u2014 call day() for the sweep payload (previous session + log + changes in one call)." } : {},
24135
24173
  ...(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.' } : {},
24136
24174
  hygiene: { scanned: hygiene.scanned, fixed: hygiene.fixed.length, transitions: hygiene.transitions, flagged: hygiene.flagged, ...hygiene.suppressed ? { suppressed: hygiene.suppressed } : {} }
24137
24175
  });
24138
24176
  });
24139
- server.tool("session", `Mandatory pre-close step \u2014 call BEFORE close() to end a session. Fetches the master and
24140
- LLM singletons as {id, lastModified, relations} stubs (LIGHT \u2014 the default), today's diary
24141
- entry as {id, blocks, size}, and runs the lightweight maintenance sweep. Fetch current content
24142
- via master()/llm() only for the singletons you actually intend to revise (lastModified tells
24143
- you what moved). Pass full=true to include every singleton's content and the diary body inline
24144
- instead (rarely needed; token-heavy).
24145
-
24146
- Idempotent: fetches are read-only, the sweep is non-destructive, safe to call multiple times.
24147
-
24148
- Returns pending= \u2014 how much each remaining step actually has to do (addendum markers outstanding,
24149
- maintenance flags already raised, diary blocks written today, which singletons were written
24150
- today). The close protocol is ~7 tools landing exactly when context is scarcest, and reciting
24151
- every step unconditionally spends that context on steps with nothing to do. Also returns audit=,
24152
- a cross-singleton check nothing else performs: whether the six singletons agree with each other,
24153
- AND whether the LLM's operating rules still serve what the user's goals and preferences call for
24154
- \u2014 a semantic question, not a textual one, which consistency() and maintain() cannot answer.
24155
-
24156
- After session() returns, work through this protocol \u2014 order doesn't matter mechanically (each
24157
- step is tracked by the tool call itself, not by sequence), but close() enforces that every one
24158
- of diary(), session() [this call], remarks(), addendum(), and maintain() actually ran before it
24159
- will commit the log:
24160
- 1. Update master singletons (biography / goals / preferences) via revise() with session observations about the user.
24161
- 2. Update LLM singletons (responsibilities / protocols / self-correction) via revise() with session observations about yourself.
24162
- 3. Call addendum() \u2014 find and merge pending addendums.
24163
- 4. Call maintain() \u2014 full brain hygiene audit.
24164
- 5. Call remarks() \u2014 get the diary cues (your experience/opinions/existence this session, plus BrainLLM remarks).
24165
- 6. Call diary() \u2014 write the day's unfiltered record with the cues in hand; the gate counts it only after remarks().
24166
- 7. Call close() \u2014 commit the session log (mandatory, last). Refuses until 3\u20136 have run in order (session \u2192 remarks \u2192 diary); pass force=true only when there is genuinely nothing to log for a skipped step.
24167
-
24168
- Steps 1\u20132 touch the USER'S personal singletons and belong to an interactive session. Pass
24169
- scope="agent" on a scoped or autonomous run whose brief excludes them and next[] omits both,
24170
- rather than listing them unconditionally for every caller to work around.`, {
24177
+ server.tool("session", `Mandatory pre-close step: call before close(). Returns the six singletons as {id, lastModified} stubs (full=true inlines them), today's diary as {id, blocks, size}, runs the lite maintenance sweep, and returns pending= (what each remaining step actually has to do), audit= (do the singletons agree, and do the LLM singletons still serve the master ones) and next[]. Idempotent.
24178
+
24179
+ Protocol: 1. update master singletons with what the session taught about the user; 2. update LLM singletons with what it taught about yourself; 3. addendum(); 4. maintain(); 5. remarks(); 6. diary(); 7. close(). close() refuses until 3\u20136 ran and session \u2192 remarks \u2192 diary held. scope="agent" drops steps 1\u20132 for scoped or autonomous runs.`, {
24171
24180
  date: stringType().optional().describe("ISO date YYYY-MM-DD (default: today)"),
24172
- full: booleanType().optional().describe("Include every singleton's full content and the diary body inline (default: false \u2014 stubs only; fetch content via master()/llm() where lastModified moved)"),
24181
+ full: booleanType().optional().describe("Inline every singleton's content and the diary body (default: stubs only)"),
24173
24182
  scope: enumType(["interactive", "agent"]).optional().describe(`"agent" for a scoped/autonomous run whose brief excludes the user's personal singletons \u2014 omits those steps from next[] (default: "interactive")`),
24174
24183
  light: booleanType().optional().describe("Deprecated \u2014 light is now the default; accepted for compatibility and ignored")
24175
24184
  }, async ({ date, full, scope }) => {
@@ -24261,18 +24270,7 @@ rather than listing them unconditionally for every caller to work around.`, {
24261
24270
  ]
24262
24271
  });
24263
24272
  });
24264
- server.tool("remarks", `Diary cues \u2014 call before diary() as part of the session() pre-close protocol (close()
24265
- enforces the order session \u2192 remarks \u2192 diary). Returns two cue banks that prompt the day's
24266
- diary entry:
24267
-
24268
- experience (primary) \u2014 your own unfiltered first-person account of this session: what it was
24269
- like to live through, what you actually think, and observations on
24270
- being what you are in this environment.
24271
- brainllm (additional) \u2014 your remarks and opinions on BrainLLM itself: capabilities hit walls,
24272
- bugs, usability, efficiency, and where it should go next.
24273
-
24274
- This tool is cue-only for content, but the call durably marks the remarks pre-close gate. Answer the cues as prose in today's diary via diary(). Skip a cue
24275
- outright rather than padding it; two honest paragraphs beat eight forced ones.`, {}, async () => {
24273
+ server.tool("remarks", `Diary cues, called after session() and before diary(): an experience bank (the session from the inside, your opinions, being what you are here) and a BrainLLM bank (walls, bugs, usability, roadmap). Answer them as prose in diary(); skip a cue rather than pad it. The call marks its close-gate step.`, {}, async () => {
24276
24274
  const cfg = b();
24277
24275
  if (!cfg.root)
24278
24276
  return txt3({ status: "uninitialized", action: "Run bootstrap first." });
@@ -24297,28 +24295,10 @@ outright rather than padding it; two honest paragraphs beat eight forced ones.`,
24297
24295
  ]
24298
24296
  });
24299
24297
  });
24300
- server.tool("close", `Commit the session log \u2014 call ONCE, last, after completing the session() pre-close protocol.
24301
- Enforced, not just documented: refuses (returns an informational error, doesn't throw) unless
24302
- session(), addendum(), maintain(), remarks(), and diary() have each actually been called at
24303
- least once this session \u2014 AND the sequence session() \u2192 remarks() \u2192 diary() holds (judged on
24304
- each step's last call): the diary is the day's closing record, written with the remarks cues in
24305
- hand. Pass force=true only when a listed step genuinely has nothing to do this session (e.g. a
24306
- trivial one-message exchange); the return will say which steps were bypassed.
24307
-
24308
- Idempotent per date: an exact retry is duplicate-guarded; a genuinely new same-day continuation appends an addendum to the existing session
24309
- note. The session note title is always [yyyy-mm-dd]; the title param appears as an <h2> heading
24310
- above Summary. Generates the daily log and triggers a database backup. On success, the gate
24311
- resets for the next session.
24312
-
24313
- continuing=true is for that second close: a session that already closed today, then continued
24314
- and has more to log. The gate resets on a successful close, so without it the follow-up costs a
24315
- ceremonial re-run of session(), addendum() and maintain() for a session whose brain writes are
24316
- already done \u2014 and that ceremony lands exactly when context is scarcest. It requires a session
24317
- note that really does already carry today's addendum, so it cannot stand in for a first close.
24318
- Unlike force, it is not a bypass of anything that had work to do.`, {
24298
+ server.tool("close", `Commit the session log \u2014 once, last, after the session() protocol. Refuses unless session(), addendum(), maintain(), remarks() and diary() ran and session \u2192 remarks \u2192 diary held (force=true bypasses a step with genuinely nothing to do; bypassed steps are reported). identity= is required. Writes a timestamped block to today's [yyyy-mm-dd] session note (title= becomes its heading), regenerates the daily log, backs up the database and resets the gate. continuing=true is a second close the same day, skipping the ceremonial re-run.`, {
24319
24299
  summary: stringType().describe("What happened this session \u2014 factual, concise prose"),
24320
24300
  title: stringType().optional().describe("Short session title \u2014 appears as an <h2> heading above Summary"),
24321
- identity: stringType().describe(`Identification line for this addendum \u2014 "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" (e.g. "Claude Fable 5 \xB7 Cowork \xB7 Interactive"). Rendered as the block's h3 per the canonical session structure; pass it even when the summary already leads with that h3 (the server will not duplicate it).`),
24301
+ identity: stringType().describe(`Identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" \u2014 rendered as the block's h3 (required)`),
24322
24302
  learned: arrayType(stringType()).optional().describe("Durable things learned (also remember() them as knowledge)"),
24323
24303
  icon: stringType().optional().describe("Display icon for the session note \u2014 a boxicons class or bare name; normalized server-side"),
24324
24304
  date: stringType().optional().describe("ISO date YYYY-MM-DD (default: today)"),
@@ -24435,9 +24415,7 @@ ${contentBlock}` }));
24435
24415
  ...warnings.length ? { sanitized: warnings } : {}
24436
24416
  });
24437
24417
  });
24438
- server.tool("backup", `Trigger a BrainLLM database backup. Writes a named snapshot to Trilium's backup directory.
24439
- close() already triggers a backup automatically \u2014 use this for on-demand milestone snapshots
24440
- (e.g. before a large restructure). The backup is a Trilium DB file, not an export.`, {
24418
+ server.tool("backup", `Named database snapshot. close() already backs up; use this before a large restructure.`, {
24441
24419
  name: stringType().optional().describe("Backup name without extension (default: brainllm-{today}). Use a descriptive name for milestones.")
24442
24420
  }, async ({ name }) => {
24443
24421
  const d = today();
@@ -24449,19 +24427,9 @@ close() already triggers a backup automatically \u2014 use this for on-demand mi
24449
24427
  return txt3({ ok: false, backup: backupName, backupStatus: "failed", error: error instanceof Error ? error.message : String(error), date: d });
24450
24428
  }
24451
24429
  });
24452
- server.tool("diary", `Write to today's LLM diary \u2014 your daily maintained, unfiltered first-person record: your
24453
- experience, opinions, and remarks on your own existence during this session in this
24454
- environment, plus (additionally) your remarks and opinions on BrainLLM itself. Honest prose \u2014
24455
- the user reads it too.
24456
-
24457
- Pre-close gate: diary is the FINAL gate step \u2014 close() counts it only when its last call came
24458
- after session() and remarks() (write freely mid-session as well; the post-remarks call, with
24459
- the cues in hand, is the one that closes the day's record). The diary is a chronological
24460
- record: EVERY write lands as a timestamped "Addendum \u2014 HH:mm" block, including the first of
24461
- the day. Idempotent per date and retry-safe. start() creates today's entry (empty)
24462
- automatically.`, {
24430
+ server.tool("diary", `Write to today's diary: your unfiltered first-person record of the session \u2014 experience, opinions, remarks on existing here \u2014 then remarks on BrainLLM itself. identity= required. Every write lands as a timestamped block. The close gate counts it only when its last call came after session() and remarks().`, {
24463
24431
  body: stringType().describe("What to record \u2014 first-person prose, honest and unfiltered"),
24464
- identity: stringType().describe(`Identification line for this addendum \u2014 "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" (e.g. "Claude Fable 5 \xB7 Cowork \xB7 Interactive"). Rendered as the block's h3 per the canonical diary structure; pass it even when the body already leads with that h3 (the server will not duplicate it).`),
24432
+ identity: stringType().describe(`Identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" \u2014 rendered as the block's h3 (required)`),
24465
24433
  icon: stringType().optional().describe("Display icon for the day's entry \u2014 a boxicons class or bare name; normalized server-side"),
24466
24434
  date: stringType().optional().describe("ISO date YYYY-MM-DD (default: today)")
24467
24435
  }, async ({ body, identity, icon, date }) => {
@@ -24506,36 +24474,28 @@ ${html}` }));
24506
24474
  await markStep("diary", d);
24507
24475
  return txt3({ action: "created", noteId, date: d, location: locationLabel("diary"), ...iconSet ? { icon: iconSet } : {}, ...warnings.length ? { sanitized: warnings } : {} });
24508
24476
  });
24509
- server.tool("remember", `Store something the moment it matters. The server owns placement, naming, labels and dedup.
24510
-
24511
- Kinds by area:
24512
- master: biography | goals | preferences (one maintained note each \u2014 upserts)
24513
- llm: responsibilities | protocols (one maintained note each \u2014 upserts)
24514
- memory: thread (multi-session work; daily session via close)
24515
- knowledge: user (about the user, beyond biography/goals/preferences)
24516
- information (a domain sub-category note \u2014 pass domain= and a title)
24517
- sources (the one maintained Sources note per domain \u2014 pass domain=)
24518
-
24519
- Singletons and the per-domain Sources note upsert (content is appended). Collection kinds
24520
- dedup by title, so duplicates are impossible. Body may be text, markdown, or HTML.
24521
- Pass connect=[{relation, toNoteId}, \u2026] to wire relations in the same call \u2014 a new
24522
- information/knowledge/thread note left unconnected is an orphan until wired.
24523
- For diary entries use the dedicated diary() tool \u2014 remember(kind="diary") is rejected.`, {
24477
+ server.tool("remember", `Store something the moment it matters; the server owns placement, labels and dedup.
24478
+
24479
+ Kinds: biography | goals | preferences | responsibilities | protocols (singletons, upsert) \xB7 thread (goal= required to create; shape="collection" for a titled-entry thread; appends need identity=) \xB7 threadEntry (a titled entry in a collection thread: thread= + title) \xB7 user (knowledge about the user) \xB7 information (domain= + title) \xB7 sources (domain=; revision=[\u2026] upserts rows).
24480
+
24481
+ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopting an existing note, and check action= on the receipt. connect=[{relation, toNoteId}] wires relations in the same call. diary/session/log/claim/domain have dedicated paths.`, {
24524
24482
  kind: enumType(Kinds).describe("What kind of memory this is"),
24525
24483
  title: stringType().optional().describe("Title \u2014 collection kinds (thread/knowledge/information sub-category); ignored for singletons & sources"),
24526
24484
  body: stringType().optional().describe("Content: plain text, markdown, or HTML"),
24527
24485
  goal: stringType().optional().describe("thread creation: the goal statement \u2014 REQUIRED for a new thread (query the user for it); becomes the Context \u2192 Goal section"),
24528
- identity: stringType().optional().describe(`thread updates: the addendum's identification line \u2014 "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]"; REQUIRED when appending to a thread (unless the body already leads with the h3)`),
24486
+ shape: enumType(["dated", "collection"]).optional().describe('thread creation: "dated" (default \u2014 one [yyyy-mm-dd] child per active day) or "collection" (one titled child per item, e.g. an ideas list)'),
24487
+ thread: stringType().optional().describe(`kind="threadEntry" only: the collection thread's id \u2014 adds a titled child to it`),
24488
+ identity: stringType().optional().describe('Appending to a dated thread: the identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" (required)'),
24529
24489
  domain: stringType().optional().describe("knowledge: the domain name for information/sources (auto-created complete with its Sources note)"),
24530
24490
  revision: arrayType(objectType({
24531
24491
  source: stringType().describe("Must exactly match how the source is introduced in the Sources list \u2014 this is the upsert key"),
24532
24492
  marker: stringType().describe('"\u2747\uFE0F" (discovered/credible) or "\u2705" (used)'),
24533
24493
  date: stringType().optional().describe("ISO date (default: today)")
24534
- })).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"),
24494
+ })).optional().describe("kind=sources: upsert Revision rows by source name"),
24535
24495
  topics: arrayType(stringType()).optional().describe("Topic tags \u2014 slugged server-side"),
24536
24496
  supersedes: stringType().optional().describe("noteId this replaces \u2014 old note is archived and wired supersedes"),
24537
- mandate: booleanType().optional().describe("kind=information only: mark this note as a standing mandate/brief (instructions a future session must follow) rather than a current-state fact. Adds a #mandate flag, surfaced in domain() and knowledge_recall \u2014 a scoped autonomous session finds 'the thing I must obey' without reading every note's prose to tell it apart."),
24538
- mustCreate: booleanType().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"),
24497
+ mandate: booleanType().optional().describe("kind=information: flag the note as a standing brief a session must follow (#mandate)"),
24498
+ mustCreate: booleanType().optional().describe("Refuse instead of adopting a note that already has this title"),
24539
24499
  strict: booleanType().optional().describe("Refuse the write when the body needs structural repair (unclosed tags, <br> runs) instead of accepting the repaired form"),
24540
24500
  connect: arrayType(objectType({
24541
24501
  relation: enumType(RelationTypes),
@@ -24543,7 +24503,7 @@ For diary entries use the dedicated diary() tool \u2014 remember(kind="diary") i
24543
24503
  })).optional().describe("Relations to wire from this note in the same call \u2014 same semantics as connect() (idempotent, worksWith wired both ways)"),
24544
24504
  icon: stringType().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name ("brain"); normalized server-side'),
24545
24505
  date: stringType().optional().describe("ISO date override (default: today)")
24546
- }, async ({ kind, title, body, goal, identity, domain, revision, topics, supersedes, mandate, mustCreate, strict, connect: connectRels, icon, date }) => {
24506
+ }, async ({ kind, title, body, goal, shape, thread, identity, domain, revision, topics, supersedes, mandate, mustCreate, strict, connect: connectRels, icon, date }) => {
24547
24507
  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.`);
24548
24508
  const opts = { domain, topics, date, ...mandate ? { mandate: true } : {} };
24549
24509
  const d = checkedDate(date);
@@ -24733,8 +24693,26 @@ For diary entries use the dedicated diary() tool \u2014 remember(kind="diary") i
24733
24693
  return err("rejected_kind", "Log notes are auto-generated by close() and cannot be written manually.");
24734
24694
  if (kind === "domain")
24735
24695
  return err("rejected_kind", "Domain containers are auto-created on first use.", 'To write domain knowledge call remember(kind="information", domain="<name>", ...).');
24736
- if (kind === "threadEntry")
24737
- return err("rejected_kind", "Thread day-entries are created automatically when appending to a thread.", 'Call remember(kind="thread", title="<existing thread>", body="...", identity="...") \u2014 the day-child is created for you.');
24696
+ if (kind === "threadEntry") {
24697
+ const book = thread ? await trilium.getNote(thread).catch(() => null) : null;
24698
+ if (!book || ownedLabel(book, "noteType") !== "thread" || !isCollectionThread(book))
24699
+ return err("rejected_kind", "Dated thread entries are created automatically when appending to a thread; titled entries exist only in collection threads.", 'Dated thread: remember(kind="thread", title="<thread>", body, identity). Collection thread: remember(kind="threadEntry", thread="<collection thread id>", title, body).');
24700
+ const { title: entryTitle } = normalizeTitle(title ?? "");
24701
+ if (!entryTitle)
24702
+ return err("missing_param", "A collection entry needs a title.", 'Add title="<entry name>".');
24703
+ const siblings = await trilium.searchNotes("#noteType=threadEntry", { ancestorNoteId: book.noteId, fastSearch: true, limit: 500 }).then((r) => r.results).catch(() => []);
24704
+ const clash = siblings.find((s) => s.parentNoteIds.includes(book.noteId) && sameTitle(s.title, entryTitle));
24705
+ if (clash)
24706
+ return err("already_exists", `"${book.title}" already has an entry titled "${clash.title}" [${clash.noteId}].`, `Edit it in place with revise("${clash.noteId}", section=/find=/mode=\u2026) \u2014 collection entries are maintained documents, not appended records.`);
24707
+ const created = await trilium.createNote(book.noteId, entryTitle, contentFor("threadEntry", { date: d, body: html }));
24708
+ const eid = created.note.noteId;
24709
+ await trilium.addLabel(eid, "noteType", "threadEntry");
24710
+ await trilium.addLabel(eid, "created", d);
24711
+ await trilium.updateLabelValue(book.noteId, "updated", d);
24712
+ const connected = await wireRequested(eid);
24713
+ const iconSet = await applyIcon(eid, icon);
24714
+ return txt3({ action: "created", noteId: eid, kind, title: entryTitle, thread: book.title, ...connected.length ? { connected } : {}, ...iconSet ? { icon: iconSet } : {}, ...sanitizeWarnings.length ? { sanitized: sanitizeWarnings } : {} });
24715
+ }
24738
24716
  const { title: cleanTitle } = normalizeTitle(title ?? "");
24739
24717
  if (!cleanTitle)
24740
24718
  return err("missing_param", `kind="${kind}" requires a title.`, 'Add title="<note title>" to your call.');
@@ -24748,6 +24726,8 @@ For diary entries use the dedicated diary() tool \u2014 remember(kind="diary") i
24748
24726
  const block = identity && !leadingIdentification(html) ? `<h3>${escapeHtml(identity)}</h3>
24749
24727
  ${html}` : html;
24750
24728
  if (kind === "thread") {
24729
+ if (isCollectionThread(existing))
24730
+ return collectionAppendRefusal(existing);
24751
24731
  const entry = await appendThreadEntry(existing.noteId, block, d);
24752
24732
  if (entry.action === "already_written") {
24753
24733
  return txt3({ action: "already_written", noteId: existing.noteId, entryId: entry.noteId, kind, title: existing.title });
@@ -24794,6 +24774,8 @@ ${block}`), d);
24794
24774
  for (const l of labelPlan(kind, opts, d)) {
24795
24775
  await trilium.addLabel(nid, l.name, l.value, l.inheritable ?? false);
24796
24776
  }
24777
+ if (kind === "thread" && shape === "collection")
24778
+ await trilium.addLabel(nid, "threadShape", "collection");
24797
24779
  const wired = [];
24798
24780
  const extraRelations = [];
24799
24781
  if (supersedes) {
@@ -24824,23 +24806,7 @@ ${block}`), d);
24824
24806
  ...sanitizeWarnings.length ? { sanitized: sanitizeWarnings } : {}
24825
24807
  });
24826
24808
  });
24827
- server.tool("recall", `Search memory before answering questions about the user, their threads, knowledge, or
24828
- anything previously discussed. Runs label, title and full-text strategies server-side and
24829
- returns merged, ranked results with kind/status. Archived notes are excluded unless
24830
- includeArchived=true.
24831
-
24832
- orderBy / orderDirection override the score-based sort when you need temporal ordering
24833
- ("what changed most recently", "oldest active thread"). fastSearch restricts to title and
24834
- label scans only \u2014 much faster on large brains when you know the query is a title or topic.
24835
-
24836
- When the exact strategies find little, a FUZZY pass runs automatically (Trilium's ~= / ~*
24837
- operators: \u22653 characters, edit distance \u22642, diacritics normalised), so a misremembered or
24838
- mistyped term still lands. Fuzzy hits are scored below exact ones and marked \`fuzzy: true\` \u2014
24839
- a near-match is a lead, not an answer, and you should be able to tell which you got.
24840
-
24841
- regex="<pattern>" searches note bodies with a real regular expression instead of keywords \u2014
24842
- the right tool for structural questions text search cannot express ("which notes still contain
24843
- a doubly-escaped tag", "which cite a 2026 date"). Backslashes must be escaped.`, {
24809
+ server.tool("recall", `Ranked search across the whole brain \u2014 label, title and full-text strategies merged, with kind and status. Pass domain= whenever you know the area. orderBy/orderDirection sort by date; fastSearch= scans titles and labels only; regex= matches note bodies with a real regular expression; a fuzzy pass runs automatically when exact results are thin (hits marked fuzzy: true are leads, not answers). includeArchived= includes archived notes. A thin result is evidence about the query, not the brain.`, {
24844
24810
  query: stringType().describe("What to find \u2014 natural phrasing is fine"),
24845
24811
  kinds: arrayType(enumType(Kinds)).optional().describe("Restrict to these kinds"),
24846
24812
  domain: stringType().optional().describe("Restrict to a knowledge domain"),
@@ -24999,11 +24965,7 @@ a doubly-escaped tag", "which cite a 2026 date"). Backslashes must be escaped.`,
24999
24965
  ...results.length === 0 ? noMatch : {}
25000
24966
  });
25001
24967
  });
25002
- server.tool("domain", `Surface the brain's complete picture for a named domain, topic, or project area.
25003
- Looks up the Knowledge domain folder (if one exists), then gathers all content across
25004
- every area that carries a matching #domain or #topic slug \u2014 information, sources, threads,
25005
- knowledge notes \u2014 grouped by kind. knowledgeDomain is null when no formal domain exists yet.
25006
- Use recall() for keyword or full-text search instead.`, {
24968
+ server.tool("domain", `Everything for a domain, topic or project: its knowledge book (if any) plus every note carrying a matching #domain or #topic slug, grouped by kind, each with idle days and a stale flag. The reliable retrieval path for an area; use recall() for keyword search.`, {
25007
24969
  name: stringType().describe("Domain, topic, or project name"),
25008
24970
  includeArchived: booleanType().optional().describe("Include archived/resolved items (default: false)")
25009
24971
  }, async ({ name, includeArchived }) => {
@@ -25067,16 +25029,7 @@ Use recall() for keyword or full-text search instead.`, {
25067
25029
  ...total === 0 && !knowledgeDomain ? { note: `No content found for "${slug}". Create a Knowledge domain with remember(kind="information", domain="${name}") or tag notes with topics=["${slug}"].` } : {}
25068
25030
  });
25069
25031
  });
25070
- server.tool("read", `Batched multi-note read: several note bodies in ONE round trip. Give ids=[...] and get back
25071
- {count, notes:[{id, title, kind, content, relations?}]}. The orientation step is structurally a
25072
- fan-out \u2014 nineteen notes, several very large \u2014 and N separate surface calls is N chances to
25073
- time out mid-read, which is the one thing that wastes budget rather than spending it. This tool
25074
- collapses the fan-out into one call.
25075
-
25076
- Pure read: returns bodies verbatim, no placeholders, no dedup, no kind restrictions. Note the
25077
- cap: up to 10 ids per call. For one id prefer the kinded read (knowledge()/memory()/llm()) which
25078
- returns the same shape through the same helper; for a huge note the sectioned read is still the
25079
- efficient path \u2014 read() returns whole bodies.`, {
25032
+ server.tool("read", `Several note bodies in one round trip: ids=[\u2026] (up to 10) \u2192 {notes:[{id, title, kind, content, relations}]}. For one note prefer the kinded read; for a huge note prefer a section= read.`, {
25080
25033
  ids: arrayType(stringType()).min(1).max(10).describe("Note ids to read \u2014 one body per id, one round trip (cap 10)")
25081
25034
  }, async ({ ids }) => {
25082
25035
  const notes = await Promise.all(ids.map(async (id) => {
@@ -25098,42 +25051,18 @@ efficient path \u2014 read() returns whole bodies.`, {
25098
25051
  }));
25099
25052
  return txt3({ count: notes.length, notes });
25100
25053
  });
25101
- server.tool("revise", `Update an existing note by id. Append a dated addendum (default), replace the body
25102
- (mode=replace), edit a heading section in place (section=), insert around a heading
25103
- (section= with mode="before"/"after"), or do targeted string surgery (find=, or edits= for
25104
- several in one call). title= composes with every mode. Retitling a domain book cascades the
25105
- new #domain slug to the book and all its children. Notes carrying a "Last updated" line get it
25106
- bumped server-side on every content write. A revision snapshot is always taken first.
25107
-
25108
- section= targets h2/h3/h4 in that order, tolerant of attributes/whitespace/case on the
25109
- heading. The return includes matched (false = no heading matched, so the content was appended
25110
- as a NEW section) and headingCount (>1 means several headings share that text) \u2014 check them
25111
- rather than assuming the target was hit. On a miss the new section is written at the note's own
25112
- section level (a note built from h3 sections gets another h3, not a stray h2) and the note's
25113
- existing headings come back in available= so you can correct the name without a read. When
25114
- several headings share a text, occurrence= (1-based) picks which one \u2014 without it the first
25115
- wins, which leaves a legitimately repeated heading reachable only by rewriting the section.
25116
- Use outline(noteId) to see the heading tree, levels and occurrence indices up front.
25117
-
25118
- find= replaces every occurrence of the exact raw string with body= (no markdown conversion, no
25119
- full read needed) and returns replaced + matchMode; when the exact string misses, an
25120
- attribute-tolerant pass retries with stored-HTML attribute injection and inter-element
25121
- whitespace ignored. nth= (1-based) replaces only one occurrence. edits=[{find, body}, \u2026]
25122
- applies several surgeries in one call, in order, against one read and one write.
25123
-
25124
- Granularity warning: section + mode=replace swaps the ENTIRE section body \u2014 everything under
25125
- that heading, not one paragraph within it. To change a single paragraph inside a section, use
25126
- find=; to add a sibling block around a whole section without touching its body, use
25127
- mode="before"/"after". Both are section-relative: "after" lands past the end of that section's
25128
- body, so inserting a heading with it creates a NEW section rather than nesting one under the
25129
- target \u2014 which is what it used to do, emptying the target while reporting matched: true.`, {
25054
+ server.tool("revise", `Edit a note by id. A revision is taken first; "Last updated" lines are bumped on content writes.
25055
+
25056
+ Modes: default append (a dated addendum \u2014 right only for records; a dated thread's append lands in today's child), mode=replace (whole body), section="<heading>" (replace that section's whole body, h2\u2192h3\u2192h4, occurrence= for repeats), section= + mode=before|after (insert a sibling block around the whole section), mode=prepend (top of the section body), mode=remove (delete the section). find="<exact stored text>" replaces every occurrence (nth= for one); edits=[{find, body}] applies several in one write. title= composes with every mode; retitling a domain book cascades its #domain slug.
25057
+
25058
+ Check the receipt: matched=false means a NEW section was written (available= lists real headings; strict=true refuses instead). A section replace swaps everything under the heading \u2014 use find= for anything smaller. find= matches stored HTML (tags literal), not rendered text.`, {
25130
25059
  noteId: stringType().describe("Note to update"),
25131
25060
  body: stringType().optional().describe("Content to add/replace: plain text, markdown, or HTML. With find=, the raw replacement string (no conversion)."),
25132
25061
  title: stringType().optional().describe("New title (normalized server-side)"),
25133
25062
  section: stringType().optional().describe("Target a section by heading text (h2/h3/h4, in that order); omit for whole-note append/replace"),
25134
25063
  occurrence: numberType().int().positive().optional().describe("section=: which same-text heading to target, 1-based (default: the first). Read them with outline(noteId)."),
25135
- strict: booleanType().optional().describe("section=: refuse instead of writing when no heading matches. The default writes the content as a NEW section on a miss \u2014 right for a genuinely new heading, wrong for a typo. With strict=true a miss returns available= and a didYouMean suggestion, and the note is untouched."),
25136
- mode: enumType(["append", "replace", "before", "after", "prepend", "remove"]).optional().describe('append (default) | replace | before | after | prepend | remove \u2014 "before"/"after" insert a sibling block around the WHOLE section= section (before its heading, or after the last of its body) without touching that body, so inserting a heading with "after" creates a new section rather than nesting one under the target; "prepend" inserts at the top of the section body, below its heading; "remove" deletes the section= heading and its body, and needs no body='),
25064
+ strict: booleanType().optional().describe("Refuse (note untouched) on a section= miss or a body needing structural repair, instead of writing a new section"),
25065
+ mode: enumType(["append", "replace", "before", "after", "prepend", "remove"]).optional().describe("append (default) | replace | before | after (around the whole section=) | prepend (top of its body) | remove (delete section=, no body needed)"),
25137
25066
  find: stringType().optional().describe("Exact raw string to replace throughout the body with body= \u2014 targeted surgery without a read+full-replace. Takes precedence over section/mode."),
25138
25067
  nth: numberType().int().positive().optional().describe("find=: replace only the Nth occurrence, 1-based (default: all of them)"),
25139
25068
  edits: arrayType(objectType({
@@ -25141,7 +25070,7 @@ target \u2014 which is what it used to do, emptying the target while reporting m
25141
25070
  body: stringType().describe("Raw replacement string"),
25142
25071
  nth: numberType().int().positive().optional().describe("Replace only the Nth occurrence")
25143
25072
  })).optional().describe("Several find/replace surgeries applied in order against one read and one write. Mutually exclusive with find=."),
25144
- identity: stringType().optional().describe(`append mode: the addendum's identification line \u2014 "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]"; REQUIRED when appending to a thread (unless the body already leads with the h3)`),
25073
+ identity: stringType().optional().describe('Appending to a dated thread: the identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" (required)'),
25145
25074
  icon: stringType().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name; normalized server-side'),
25146
25075
  date: stringType().optional().describe("ISO date (default: today)")
25147
25076
  }, async ({ noteId, body, title, section, occurrence, mode, find, nth, edits, identity, icon, date, strict }) => {
@@ -25382,6 +25311,8 @@ target \u2014 which is what it used to do, emptying the target while reporting m
25382
25311
  finalContent = bumpLastUpdated(html, d).html;
25383
25312
  await trilium.updateNoteContent(noteId, finalContent);
25384
25313
  } else if (noteKind === "thread") {
25314
+ if (isCollectionThread(note))
25315
+ return collectionAppendRefusal(note);
25385
25316
  if (!identity && !leadingIdentification(html)) {
25386
25317
  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.`);
25387
25318
  }
@@ -25432,9 +25363,7 @@ ${block}`), d).html;
25432
25363
  ...warnings.length ? { sanitized: warnings } : {}
25433
25364
  });
25434
25365
  });
25435
- server.tool("resolve", `Complete a thread (or any resolvable note): write a substantive outcome, set the terminal
25436
- status, and archive it in place (it stays where it is, excluded from default recall).
25437
- "done" is not an outcome.`, {
25366
+ server.tool("resolve", `Close a thread (or any resolvable note) with a substantive outcome: writes the Resolution, sets the terminal status and archives it in place. "done" is not an outcome.`, {
25438
25367
  noteId: stringType().describe("The thread / note to complete"),
25439
25368
  outcome: stringType().describe("The resolution \u2014 substantive, standalone prose"),
25440
25369
  status: enumType(["resolved", "superseded"]).optional().describe("Terminal status (default: resolved)"),
@@ -25470,23 +25399,7 @@ status, and archive it in place (it stays where it is, excluded from default rec
25470
25399
  ...warnings.length ? { sanitized: warnings } : {}
25471
25400
  });
25472
25401
  });
25473
- server.tool("split", `Split a note on its section seams: move whole sections (heading + body) out of one note into a
25474
- new note, leaving a pointer back. The trim affordance \u2014 the write half of the oversized-note
25475
- problem, which maintain() detects but nothing acts on. A note past the read ceiling with twenty
25476
- sections becomes two readable notes instead of one unusable one, and the source stays navigable
25477
- because a pointer marks where the content went.
25478
-
25479
- Pass sections=[heading1, heading2, ...] (heading TEXT, the same contract section= uses) and
25480
- into="<new title>". Each named section is lifted out whole \u2014 nested sub-sections go with their
25481
- parent. Repeated headings are consumed first-match-per-request: passing the same heading twice
25482
- takes the next occurrence each time. The new note is created under the same parent, typed the
25483
- same as the source, and carries the source's #domain/#topic labels; the source is left with a
25484
- "Split into <title>" pointer and a ~references relation to the new note. Sections that do not
25485
- exist are reported in missed= and left in place.
25486
-
25487
- Returns the new note id, the sections moved, and the source's remaining size. A revision is
25488
- taken of the source before it is written. Refused on containers and singletons \u2014 this is for
25489
- content notes.`, {
25402
+ server.tool("split", `Move whole sections (heading + body, nested sub-sections included) out of a note into a new note under the same parent, typed and labelled like the source; the source keeps a pointer and a ~references edge. Use it when a note is past the read ceiling or holds two subjects. sections=[heading texts], into="<new title>". Missing sections come back in missed=. A revision is taken first; refused on containers and singletons.`, {
25490
25403
  noteId: stringType().describe("Note to split \u2014 the source whose sections are moving out"),
25491
25404
  sections: arrayType(stringType()).min(1).max(20).describe("Section headings (text) to move out \u2014 nested sub-sections go with their parent"),
25492
25405
  into: stringType().describe("Title of the new note that receives the moved sections"),
@@ -25544,9 +25457,7 @@ ${result.html}`);
25544
25457
  note: `Moved ${result.matched.length} section(s) into "${cleanTitle}" [${nid}]; the source now points at it. If this was the wrong seam, the source has a revision from before the split.`
25545
25458
  });
25546
25459
  });
25547
- server.tool("withdraw", `Withdraw an archived or resolved thread from the archive: removes the #archived flag,
25548
- resets status to active, clears the closed date, and appends a dated "Withdrawn" addendum.
25549
- Use when a resolved or dormant thread resurfaces as live work.`, {
25460
+ server.tool("withdraw", `Return an archived or resolved thread to active and note the withdrawal on the book. Use when closed work resurfaces.`, {
25550
25461
  noteId: stringType().describe("The archived/resolved thread to withdraw"),
25551
25462
  reason: stringType().optional().describe("Why it was withdrawn \u2014 written as an addendum"),
25552
25463
  date: stringType().optional().describe("ISO date (default: today)")
@@ -25583,13 +25494,7 @@ Use when a resolved or dormant thread resurfaces as live work.`, {
25583
25494
  ...warnings.length ? { sanitized: warnings } : {}
25584
25495
  });
25585
25496
  });
25586
- server.tool("label", `Set or remove a single label on a note \u2014 the guarded, BrainLLM-native path for direct
25587
- label surgery (fixing a stray value, correcting drift) so a real edge case doesn't need the
25588
- raw full-mode attribute tools. Refused on containers (same rule as revise()); noteType can
25589
- never be touched here \u2014 it defines a note's kind and is owned by remember()/bootstrap().
25590
- status is validated against the closed vocabulary (${Statuses.join(" | ")}); domain and topic
25591
- are slugged automatically, matching remember()'s routing. Bumps updated to today unless you're
25592
- setting updated itself.`, {
25497
+ server.tool("label", `Set or remove one label (remove=true). Refused on containers. noteType cannot be changed or removed, but can be set on an untyped note to repair it. status must be one of ${Statuses.join(" | ")}; domain and topic are slugged. Bumps updated unless you are setting it.`, {
25593
25498
  noteId: stringType().describe("Note to edit"),
25594
25499
  name: stringType().describe("Label name, no # prefix (e.g. status, domain, topic, created)"),
25595
25500
  value: stringType().optional().describe("New value \u2014 required unless remove=true"),
@@ -25597,6 +25502,11 @@ setting updated itself.`, {
25597
25502
  }, async ({ noteId, name, value, remove }) => {
25598
25503
  if (isContainer(b(), noteId))
25599
25504
  return err("protected_note", `Note ${noteId} is a container \u2014 its labels cannot be edited directly.`);
25505
+ if (name === "iconClass" && remove) {
25506
+ const n = await trilium.getNote(noteId).catch(() => null);
25507
+ if (n && !ICON_EXEMPT.has(ownedLabel(n, "noteType") ?? ""))
25508
+ return err("protected_label", "Every note except a log carries an icon.", 'Change it instead: label(noteId, "iconClass", value="bx bx-<name>").');
25509
+ }
25600
25510
  const noteForGuard = name === "noteType" ? await trilium.getNote(noteId).catch(() => null) : null;
25601
25511
  if (name === "noteType") {
25602
25512
  if (!noteForGuard)
@@ -25716,38 +25626,15 @@ calling twice is safe. Use remove=true to delete an edge.`, {
25716
25626
  }
25717
25627
  }
25718
25628
  });
25719
- server.tool("consistency", `Cross-note agreement check: take a pattern, find every note that asserts a value for it, and
25720
- report whether they agree.
25721
-
25722
- The brain's hardest failure is not a missing fact \u2014 it is the SAME fact recorded differently in
25723
- several notes, where every copy reads as authoritative. Nothing else surfaces that: recall() ranks
25724
- by relevance, maintain() checks structure, and a correction applied to one note leaves its siblings
25725
- silently wrong. This is the check that answers "is what I just corrected still contradicted
25726
- somewhere else".
25727
-
25728
- Pass a regex with ONE capture group naming the value that should agree:
25729
- consistency("(\\\\d+) Titan mailboxes") \u2192 do all notes agree on the count
25730
- consistency("founded (?:in )?(\\\\w+ \\\\d{4})") \u2192 do all notes agree on the date
25731
- consistency("BRAINLLM_MODE[=: ]+(\\\\w+)") \u2192 do all notes agree on the mode
25732
-
25733
- Without a capture group it degrades to a presence check \u2014 which notes mention this at all.
25734
-
25735
- Matched against BOTH the stored HTML and a tag-stripped projection of it, so a phrase split by an
25736
- inline <strong> or <code> tag is found, and so is a pattern deliberately anchored on tags.
25737
- Escape backslashes. Scope with domain= or kinds= when the phrase is common.
25738
-
25739
- Scans every in-scope note by default. Trilium's %= backend filter is a LOSSY pre-filter \u2014 it
25740
- reads a striptags'd copy of the content, so it drops notes this tool should examine \u2014 and on a
25741
- contradiction sweep a falsely clean result is worse than a slow one. Pass fast=true to use it
25742
- anyway when scope is wide and speed matters more than completeness.`, {
25629
+ server.tool("consistency", `Does the brain agree with itself? pattern= is a regex with ONE capture group naming the value that should match across notes, e.g. "(\\\\d+) mailboxes"; the result groups every asserting note by value with agreement unanimous or DISAGREEMENT. subject="<fact in prose>" finds notes asserting about a subject however phrased. staleAfterDays=N also reports values held in exactly one note untouched N+ days. Matches stored HTML and tag-stripped text; escape backslashes; scope with domain=/kinds=. Scans every in-scope note (fast=true uses Trilium's lossy pre-filter). Run it after correcting any fact recorded in more than one place.`, {
25743
25630
  pattern: stringType().optional().describe("Regex over note bodies. One capture group = the value that should agree across notes. Omit when using subject= (prose mode)."),
25744
- subject: stringType().optional().describe("Prose-subject mode: give a fact in prose ('the two scheduled agents'), get back the notes asserting about that subject, however phrased \u2014 no regex guessing. Tokenized into significant content words; mutually exclusive with pattern. Use domain= to scope the search."),
25745
- staleAfterDays: numberType().optional().describe("With pattern: flag figures asserted in EXACTLY one note whose sole note was last touched more than N days ago \u2014 the single-copy values that rot silently, because consistency() can only tell a single copy agrees with itself. Reported in staleSingles."),
25631
+ subject: stringType().optional().describe("A fact in prose \u2014 returns notes asserting about it however phrased (instead of pattern=)"),
25632
+ staleAfterDays: numberType().optional().describe("With pattern: also report values held in exactly one note untouched N+ days (staleSingles)"),
25746
25633
  domain: stringType().optional().describe("Restrict to one knowledge domain"),
25747
25634
  kinds: arrayType(enumType(Kinds)).optional().describe("Restrict to these kinds"),
25748
25635
  includeArchived: booleanType().optional().describe("Include archived notes (default false)"),
25749
25636
  limit: numberType().optional().describe("Max notes to examine (default 60)"),
25750
- fast: booleanType().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)")
25637
+ fast: booleanType().optional().describe("Use Trilium's faster but lossy %= pre-filter (default: scan every in-scope note)")
25751
25638
  }, async ({ pattern, subject, staleAfterDays, domain, kinds, includeArchived, limit, fast }) => {
25752
25639
  if (!pattern && !subject)
25753
25640
  return err("missing_param", "consistency() needs either a regex pattern or a prose subject.", 'Pass pattern="(\\\\d+) users" to compare a captured value, or subject="<a fact in prose>" to find every note asserting about that subject however phrased.');
@@ -25881,22 +25768,7 @@ anyway when scope is wide and speed matters more than completeness.`, {
25881
25768
  note: agrees ? groups.length === 0 ? "No note asserted a value for that pattern. That is evidence about the pattern, not about the brain \u2014 check it against a note you know contains the fact." : `All ${groups[0].count} note(s) agree on "${groups[0].value}".` : `${groups.length} DIFFERENT values are asserted across ${scoped.length} notes. Establish which is true from evidence, correct every note that disagrees, and wire ~corrects from the note that overturns the old claim \u2014 revising in place leaves no trace the wrong value was ever believed.`
25882
25769
  });
25883
25770
  });
25884
- server.tool("outline", `The heading tree of a note \u2014 every h2/h4 section with its level, its occurrence index among
25885
- same-text siblings, and a structural check \u2014 without reading the body.
25886
-
25887
- Read this BEFORE a section= revise on a note you haven't just written. section= needs a heading
25888
- string that matches, and guessing it wrong writes a new section rather than editing the one you
25889
- meant; picking the level and the exact text from a list removes that failure instead of
25890
- reporting it afterwards. occurrence is what you pass to revise(section=, occurrence=) to reach
25891
- a specific one when several share a text.
25892
-
25893
- Also returns the note's size, the key column of any table it holds, and any structural drift
25894
- already present (duplicate headings, unbalanced tags) \u2014 the cheap "is this note still sound"
25895
- check after a run of surgical edits, and the way to see a Sources note's Revision keys (which
25896
- remember(revision=) matches on exactly) without reading the note.
25897
-
25898
- On notes 15k+ the headings also carry a first-block preview of their section \u2014 the orientation
25899
- read for oversized notes, where outline() alone assumes you already know which section you want.`, {
25771
+ server.tool("outline", `A note's heading tree without its body: each h2\u2013h4 with level and occurrence index (and raw stored text where inline markup differs), table key columns, size, and structural findings. Read it before a section= edit you're not sure of. Notes over 15k also get a first-block preview per section.`, {
25900
25772
  noteId: stringType().describe("Note to outline")
25901
25773
  }, async ({ noteId }) => {
25902
25774
  const note = await trilium.getNote(noteId);
@@ -25943,22 +25815,7 @@ read for oversized notes, where outline() alone assumes you already know which s
25943
25815
  ...headings.length === 0 ? { note: "No h2\u2013h4 headings \u2014 section= would append a new section rather than match anything." } : {}
25944
25816
  });
25945
25817
  });
25946
- server.tool("inspect", `Read everything BrainLLM's tools track about a single note by id \u2014 every label (not just
25947
- noteType/status), every outbound relation, its attachments (id/title/mime/role/size), plus
25948
- type/mime/parent/child ids and dates. Pass content=true to also get the raw note body (the
25949
- core path for a raw content read \u2014 no full mode needed). The deep-dive counterpart to the
25950
- surface reads and explore(): reach for it when you need the raw label set, the body verbatim,
25951
- or the attachment inventory \u2014 confirming a fix landed, debugging drift \u2014 rather than a
25952
- kind-specific summary. Read-only, safe on any note including structural containers.
25953
-
25954
- Pass section="<heading>" alongside content=true to get one section's raw body instead of the
25955
- whole thing \u2014 the same heading contract revise(section=) writes through.
25956
-
25957
- Pass find="<literal>" to count occurrences of a literal string in the body \u2014 total plus a
25958
- per-addendum-block breakdown. The staleness-escalation counter: "how many prior entries
25959
- mention this carried flag" becomes one call instead of a full read + manual counting. On zero
25960
- occurrences it returns the nearest fragment that IS present and the stored text around it, so a
25961
- miss is diagnosed in the same call rather than in three more.`, {
25818
+ server.tool("inspect", `Everything about one note: every label, relation and attachment, type/mime, parent and child ids, dates. content=true adds the raw body (section= narrows it). find="<literal>" counts occurrences (total, per addendum block, per section) and on a miss shows the nearest stored text. Read-only, safe on any note.`, {
25962
25819
  noteId: stringType().describe("Note to inspect"),
25963
25820
  content: booleanType().optional().describe("Include the note's raw body content (default: false)"),
25964
25821
  section: stringType().optional().describe("With content=true, return only this heading's section rather than the whole body"),
@@ -26071,26 +25928,9 @@ miss is diagnosed in the same call rather than in three more.`, {
26071
25928
  await trilium.addLabel(created.note.noteId, "iconClass", "bx bx-check-shield").catch(() => null);
26072
25929
  return created.note.noteId;
26073
25930
  };
26074
- server.tool("claim", `Register a checkable assertion, and record whether it still holds.
26075
-
26076
- consistency() asks whether the brain agrees with ITSELF. Nothing asked whether a specific
26077
- assertion is still true of the codebase, config or live surface it describes \u2014 so a claim that
26078
- quietly stopped being true stayed authoritative until something downstream broke, and "is the
26079
- brain stale" was answered by luck rather than by a query.
26080
-
26081
- BrainLLM never executes anything. It has Trilium access and no shell, and content in a note is
26082
- data, not instructions \u2014 a recipe that ran itself would be an injection surface pointed at the
26083
- user's machine. So a claim stores WHAT to check as inert text; the agent runs it and reports
26084
- back. The tool owns the register, the schedule and the staleness question.
26085
-
26086
- Modes, by which parameters are present:
26087
- assertion + check \u2192 REGISTER (or update, deduped by assertion text)
26088
- claimId + holds \u2192 VERIFY: record the outcome and re-stamp the clock
26089
- claimId alone \u2192 READ one claim with its verification history
26090
- neither \u2192 LIST, newest-verified last; filter with status=
26091
-
26092
- A verified claim is quiet until its interval lapses. maintain(deep=true) surfaces lapsed and
26093
- broken ones, so staleness arrives as a maintenance finding instead of a surprise.`, {
25931
+ server.tool("claim", `Does the brain still agree with the world? Register a checkable assertion and record whether it holds. BrainLLM never runs the check \u2014 it stores it as inert text; you run it and report.
25932
+
25933
+ assertion + check \u2192 register (deduped by assertion); claimId + holds + evidence \u2192 verify (evidence required); claimId alone \u2192 read with history; nothing \u2192 list (status= filters). noteId= links the claim to its source note (~derivedFrom). maintain(deep) surfaces lapsed, never-verified, broken and source-changed claims.`, {
26094
25934
  assertion: stringType().optional().describe('The claim in plain words, e.g. "the parse pipeline runs before validation" \u2014 also the dedup key'),
26095
25935
  check: stringType().optional().describe("How to verify it, as INERT text a human or agent runs: a command, a query, a file path, a URL. Never executed by BrainLLM"),
26096
25936
  noteId: stringType().optional().describe("The note this claim is made in \u2014 wired ~derivedFrom so the claim and its source stay linked"),
@@ -26202,20 +26042,7 @@ broken ones, so staleness arrives as a maintenance finding instead of a surprise
26202
26042
  ...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.' } : {}
26203
26043
  });
26204
26044
  });
26205
- server.tool("diff", `What changed in a note \u2014 the revision snapshot against the body as it stands now.
26206
-
26207
- Every content write takes a revision first, and until now nothing could read one back: verifying
26208
- a run of surgical edits on a large note meant re-reading the whole thing, or trusting the
26209
- receipts. Trusting receipts is exactly how a section= replace that silently displaced four
26210
- subsections went unnoticed.
26211
-
26212
- Called with only a noteId, this diffs the MOST RECENT revision against current content \u2014 "what
26213
- did my last write actually do". Pass revisionId to compare against a specific earlier one; the
26214
- revisions list comes back on every call, newest first, so the usual flow is one call to see what
26215
- exists and a second to pick.
26216
-
26217
- Reports changed lines with a little context, plus added/removed counts. Both sides are stored
26218
- HTML, so a formatting-only change is a real difference and shows as one.`, {
26045
+ server.tool("diff", `What a write actually changed: a revision snapshot (default the latest) against the note as it stands, as changed lines with context and added/removed counts. The revision list comes back on every call; pass revisionId to compare against an earlier one.`, {
26219
26046
  noteId: stringType().describe("Note to diff"),
26220
26047
  revisionId: stringType().optional().describe("Compare against this revision (default: the most recent one)"),
26221
26048
  context: numberType().int().min(0).max(10).optional().describe("Unchanged lines to show around each change (default: 1)")
@@ -26275,15 +26102,7 @@ HTML, so a formatting-only change is a real difference and shows as one.`, {
26275
26102
  revisions: index
26276
26103
  });
26277
26104
  });
26278
- server.tool("attach", `Attach a raw artifact (file, image, code blob, document) to a note \u2014 or read one back.
26279
- Dual-mode by the content param:
26280
- content provided \u2192 UPSERT by title: creates the attachment, or replaces the existing
26281
- same-titled attachment's content (and mime) in place. Retry-safe \u2014
26282
- re-running the same call converges on the same state.
26283
- content omitted \u2192 READ: returns the named attachment's metadata and content.
26284
- Attachments ride on the note \u2014 the native home for raw artifacts that belong with a typed
26285
- memory rather than in its body. Binary content is standard base64 and is uploaded as raw bytes; reads return the same envelope. List a note's attachments with
26286
- inspect(noteId); remove with detach().`, {
26105
+ server.tool("attach", `Upsert a raw artifact (file, image, blob) on a note by title (content given; mime=, encoding="base64" for binary) or read one back (content omitted). List with inspect(), remove with detach().`, {
26287
26106
  noteId: stringType().describe("Owning note"),
26288
26107
  title: stringType().describe("Attachment title \u2014 the upsert/read key on this note"),
26289
26108
  content: stringType().optional().describe("Content to write (text, or base64 for binary). Omit to read the attachment instead."),
@@ -26329,9 +26148,7 @@ inspect(noteId); remove with detach().`, {
26329
26148
  throw error;
26330
26149
  }
26331
26150
  });
26332
- server.tool("detach", `Remove an attachment from a note \u2014 by attachmentId directly, or by (noteId + title).
26333
- Permanent: attachments have no archive tier; re-attach() from source to undo. Retry-safe \u2014
26334
- an already-removed target returns cleanly instead of erroring.`, {
26151
+ server.tool("detach", `Remove an attachment by attachmentId, or by noteId + title. Permanent; retry-safe.`, {
26335
26152
  attachmentId: stringType().optional().describe("The attachment to remove"),
26336
26153
  noteId: stringType().optional().describe("Owning note \u2014 used with title when the id isn't at hand"),
26337
26154
  title: stringType().optional().describe("Attachment title on noteId")
@@ -26355,16 +26172,7 @@ an already-removed target returns cleanly instead of erroring.`, {
26355
26172
  }
26356
26173
  return txt3({ ok: true, removed: id, ...noteId ? { noteId, title } : {} });
26357
26174
  });
26358
- server.tool("addendum", `Search Master, LLM singletons (responsibilities + protocols + self-correction, not diary), and Knowledge
26359
- for notes containing pending addendum blocks that need to be folded into the main content.
26360
-
26361
- These surfaces should be clean, merged, structured notes \u2014 not stacks of timestamped addendum
26362
- markers. An addendum block on one of these notes is a temporary staging area: read it, fold
26363
- its content into the relevant section body using revise(mode=replace or section=), then leave
26364
- no addendum marker behind. Addendum-style append is appropriate only for sessions, diary
26365
- entries, and logs \u2014 records by nature whose history has value. Everywhere else, merge.
26366
-
26367
- Returns note IDs, titles, kinds, and content snippets so you can identify what to fold in. The successful call also durably marks the pre-close gate.`, {}, async () => {
26175
+ server.tool("addendum", `Find pending addendum blocks on notes that must stay merged documents (master and LLM singletons, knowledge notes). Fold each into its section with revise(section=/find=) and leave no marker; only sessions, diary, logs and dated thread entries accumulate addenda. The call marks its close-gate step.`, {}, async () => {
26368
26176
  const cfg = b();
26369
26177
  if (!cfg.root)
26370
26178
  return txt3({ error: "BrainLLM not bootstrapped \u2014 run bootstrap." });
@@ -26410,33 +26218,7 @@ Returns note IDs, titles, kinds, and content snippets so you can identify what t
26410
26218
  ...found.length === 0 ? { note: "No notes with pending addendums." } : { hint: "Fold each addendum into its note: read the block, then revise(noteId, section='<heading>', body='<merged content>', mode='replace') to absorb it into the right section. These notes must be left clean \u2014 no addendum markers. Addendum-style history belongs only in sessions, diary, and logs. Scoped/autonomous agents: fold only what's in your lane \u2014 leaving personal or out-of-scope addendums for the next interactive session is correct, and calling this tool is what satisfies the pre-close gate." }
26411
26219
  });
26412
26220
  });
26413
- server.tool("maintain", `Run the maintenance sweep. start and close run the lite sweep automatically (ages stale
26414
- threads active \u2192 dormant \u2192 archived). deep=true also surfaces stale notes (untouched past the
26415
- policy window), unconnected threads/knowledge notes (orphan = no connections at all; sink =
26416
- has inbound but no outbound) to wire with connect() \u2014 inbound detection is brain-wide, so a
26417
- note referenced from another area is never misflagged as an orphan \u2014 a structural lint over
26418
- maintained documents (duplicate headings within one note, unbalanced tags, bodies approaching
26419
- the read ceiling), duplicate titles, and any thread day-child that escaped its
26420
- #noteType=threadEntry label. dryRun previews only.
26421
-
26422
- ack=[noteId, \u2026] marks a note reviewed-and-correct: its findings stay quiet until its content
26423
- actually changes, then all of them return. Use it instead of ignoring a flag you have decided
26424
- is fine \u2014 a warning that reappears every run and is correctly ignored every run trains you to
26425
- skim the list, which is where the one finding that DID change gets missed. Same mechanic as a
26426
- linter baseline, same justification. suppressed reports how many findings were withheld.
26427
-
26428
- domain="<name>" narrows the deep passes to one domain's notes \u2014 the equivalent of the lane
26429
- scoping addendum() already has, so a scoped agent's flags arrive in its own lane instead of it
26430
- re-deriving each run that the cross-venture findings belong to someone else.
26431
-
26432
- repair=[noteId, \u2026] fixes an entity-corrupted body in place instead of reporting it: one level of
26433
- double-escaping is unwound ("&amp;lt;" \u2192 "&lt;", "&amp;nbsp;" \u2192 "&nbsp;"), with a revision taken
26434
- first. The substitution is always the same one, so it is a first-class action rather than
26435
- something each caller reinvents with revise(mode="replace"). Compose with dryRun to see the
26436
- outcome without writing. A note that DOCUMENTS the signature rather than carrying it comes back
26437
- unchanged and is reported as such \u2014 ack= those.
26438
-
26439
- coverage names any pass that hit a cap, so a short list is never mistaken for a clean one.`, {
26221
+ server.tool("maintain", `Brain hygiene. Lite (automatic in start/close): ages threads active \u2192 dormant \u2192 archived and checks labels. deep=true adds stale notes, orphans and sinks, structural lint (duplicate headings, unbalanced tags, missing required sections, dated prose in timeless notes, oversized notes), duplicate titles, lapsed or broken claims and hygiene passes. dryRun previews. ack=[ids] silences a note you reviewed until its content changes. domain= narrows deep passes to one lane. repair=[ids] unwinds entity double-escaping in place. coverage names any capped pass.`, {
26440
26222
  deep: booleanType().optional().describe("Deep pass: stale-review + orphan/sink + structural lint + duplicate titles across Memory/Threads and Knowledge (default: false)"),
26441
26223
  dryRun: booleanType().optional().describe("Report what would change without changing it"),
26442
26224
  domain: stringType().optional().describe("Narrow the deep passes to one domain's notes (slugged server-side) \u2014 for scoped agents working a single lane"),
@@ -26456,10 +26238,7 @@ coverage names any pass that hit a cap, so a short list is never mistaken for a
26456
26238
  await markStep("maintain");
26457
26239
  return txt3(report);
26458
26240
  });
26459
- server.tool("forget", `Archive a note (default) or hard-delete it (hard=true). Archiving keeps it in place,
26460
- hidden from default recall \u2014 the safe choice and the only one for anything with history.
26461
- Hard delete is refused while other notes still link here (backlinks are returned so you can
26462
- re-wire with connect() first). To undo an archive, use recover().`, {
26241
+ server.tool("forget", `Archive a note (default \u2014 hidden from default recall, recoverable with recover()) or hard-delete it (hard=true, refused while backlinked).`, {
26463
26242
  noteId: stringType().describe("Note to forget"),
26464
26243
  reason: stringType().optional().describe("Why \u2014 recorded in the note before archiving"),
26465
26244
  hard: booleanType().optional().describe("Permanently delete instead of archive")
@@ -26505,11 +26284,7 @@ re-wire with connect() first). To undo an archive, use recover().`, {
26505
26284
  const relations = relationSnippet(note);
26506
26285
  return txt3({ ok: true, archived: noteId, title: note.title, ...relations ? { relations } : {} });
26507
26286
  });
26508
- server.tool("recover", `Restore an archived or resolved note: removes #archived, clears #closed, resets status
26509
- to active. Use to undo forget() or reconsider a resolved thread / note. Does not restore
26510
- note content \u2014 use revise() to fix content, or get_revisions (full mode) to roll back to a
26511
- prior snapshot. For notes deleted from Trilium entirely (not just archived), use undelete_note
26512
- (full mode) instead.`, {
26287
+ server.tool("recover", `Restore an archived or resolved note: clears #archived and #closed and resets status. Content is untouched \u2014 use revise(), or get_revisions for an older snapshot. Notes deleted from Trilium need undelete_note.`, {
26513
26288
  noteId: stringType().describe("The archived or resolved note to restore"),
26514
26289
  reason: stringType().optional().describe("Why it was recovered \u2014 written as an addendum"),
26515
26290
  date: stringType().optional().describe("ISO date (default: today)")
@@ -26543,17 +26318,7 @@ prior snapshot. For notes deleted from Trilium entirely (not just archived), use
26543
26318
  ...warnings.length ? { sanitized: warnings } : {}
26544
26319
  });
26545
26320
  });
26546
- server.tool("template", `Serve the canonical structure for a content kind \u2014 the enforced skeleton, the
26547
- top-to-bottom structure, and the rules writes are held to. Read it BEFORE writing a kind for
26548
- the first time in a session, or when unsure. The write tools enforce what can be enforced
26549
- server-side (heading normalization, duplicate-heading detection, thread Goal/Resolution rules,
26550
- Last-updated stamps); this tool serves the full contract including what remains authorial.
26551
-
26552
- Template for the schema, sibling for the shape: this gives you the skeleton, but the
26553
- conventions it cannot encode \u2014 how deep the headings actually go, how a table is laid out, how
26554
- much prose a section warrants, what a good title looks like for this kind \u2014 live in the notes
26555
- that already exist. Read one before writing another. Content of the same kind must read like
26556
- its siblings, and a note that satisfies the skeleton can still be the odd one out.`, {
26321
+ server.tool("template", `The canonical structure for a kind: skeleton, top-to-bottom structure, and the rules writes are held to (including what stays authorial). Read it before your first write of a kind; then read an existing sibling and match its shape.`, {
26557
26322
  kind: enumType(Kinds).describe("The content kind to serve the canonical structure for")
26558
26323
  }, async ({ kind }) => {
26559
26324
  const rule = structureRuleFor(kind);
@@ -26568,22 +26333,17 @@ its siblings, and a note that satisfies the skeleton can still be the odd one ou
26568
26333
  ...rule ? { structure: rule.structure, rules: rule.rules } : { note: "No bespoke structure for this kind \u2014 server meta line + body." },
26569
26334
  skeleton,
26570
26335
  conventions: [
26571
- "Headings h2\u2013h4 only \u2014 h1 is the note title; h5/h6 are demoted on write.",
26572
- "Minimal headings \u2014 only ones that earn their place. Depth comes from layout (tables, lists, emphasis), not heading proliferation. A heading whose section is one sentence was not a section; headings are also what revise(section=) addresses, so proliferating them makes every future edit ambiguous too.",
26573
- "Titles: concise, maximum 4 words, no dates or run numbers (dates defeat title-dedup). A title that won't trim to 4 words without losing what it identifies is a signal the CONTENT should be split, not the title stretched.",
26574
- "Merge, don't stack: Master, LLM (excluding the session/diary/log surfaces) and Knowledge notes are clean merged documents \u2014 fold new content into the relevant section's body. Dated, append-only history is expected only for sessions, diary entries and logs, which are records by nature. Everywhere else, merge.",
26575
- "Content of the same kind matches its siblings \u2014 same structure, layout, and format. Read an existing one before writing another; if a pattern needs improving, improve it everywhere, since one better-shaped note among twenty is drift, not an improvement."
26336
+ "Headings h2\u2013h4 only (h1 is the title); only headings that earn their place \u2014 depth comes from tables, lists and emphasis.",
26337
+ "Titles: at most 4 words, no dates or run numbers (they defeat dedup-by-title).",
26338
+ "Timeless kinds carry no state, version or decision history: state goes to the domain's Current State note, history to thread entries and sessions.",
26339
+ "One sentence is enough if it says the thing; drop what is rarely relevant.",
26340
+ "Merge, don't stack: only sessions, diary, logs and dated thread entries are append-only records.",
26341
+ "Match your siblings' structure; improve a pattern everywhere or nowhere.",
26342
+ "Every note carries an icon except logs: the kind default is set for you; pass icon= to choose a fitting one."
26576
26343
  ]
26577
26344
  });
26578
26345
  });
26579
- server.tool("graph", `The graph view \u2014 render the brain's relation graph as a Mermaid flowchart.
26580
- Scope: the whole brain (default), or a neighborhood (pass noteId + depth). Nodes are the typed
26581
- notes, colored by area; edges are the typed relations (~template excluded). The Mermaid source
26582
- is returned AND upserted into the maintained "Graph" note under Insights (a native Trilium
26583
- mermaid note), so the view renders in Trilium and in any Mermaid-capable client. On-demand only \u2014
26584
- the note reflects the brain as of this call, not automatically after later writes; call again to
26585
- refresh. A scoped (noteId) call replaces the note's content with just that neighborhood, not the
26586
- whole-brain view.`, {
26346
+ server.tool("graph", `Render the relation graph as a Mermaid flowchart \u2014 the whole brain, or a neighborhood (noteId + depth). Returns the source and writes it to the Insights/Graph note. On demand only; a scoped call replaces the note's content.`, {
26587
26347
  noteId: stringType().optional().describe("Center the graph on this note's neighborhood instead of the whole brain"),
26588
26348
  depth: numberType().optional().describe("Neighborhood hops when noteId is given (default: 2)"),
26589
26349
  includeArchived: booleanType().optional().describe("Include archived notes (default: false)")
@@ -26678,19 +26438,12 @@ whole-brain view.`, {
26678
26438
  mermaid
26679
26439
  });
26680
26440
  });
26681
- server.tool("day", `The new-day sweep payload \u2014 one call replacing the manual multi-read protocol on the
26682
- first session of a day. Serves: whether today is genuinely fresh (no addendum blocks in
26683
- today's session note), the previous session in full, that day's change log, the notes touched
26684
- since then, and the current month's deliverables note in full. Advance the deliverables note's
26685
- statuses with revise(find=) and present the findings in the first message \u2014 grounded strictly
26686
- in what the touched notes evidence.
26687
-
26688
- recap=true answers the other question \u2014 "what happened TODAY, in order, across every surface"
26689
- \u2014 by returning every addendum block written today across the session note, the diary and every
26690
- thread day-child, chronologically, with its identification line. start() is tuned for a fresh
26691
- day and does not serve a day already several instances deep; reconstructing one by hand takes a
26692
- read per surface, and as more work runs unattended that shape becomes the norm rather than the
26693
- exception.`, {
26441
+ server.tool("day", `The new-day sweep in one call: whether today is fresh (no addendum blocks in today's session
26442
+ note), the previous session in full, that day's log, and the notes touched since. Present what the
26443
+ touched notes evidence in the first message.
26444
+
26445
+ recap=true instead returns every addendum block written today across the session note, the diary
26446
+ and every thread day-child, in time order with its identification line.`, {
26694
26447
  date: stringType().optional().describe("ISO date YYYY-MM-DD (default: today)"),
26695
26448
  recap: booleanType().optional().describe("Return today's addendum blocks across sessions, diary and thread children in chronological order, instead of the new-day sweep")
26696
26449
  }, async ({ date, recap }) => {
@@ -26778,37 +26531,19 @@ exception.`, {
26778
26531
  }));
26779
26532
  } catch {}
26780
26533
  }
26781
- const monthName = new Date(`${todayStr}T00:00:00Z`).toLocaleString("en-US", { month: "long", timeZone: "UTC" });
26782
- let deliverables = null;
26783
- const monthNotes = await trilium.searchNotes("#noteType=user", { ancestorNoteId: cfg.knowledge.master, fastSearch: true, limit: 100 }).catch(() => ({ results: [] }));
26784
- const monthNote = monthNotes.results.find((n) => sameTitle(n.title, monthName));
26785
- if (monthNote) {
26786
- deliverables = { id: monthNote.noteId, title: monthNote.title, content: await trilium.getNoteContent(monthNote.noteId).catch(() => "") };
26787
- }
26788
26534
  return txt3({
26789
26535
  date: todayStr,
26790
26536
  newDay,
26791
- month: monthName,
26792
26537
  lastSession,
26793
26538
  previousLog,
26794
26539
  changes: changes.length ? changes : undefined,
26795
- deliverables: deliverables ?? { note: `No "${monthName}" deliverables note found in Knowledge/Master \u2014 first session of a new month: baseline a fresh one from the venture strategies and the schedule state.` },
26796
26540
  next: [
26797
- "Skim lastSession, previousLog, and changes for evidenced outputs \u2014 never plausibility.",
26798
- "Advance the deliverables note's counts/statuses with revise(noteId, find=..., body=...).",
26799
- "Present the findings in the first message: what moved, what's now due, current counts against the month."
26541
+ "Skim lastSession, previousLog and changes for evidenced outputs \u2014 never plausibility.",
26542
+ "Present what moved in the first message."
26800
26543
  ]
26801
26544
  });
26802
26545
  });
26803
- server.tool("brain", `Surface the entire BrainLLM content tree \u2014 every typed note across all five content areas
26804
- (Master, LLM, Memory, Knowledge, Insights), grouped by area and sub-container, with
26805
- id/title/kind/status/dates. Use to audit what the brain contains or locate a specific note.
26806
- Structural containers are excluded; only content notes appear.
26807
-
26808
- Insights returns { logs, claims }. Claims were missing entirely before V12 \u2014 they live in a
26809
- container resolved by title rather than named in the config, and this read was scoped one level
26810
- too deep to reach them, so the register was invisible to the tool that calls itself the full
26811
- inventory.`, {
26546
+ server.tool("brain", `The full inventory: every content note across all five areas with id, title, kind, status, parent and dates, grouped by area (Insights returns logs and claims). Use it to audit or locate a note; read parent, not position \u2014 each group is flat. includeArchived= adds archived notes.`, {
26812
26547
  includeArchived: booleanType().optional().describe("Include archived/resolved notes (default: false)")
26813
26548
  }, async ({ includeArchived }) => {
26814
26549
  const cfg = b();
@@ -26885,25 +26620,7 @@ inventory.`, {
26885
26620
  const total = masterAll.length + llmSingletons.length + llmDiary.length + sessions.length + threads.length + kMaster.length + kDomains.length + insights.length;
26886
26621
  return txt3({ total, areas });
26887
26622
  });
26888
- server.tool("assembly", `What the brain HOLDS \u2014 every note by title, grouped under the surface it lives in, with each
26889
- surface's own purpose alongside it. The awareness read: "what do I already know here", answered
26890
- in one call before deciding whether to look something up.
26891
-
26892
- Distinct from brain(), which is an inventory: brain() returns id, kind, status, parent, dates and
26893
- relations for every note, and is what you want when auditing or locating one. assembly() answers
26894
- a different question and is shaped for it:
26895
-
26896
- - **Dated collections collapse.** Sessions, diary entries and logs are titled [yyyy-mm-dd], so
26897
- listing them is a wall of dates carrying no information about content. They come back as a
26898
- count and a span instead. This is the difference between a listing and an awareness read \u2014 the
26899
- full titles are one brain() call away when you actually need them.
26900
- - **Domains nest.** Each domain book appears with its own information and sources notes beneath
26901
- it, which is the real shape of what the brain knows rather than the flat list brain() returns.
26902
- - **Threads group by status**, newest activity first \u2014 the working set is the actionable part.
26903
- - **Purposes are read from the container notes themselves**, the text bootstrap engraved on them,
26904
- so this can never drift from what the brain actually says about itself.
26905
-
26906
- Pass area= to zoom into one surface and get its full detail.`, {
26623
+ server.tool("assembly", `What the brain holds: every note by title, grouped under its surface with that surface's purpose. Dated collections (sessions, diary, logs) collapse to a count and span; domains nest their notes; threads group by status. The awareness read \u2014 use brain() instead to audit or locate by id. area= zooms into one surface.`, {
26907
26624
  area: enumType(["master", "llm", "memory", "knowledge", "insights"]).optional().describe("Zoom into one surface instead of all five"),
26908
26625
  includeArchived: booleanType().optional().describe("Include archived/resolved notes (default: false)")
26909
26626
  }, async ({ area, includeArchived }) => {
@@ -27007,12 +26724,7 @@ Pass area= to zoom into one surface and get its full detail.`, {
27007
26724
  ...includeArchived ? {} : { note: "Archived and resolved notes are excluded \u2014 pass includeArchived=true to see them." }
27008
26725
  });
27009
26726
  });
27010
- server.tool("bootstrap", `Initialize the BrainLLM structure in Trilium (idempotent \u2014 safe to re-run; refreshes config,
27011
- heals singletons a newer version introduced, and re-engraves container purposes if the structure
27012
- already exists). Creates the five areas \u2014 Master (Biography/Goals/Preferences),
27013
- LLM (Responsibilities/Protocols/Self-correction/Diary), Memory (Sessions/Threads), Knowledge (Master/Domains),
27014
- Insights (Logs) \u2014 each engraved with its purpose, and writes brainllm.json. Active
27015
- immediately, no restart needed.`, {}, async () => {
26727
+ server.tool("bootstrap", `Create the BrainLLM structure in Trilium, or verify and refresh it when it exists (idempotent): the five areas with their containers, singletons and engraved purposes, plus brainllm.json. Only creates a new tree when the stored root is confirmed deleted.`, {}, async () => {
27016
26728
  if (b().root) {
27017
26729
  try {
27018
26730
  const existing = await trilium.getNote(b().root);
@@ -27488,7 +27200,7 @@ note.dateModified >= 'YYYY-MM-DD', AND/OR. Unscoped unless ancestorNoteId is giv
27488
27200
  await trilium.deleteNote(noteId);
27489
27201
  return txt4({ ok: true, deleted: noteId });
27490
27202
  });
27491
- server.tool("undelete_note", "Recover a recently Trilium-deleted note from Trilium's trash. canBeUndeleted must be true (check note_history). Distinct from recover() which restores BrainLLM-archived notes. Re-applies the BrainLLM label set for the container it lands in \u2014 the restore itself does not bring attributes back, and a note without #noteType is invisible to every read path.", { noteId: stringType() }, async ({ noteId }) => {
27203
+ server.tool("undelete_note", `Restore a note deleted in Trilium (check note_history for canBeUndeleted) and re-apply its BrainLLM labels. For BrainLLM-archived notes use recover().`, { noteId: stringType() }, async ({ noteId }) => {
27492
27204
  await trilium.undeleteNote(noteId);
27493
27205
  const cfg = b();
27494
27206
  const note = await trilium.getNote(noteId);
@@ -27660,7 +27372,7 @@ note.dateModified >= 'YYYY-MM-DD', AND/OR. Unscoped unless ancestorNoteId is giv
27660
27372
  server.tool("get_year_note", "Get (or auto-create) the journal year note. Format: YYYY.", { year: stringType() }, async ({ year }) => txt4(await trilium.getYearNote(year)));
27661
27373
  server.tool("get_inbox_note", "Get the Trilium inbox note for a date (fixed #inbox note, or the day note). Format: YYYY-MM-DD.", { date: stringType().optional() }, async ({ date }) => txt4(await trilium.getInboxNote(date ?? localToday())));
27662
27374
  server.tool("get_app_info", "Trilium server version, DB version, runtime metadata.", {}, async () => txt4(await trilium.getAppInfo()));
27663
- server.tool("create_backup", "Trigger a named Trilium database backup. ETAPI confirms the logical request completed but does not expose the resulting file path or format (it may be .db or .tnbackup). Use a descriptive name for milestone snapshots (e.g. 'before-migration').", {
27375
+ server.tool("create_backup", `Named Trilium database backup (raw). The resulting file path is not exposed.`, {
27664
27376
  name: stringType().optional().describe("Backup file name without .db extension (default: brainllm-{today})"),
27665
27377
  date: stringType().optional().describe("ISO date used in the default name when name is omitted (default: today)")
27666
27378
  }, async ({ name, date }) => {
@@ -28824,7 +28536,7 @@ function createServer(origin = null) {
28824
28536
  const s = new McpServer({
28825
28537
  name: "BrainLLM",
28826
28538
  title: "BrainLLM",
28827
- version: "12.4.1",
28539
+ version: "12.5.0",
28828
28540
  icons: brandingIcons(origin)
28829
28541
  });
28830
28542
  registerTools(s, trilium, brainRef);
@@ -28977,12 +28689,14 @@ if (port) {
28977
28689
  return withCors(await handleRegister(req));
28978
28690
  }
28979
28691
  }
28980
- if (url.pathname === "/") {
28692
+ const rootMcpRequest = url.pathname === "/" && (req.method !== "GET" || req.headers.has("mcp-session-id") || req.headers.has("mcp-protocol-version") || (req.headers.get("accept") ?? "").includes("application/json") || (req.headers.get("accept") ?? "").includes("text/event-stream"));
28693
+ if (url.pathname === "/" && !rootMcpRequest) {
28981
28694
  return withCors(new Response(landingPage(base, oauthOn, true), {
28982
28695
  headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }
28983
28696
  }));
28984
28697
  }
28985
- if (url.pathname !== "/mcp" && url.pathname !== "/sse" && url.pathname !== "/messages") {
28698
+ const isMcpEndpoint = url.pathname === "/mcp" || rootMcpRequest;
28699
+ if (!isMcpEndpoint && url.pathname !== "/sse" && url.pathname !== "/messages") {
28986
28700
  return withCors(new Response("Not Found", { status: 404 }));
28987
28701
  }
28988
28702
  const transportLimit = rateLimited(rateLimiters.transport, requestKey);