brainllm 12.2.0 → 12.3.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/.env.example CHANGED
@@ -17,7 +17,7 @@ TRILIUM_ETAPI_TOKEN=
17
17
  TRILIUM_PASSWORD=
18
18
 
19
19
  # ── Tool surface ───────────────────────────────────────────────────────────────
20
- # core (default): the 39 brain-aware tools. full: adds the 33 raw ETAPI tools.
20
+ # core (default): the 44 brain-aware tools. full: adds the 33 raw ETAPI tools.
21
21
  # BRAINLLM_MODE=full
22
22
 
23
23
  # ── Timezone ───────────────────────────────────────────────────────────────────
package/README.md CHANGED
@@ -10,7 +10,7 @@ A persistent, graph-structured second brain for Claude and any MCP client — bu
10
10
 
11
11
  [**brainllm site**](https://miisodev.github.io/BrainLLM/) · [How it works](https://miisodev.github.io/BrainLLM/how-it-works.html) · [Use cases](https://miisodev.github.io/BrainLLM/use-cases.html) · [Docs](https://miisodev.github.io/BrainLLM/docs.html)
12
12
 
13
- [![Version](https://img.shields.io/badge/version-12.2.0-f59e0b?style=flat-square)](https://github.com/miisodev/BrainLLM/releases)
13
+ [![Version](https://img.shields.io/badge/version-12.3.0-f59e0b?style=flat-square)](https://github.com/miisodev/BrainLLM/releases)
14
14
  [![CI](https://img.shields.io/github/actions/workflow/status/miisodev/BrainLLM/ci.yml?branch=main&style=flat-square&label=CI&color=f59e0b)](https://github.com/miisodev/BrainLLM/actions/workflows/ci.yml)
15
15
  [![License: MIT](https://img.shields.io/badge/license-MIT-a1a1aa?style=flat-square)](./LICENSE)
16
16
  [![Runtime: Bun](https://img.shields.io/badge/runtime-Bun%20%E2%89%A5%201.0-a1a1aa?style=flat-square&logo=bun&logoColor=white)](https://bun.sh)
@@ -50,7 +50,8 @@ Placement, naming, labels, deduplication, relation bookkeeping, lifecycle aging,
50
50
  - **A visible graph** — `graph()` renders the whole relation graph (or any note's neighborhood) as a Mermaid flowchart, maintained as a native Trilium note.
51
51
  - **One-call day orientation** — `day()` serves the previous session, its change log, everything touched since, and the month's deliverables in a single call.
52
52
  - **Resilient plumbing** — every backend call is timeout-bounded with retry on idempotent reads; all writes are idempotent or duplicate-guarded, so crashes and retries never double-write; content surgery survives the editor's own HTML rewriting; renaming a domain cascades to everything inside it; the maintenance sweep heals drift it finds.
53
- - **Multi-agent by default** — all write-classified tools serialize behind a process-wide FIFO lock, so two agents (an interactive session and an automated run, say) writing through one hosted instance queue in arrival order instead of racing; reads stay fully parallel. Deletion catch-up in the daily log keeps a note deleted after its day's close from vanishing without a trace.
53
+ - **Multi-agent by default** — all write-classified tools serialize behind a process-wide FIFO lock, so two agents (an interactive session and an automated run, say) writing through one hosted instance queue in arrival order instead of racing; reads stay fully parallel. Deletion catch-up in the daily log keeps a note deleted after its day's close from vanishing without a trace — and its window is configurable (`deletionCatchupDays` in the lifecycle policy), defaulting to Trilium's 7-day retention.
54
+ - **Ask in prose, split on a seam** — `consistency(subject="…")` finds every note asserting about a fact however it is phrased, no regex guessing; `consistency(pattern, staleAfterDays=N)` surfaces figures held in exactly one untouched note, the ones that rot silently because nothing disagrees with them; `read(ids=[…])` batches a multi-note orientation into one round trip; and `split(noteId, sections=[…], into="…")` is the write half of the oversized-note problem — it lifts whole sections into a new note and leaves a pointer back. Information notes can carry a `#mandate` marker so a scoped session finds the one note it must obey without reading every note's prose.
54
55
 
55
56
  ---
56
57
 
@@ -193,13 +194,13 @@ Every tool declares whether it reads, writes, or destroys, so your client can gr
193
194
 
194
195
  The classification lives in one reviewable table ([`src/annotations.ts`](./src/annotations.ts)) rather than scattered across registrations, and its default is deliberately unsafe-side: a tool missing from the table is treated as a **write**, never a read, and says so at startup. Three are worth knowing because they look like reads and aren't — `start()` creates today's diary and session stubs, `session()` runs the maintenance sweep, and `graph()` writes the rendered graph note.
195
196
 
196
- ### Core — universal verbs (32)
197
+ ### Core — universal verbs (34)
197
198
 
198
199
  | Group | Tools |
199
200
  |---|---|
200
201
  | Session lifecycle | `start` · `day` · `session` · `remarks` · `close` · `backup` |
201
- | Writing | `remember` · `diary` · `revise` · `resolve` · `withdraw` · `recover` |
202
- | Reading & search | `recall` · `domain` · `brain` · `assembly` · `outline` · `inspect` · `diff` · `template` · `consistency` |
202
+ | Writing | `remember` · `diary` · `revise` · `split` · `resolve` · `withdraw` · `recover` |
203
+ | Reading & search | `read` · `recall` · `domain` · `brain` · `assembly` · `outline` · `inspect` · `diff` · `template` · `consistency` |
203
204
  | Graph | `connect` · `explore` · `graph` |
204
205
  | Attachments & labels | `attach` · `detach` · `label` |
205
206
  | Maintenance & system | `addendum` · `maintain` · `claim` · `forget` · `bootstrap` |
package/dist/index.js CHANGED
@@ -25099,7 +25099,8 @@ var SymmetricRelations = ["worksWith"];
25099
25099
  var DEFAULT_POLICY = {
25100
25100
  dormantAfterDays: 21,
25101
25101
  archiveDormantAfterDays: 45,
25102
- staleAfterDays: 7
25102
+ staleAfterDays: 7,
25103
+ deletionCatchupDays: 7
25103
25104
  };
25104
25105
 
25105
25106
  // src/time.ts
@@ -26445,6 +26446,57 @@ function splitByH3(html) {
26445
26446
  }
26446
26447
  return out;
26447
26448
  }
26449
+ function sectionSpans(html) {
26450
+ const closed = closeDangling(html);
26451
+ const heads = [];
26452
+ const re = /<h([2-4])(?:\s[^>]*)?>([\s\S]*?)<\/h\1>/gi;
26453
+ let m;
26454
+ while ((m = re.exec(closed)) !== null) {
26455
+ const text = decodeEntities(m[2].replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim();
26456
+ if (!text)
26457
+ continue;
26458
+ heads.push({ level: Number(m[1]), text, start: m.index, bodyStart: m.index + m[0].length });
26459
+ }
26460
+ const seen = new Map;
26461
+ return heads.map((h, i) => {
26462
+ const next = heads.findIndex((x, j) => j > i && x.level <= h.level);
26463
+ const end = next === -1 ? closed.length : heads[next].start;
26464
+ const occurrence = (seen.get(`${h.level}::${h.text.toLowerCase()}`) ?? 0) + 1;
26465
+ seen.set(`${h.level}::${h.text.toLowerCase()}`, occurrence);
26466
+ return { level: h.level, text: h.text, start: h.start, bodyStart: h.bodyStart, end, occurrence };
26467
+ });
26468
+ }
26469
+ function extractSections(html, headings) {
26470
+ if (!headings.length)
26471
+ return { html, extracted: "", matched: [], missed: [] };
26472
+ const closed = closeDangling(html);
26473
+ const spans = sectionSpans(closed);
26474
+ const key = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
26475
+ const taken = [];
26476
+ const missed = [];
26477
+ const used = new Set;
26478
+ for (const wanted of headings) {
26479
+ const idx = spans.findIndex((s, i) => !used.has(i) && key(s.text) === key(wanted));
26480
+ if (idx === -1) {
26481
+ missed.push(wanted);
26482
+ continue;
26483
+ }
26484
+ used.add(idx);
26485
+ taken.push(spans[idx]);
26486
+ }
26487
+ if (!taken.length)
26488
+ return { html, extracted: "", matched: [], missed };
26489
+ const sorted = [...taken].sort((a, b) => a.start - b.start);
26490
+ let remaining = closed;
26491
+ for (const s of [...sorted].sort((a, b) => b.start - a.start)) {
26492
+ remaining = `${remaining.slice(0, s.start)}${remaining.slice(s.end)}`.replace(/\n{3,}/g, `
26493
+
26494
+ `);
26495
+ }
26496
+ const extracted = sorted.map((s) => closed.slice(s.start, s.end).trim()).filter(Boolean).join(`
26497
+ `);
26498
+ return { html: remaining.trim(), extracted, matched: taken.map((s) => s.text), missed };
26499
+ }
26448
26500
  function mergeUnderSection(current, incoming, parentHeading) {
26449
26501
  const groups = splitByH3(incoming);
26450
26502
  if (!groups.length)
@@ -26937,6 +26989,9 @@ function labelPlan(kind, opts, date) {
26937
26989
  if ((kind === "information" || kind === "domain" || kind === "sources") && opts.domain) {
26938
26990
  labels.push({ name: "domain", value: slugify(opts.domain) });
26939
26991
  }
26992
+ if (kind === "information" && opts.mandate) {
26993
+ labels.push({ name: "mandate", value: "" });
26994
+ }
26940
26995
  for (const topic of opts.topics ?? []) {
26941
26996
  const slug = slugify(topic);
26942
26997
  if (slug)
@@ -27099,7 +27154,8 @@ async function sweep(trilium, cfg, opts = {}) {
27099
27154
  policy: {
27100
27155
  dormantAfterDays: policy.dormantAfterDays,
27101
27156
  archiveDormantAfterDays: policy.archiveDormantAfterDays,
27102
- staleAfterDays: policy.staleAfterDays
27157
+ staleAfterDays: policy.staleAfterDays,
27158
+ deletionCatchupDays: policy.deletionCatchupDays ?? 7
27103
27159
  }
27104
27160
  };
27105
27161
  if (!cfg.root)
@@ -27231,7 +27287,7 @@ async function sweep(trilium, cfg, opts = {}) {
27231
27287
  }
27232
27288
  if (deep && !domainSlug) {
27233
27289
  const feed = await trilium.getNoteHistory(cfg.root).catch(() => []);
27234
- const deletionsWindow = isoDaysAgo(7);
27290
+ const deletionsWindow = isoDaysAgo(policy.deletionCatchupDays ?? 7);
27235
27291
  const recent = feed.filter((h) => h.current_isDeleted && h.date.slice(0, 10) >= deletionsWindow);
27236
27292
  report.scanned += recent.length;
27237
27293
  for (const [i, h] of recent.entries()) {
@@ -27794,13 +27850,14 @@ async function generateDailyLog(trilium, cfg, date) {
27794
27850
  }
27795
27851
  var DELETION_CATCHUP_DAYS = 7;
27796
27852
  async function catchUpDeletions(trilium, cfg, today) {
27797
- const empty = { windowDays: DELETION_CATCHUP_DAYS, deletionsFound: 0, regenerated: [] };
27853
+ const windowDays = cfg.policy.deletionCatchupDays ?? DELETION_CATCHUP_DAYS;
27854
+ const empty = { windowDays, deletionsFound: 0, regenerated: [] };
27798
27855
  if (!cfg.root || !cfg.insights.logs)
27799
27856
  return empty;
27800
27857
  const history = await trilium.getNoteHistory(cfg.root).catch(() => []);
27801
27858
  if (!history.length)
27802
27859
  return empty;
27803
- const cutoff = new Date(Date.parse(`${today}T00:00:00Z`) - (DELETION_CATCHUP_DAYS - 1) * 86400000).toISOString().slice(0, 10);
27860
+ const cutoff = new Date(Date.parse(`${today}T00:00:00Z`) - (windowDays - 1) * 86400000).toISOString().slice(0, 10);
27804
27861
  const byDay = new Map;
27805
27862
  let total = 0;
27806
27863
  for (const h of history) {
@@ -27822,7 +27879,7 @@ async function catchUpDeletions(trilium, cfg, today) {
27822
27879
  if (report)
27823
27880
  regenerated.push(day);
27824
27881
  }
27825
- return { windowDays: DELETION_CATCHUP_DAYS, deletionsFound: total, regenerated };
27882
+ return { windowDays, deletionsFound: total, regenerated };
27826
27883
  }
27827
27884
 
27828
27885
  // src/tools-surface.ts
@@ -27853,7 +27910,8 @@ async function skim(trilium, ancestorNoteId, opts = {}) {
27853
27910
  status: labelOf(n, "status") ?? undefined,
27854
27911
  updated: n.dateModified.slice(0, 10),
27855
27912
  preview: toText(content, 160),
27856
- ...relations ? { relations } : {}
27913
+ ...relations ? { relations } : {},
27914
+ ...n.attributes.some((a) => a.type === "label" && a.name === "mandate") ? { mandate: true } : {}
27857
27915
  };
27858
27916
  }));
27859
27917
  }
@@ -28288,7 +28346,7 @@ sessions need one section, not five documents.`, {
28288
28346
  deleted: [],
28289
28347
  flagged: [`sweep failed: ${e}`],
28290
28348
  dryRun: false,
28291
- policy: { dormantAfterDays: cfg.policy.dormantAfterDays, archiveDormantAfterDays: cfg.policy.archiveDormantAfterDays, staleAfterDays: cfg.policy.staleAfterDays }
28349
+ policy: { dormantAfterDays: cfg.policy.dormantAfterDays, archiveDormantAfterDays: cfg.policy.archiveDormantAfterDays, staleAfterDays: cfg.policy.staleAfterDays, deletionCatchupDays: cfg.policy.deletionCatchupDays ?? 7 }
28292
28350
  })),
28293
28351
  buildDigest(trilium, cfg, { depth: depth ?? "digest" })
28294
28352
  ]);
@@ -28748,6 +28806,7 @@ For diary entries use the dedicated diary() tool \u2014 remember(kind="diary") i
28748
28806
  })).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"),
28749
28807
  topics: arrayType(stringType()).optional().describe("Topic tags \u2014 slugged server-side"),
28750
28808
  supersedes: stringType().optional().describe("noteId this replaces \u2014 old note is archived and wired supersedes"),
28809
+ 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."),
28751
28810
  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"),
28752
28811
  strict: booleanType().optional().describe("Refuse the write when the body needs structural repair (unclosed tags, <br> runs) instead of accepting the repaired form"),
28753
28812
  connect: arrayType(objectType({
@@ -28756,9 +28815,9 @@ For diary entries use the dedicated diary() tool \u2014 remember(kind="diary") i
28756
28815
  })).optional().describe("Relations to wire from this note in the same call \u2014 same semantics as connect() (idempotent, worksWith wired both ways)"),
28757
28816
  icon: stringType().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name ("brain"); normalized server-side'),
28758
28817
  date: stringType().optional().describe("ISO date override (default: today)")
28759
- }, async ({ kind, title, body, goal, identity, domain, revision, topics, supersedes, mustCreate, strict, connect: connectRels, icon, date }) => {
28818
+ }, async ({ kind, title, body, goal, identity, domain, revision, topics, supersedes, mandate, mustCreate, strict, connect: connectRels, icon, date }) => {
28760
28819
  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.`);
28761
- const opts = { domain, topics, date };
28820
+ const opts = { domain, topics, date, ...mandate ? { mandate: true } : {} };
28762
28821
  const d = date ?? today();
28763
28822
  const { html, warnings: sanitizeWarnings } = renderBody(body ?? "");
28764
28823
  if (strict) {
@@ -29256,6 +29315,7 @@ Use recall() for keyword or full-text search instead.`, {
29256
29315
  modified: n.dateModified.slice(0, 10),
29257
29316
  idleDays: idle,
29258
29317
  ...stale ? { stale: true } : {},
29318
+ ...hasLabel(n, "mandate") ? { mandate: true } : {},
29259
29319
  ...hasLabel(n, "archived") ? { archived: true } : {},
29260
29320
  ...relations ? { relations } : {}
29261
29321
  });
@@ -29274,6 +29334,30 @@ Use recall() for keyword or full-text search instead.`, {
29274
29334
  ...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}"].` } : {}
29275
29335
  });
29276
29336
  });
29337
+ server.tool("read", `Batched multi-note read: several note bodies in ONE round trip. Give ids=[...] and get back
29338
+ {count, notes:[{id, title, kind, content, relations?}]}. The orientation step is structurally a
29339
+ fan-out \u2014 nineteen notes, several very large \u2014 and N separate surface calls is N chances to
29340
+ time out mid-read, which is the one thing that wastes budget rather than spending it. This tool
29341
+ collapses the fan-out into one call.
29342
+
29343
+ Pure read: returns bodies verbatim, no placeholders, no dedup, no kind restrictions. Note the
29344
+ cap: up to 10 ids per call. For one id prefer the kinded read (knowledge()/memory()/llm()) which
29345
+ returns the same shape through the same helper; for a huge note the sectioned read is still the
29346
+ efficient path \u2014 read() returns whole bodies.`, {
29347
+ ids: arrayType(stringType()).min(1).max(10).describe("Note ids to read \u2014 one body per id, one round trip (cap 10)")
29348
+ }, async ({ ids }) => {
29349
+ const notes = await Promise.all(ids.map(async (id) => {
29350
+ try {
29351
+ const note = await trilium.getNote(id);
29352
+ const content = await trilium.getNoteContent(id).catch(() => "");
29353
+ const relations = relationSnippet(note);
29354
+ return { id, title: note.title, kind: ownedLabel(note, "noteType") ?? undefined, content, ...relations ? { relations } : {} };
29355
+ } catch {
29356
+ return { id, missing: true };
29357
+ }
29358
+ }));
29359
+ return txt3({ count: notes.length, notes });
29360
+ });
29277
29361
  server.tool("revise", `Update an existing note by id. Append a dated addendum (default), replace the body
29278
29362
  (mode=replace), edit a heading section in place (section=), insert around a heading
29279
29363
  (section= with mode="before"/"after"), or do targeted string surgery (find=, or edits= for
@@ -29646,6 +29730,77 @@ status, and archive it in place (it stays where it is, excluded from default rec
29646
29730
  ...warnings.length ? { sanitized: warnings } : {}
29647
29731
  });
29648
29732
  });
29733
+ server.tool("split", `Split a note on its section seams: move whole sections (heading + body) out of one note into a
29734
+ new note, leaving a pointer back. The trim affordance \u2014 the write half of the oversized-note
29735
+ problem, which maintain() detects but nothing acts on. A note past the read ceiling with twenty
29736
+ sections becomes two readable notes instead of one unusable one, and the source stays navigable
29737
+ because a pointer marks where the content went.
29738
+
29739
+ Pass sections=[heading1, heading2, ...] (heading TEXT, the same contract section= uses) and
29740
+ into="<new title>". Each named section is lifted out whole \u2014 nested sub-sections go with their
29741
+ parent. Repeated headings are consumed first-match-per-request: passing the same heading twice
29742
+ takes the next occurrence each time. The new note is created under the same parent, typed the
29743
+ same as the source, and carries the source's #domain/#topic labels; the source is left with a
29744
+ "Split into <title>" pointer and a ~references relation to the new note. Sections that do not
29745
+ exist are reported in missed= and left in place.
29746
+
29747
+ Returns the new note id, the sections moved, and the source's remaining size. A revision is
29748
+ taken of the source before it is written. Refused on containers and singletons \u2014 this is for
29749
+ content notes.`, {
29750
+ noteId: stringType().describe("Note to split \u2014 the source whose sections are moving out"),
29751
+ sections: arrayType(stringType()).min(1).max(20).describe("Section headings (text) to move out \u2014 nested sub-sections go with their parent"),
29752
+ into: stringType().describe("Title of the new note that receives the moved sections"),
29753
+ date: stringType().optional().describe("ISO date (default: today)")
29754
+ }, async ({ noteId, sections, into, date }) => {
29755
+ if (isStructural(b(), noteId))
29756
+ return err("protected_note", `Note ${noteId} is a structural note (container or singleton) and cannot be split.`, "split() is for content notes \u2014 pass a content note id, not a container.");
29757
+ const d = date ?? today();
29758
+ const note = await trilium.getNote(noteId);
29759
+ const kind = labelOf2(note, "noteType") ?? "information";
29760
+ const current = await trilium.getNoteContent(noteId).catch(() => "");
29761
+ const result = extractSections(current, sections);
29762
+ if (!result.matched.length)
29763
+ return err("no_sections_matched", `None of the requested sections exist in this note.`, `Available headings: ${headingOutline(current).map((h) => `"${h.text}"`).join(", ") || "(none)"}. Pass the exact heading text from outline(noteId).`);
29764
+ const { title: cleanTitle } = normalizeTitle(into);
29765
+ if (!cleanTitle)
29766
+ return err("missing_param", "into= produced no usable title.", 'Give the new note a real title, e.g. into="Code Priority Queue \u2014 resolved items".');
29767
+ const parentId = note.parentNoteIds[0];
29768
+ if (!parentId)
29769
+ return err("no_parent", "The source note has no parent \u2014 cannot place the split target.", "A content note should have exactly one parent.");
29770
+ const created = await trilium.createNote(parentId, cleanTitle, result.extracted, "text");
29771
+ const nid = created.note.noteId;
29772
+ for (const l of labelPlan(kind, { domain: labelOf2(note, "domain"), topics: note.attributes.filter((a) => a.type === "label" && a.name === "topic").map((a) => a.value ?? "").filter(Boolean) }, d)) {
29773
+ if (l.name === "noteType" || l.name === "created")
29774
+ await trilium.addLabel(nid, l.name, l.value, l.inheritable ?? false);
29775
+ }
29776
+ if (labelOf2(note, "domain"))
29777
+ await trilium.addLabel(nid, "domain", labelOf2(note, "domain")).catch(() => null);
29778
+ for (const t of note.attributes.filter((a) => a.type === "label" && a.name === "topic")) {
29779
+ if (t.value)
29780
+ await trilium.addLabel(nid, "topic", t.value).catch(() => null);
29781
+ }
29782
+ const pointer = `<p><em>Split ${d} \u2014 moved ${result.matched.length} section(s) into <strong>${escapeHtml(cleanTitle)}</strong> [${nid}].</em></p>`;
29783
+ await trilium.createRevision(noteId).catch(() => null);
29784
+ await trilium.updateNoteContent(noteId, `${pointer}
29785
+ ${result.html}`);
29786
+ await trilium.updateLabelValue(noteId, "updated", d);
29787
+ await trilium.addRelation(noteId, "references", nid).catch(() => null);
29788
+ const remaining = await trilium.getNoteContent(noteId).catch(() => "");
29789
+ const remainingNote = await trilium.getNote(noteId).catch(() => null);
29790
+ return txt3({
29791
+ ok: true,
29792
+ action: "split",
29793
+ noteId,
29794
+ newNoteId: nid,
29795
+ newNoteTitle: cleanTitle,
29796
+ moved: result.matched,
29797
+ ...result.missed.length ? { missed: result.missed } : {},
29798
+ remainingSize: remaining.length,
29799
+ pointer: `Split ${d} \u2014 moved ${result.matched.length} section(s) into ${cleanTitle} [${nid}]`,
29800
+ ...remainingNote ? { relations: relationSnippet(remainingNote) } : {},
29801
+ 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.`
29802
+ });
29803
+ });
29649
29804
  server.tool("withdraw", `Withdraw an archived or resolved thread from the archive: removes the #archived flag,
29650
29805
  resets status to active, clears the closed date, and appends a dated "Withdrawn" addendum.
29651
29806
  Use when a resolved or dormant thread resurfaces as live work.`, {
@@ -29842,13 +29997,58 @@ Scans every in-scope note by default. Trilium's %= backend filter is a LOSSY pre
29842
29997
  reads a striptags'd copy of the content, so it drops notes this tool should examine \u2014 and on a
29843
29998
  contradiction sweep a falsely clean result is worse than a slow one. Pass fast=true to use it
29844
29999
  anyway when scope is wide and speed matters more than completeness.`, {
29845
- pattern: stringType().describe("Regex over note bodies. One capture group = the value that should agree across notes."),
30000
+ pattern: stringType().optional().describe("Regex over note bodies. One capture group = the value that should agree across notes. Omit when using subject= (prose mode)."),
30001
+ 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."),
30002
+ 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."),
29846
30003
  domain: stringType().optional().describe("Restrict to one knowledge domain"),
29847
30004
  kinds: arrayType(enumType(Kinds)).optional().describe("Restrict to these kinds"),
29848
30005
  includeArchived: booleanType().optional().describe("Include archived notes (default false)"),
29849
30006
  limit: numberType().optional().describe("Max notes to examine (default 60)"),
29850
30007
  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)")
29851
- }, async ({ pattern, domain, kinds, includeArchived, limit, fast }) => {
30008
+ }, async ({ pattern, subject, staleAfterDays, domain, kinds, includeArchived, limit, fast }) => {
30009
+ if (!pattern && !subject)
30010
+ 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.');
30011
+ if (subject && !pattern) {
30012
+ const max = limit ?? 60;
30013
+ const tokens = queryTokens(subject, 8);
30014
+ if (!tokens.length)
30015
+ return err("no_tokens", "The subject produced no significant content words.", 'A subject of all stop-words ("what was it") cannot find anything \u2014 name the thing you re-measured.');
30016
+ const clauses = ["#noteType"];
30017
+ if (domain)
30018
+ clauses.push(`#domain='${slugify(domain)}'`);
30019
+ const notes = await trilium.searchNotes(clauses.join(" AND "), { ancestorNoteId: b().root, limit: max, includeArchivedNotes: includeArchived ?? false }).then((r) => r.results).catch(() => []);
30020
+ const scoped = notes.filter((n) => {
30021
+ const kind = ownedLabel(n, "noteType");
30022
+ if (!kind)
30023
+ return false;
30024
+ return !kinds?.length || kinds.includes(kind);
30025
+ });
30026
+ const threshold = Math.max(1, Math.ceil(tokens.length / 2));
30027
+ const hits = [];
30028
+ for (const n of scoped) {
30029
+ const content = await trilium.getNoteContent(n.noteId).catch(() => "");
30030
+ if (!content)
30031
+ continue;
30032
+ const text = stripTagsWithMap(content).text.toLowerCase();
30033
+ const matchedTokens = tokens.filter((t) => text.includes(t));
30034
+ if (matchedTokens.length < threshold)
30035
+ continue;
30036
+ hits.push({ id: n.noteId, title: n.title, kind: ownedLabel(n, "noteType") ?? "", matchedTokens, snippet: toText(content, 200) });
30037
+ }
30038
+ return txt3({
30039
+ mode: "subject",
30040
+ subject,
30041
+ tokens,
30042
+ threshold: `${threshold} of ${tokens.length} token(s)`,
30043
+ notesExamined: scoped.length,
30044
+ notes: hits,
30045
+ total: hits.length,
30046
+ ...domain ? { domain: slugify(domain) } : {},
30047
+ note: hits.length ? `${hits.length} note(s) assert something about "${subject}". Open each to read the claim \u2014 or narrow with domain= / kinds=.` : `No note in scope matched ${threshold}+ of the subject's tokens. That is evidence about the phrasing, not the brain \u2014 this mode is deliberately phrase-agnostic, so if you expected a hit, the fact may not be recorded here at all.`
30048
+ });
30049
+ }
30050
+ if (!pattern)
30051
+ return err("missing_param", "No regex pattern given for consistency.", 'Pass pattern="(\\\\d+) users" \u2014 or subject="<a fact in prose>" for the prose-subject mode.');
29852
30052
  let re;
29853
30053
  try {
29854
30054
  re = new RegExp(pattern, "gi");
@@ -29885,11 +30085,12 @@ anyway when scope is wide and speed matters more than completeness.`, {
29885
30085
  return { values, matched };
29886
30086
  };
29887
30087
  let tagSpanning = 0;
30088
+ const idleSince = (iso) => Math.max(0, Math.floor((Date.now() - new Date(iso.replace(" ", "T")).getTime()) / 86400000));
29888
30089
  for (const n of scoped) {
29889
30090
  const content = await trilium.getNoteContent(n.noteId).catch(() => "");
29890
30091
  if (!content)
29891
30092
  continue;
29892
- const stub = { id: n.noteId, title: n.title, kind: ownedLabel(n, "noteType") ?? "" };
30093
+ const stub = { id: n.noteId, title: n.title, kind: ownedLabel(n, "noteType") ?? "", idleDays: idleSince(n.dateModified) };
29893
30094
  const raw = scan(content);
29894
30095
  const projected = scan(stripTagsWithMap(content).text);
29895
30096
  if (projected.matched && !raw.matched)
@@ -29919,6 +30120,7 @@ anyway when scope is wide and speed matters more than completeness.`, {
29919
30120
  });
29920
30121
  }
29921
30122
  const groups = [...byValue.entries()].map(([value, notes]) => ({ value, count: notes.length, notes })).sort((a, b) => b.count - a.count);
30123
+ const staleSingles = staleAfterDays ? groups.filter((g) => g.count === 1 && g.notes[0].idleDays >= staleAfterDays).map((g) => ({ value: g.value, note: { id: g.notes[0].id, title: g.notes[0].title, kind: g.notes[0].kind }, idleDays: g.notes[0].idleDays })) : [];
29922
30124
  const agrees = groups.length <= 1;
29923
30125
  return txt3({
29924
30126
  mode: "consistency",
@@ -29930,6 +30132,8 @@ anyway when scope is wide and speed matters more than completeness.`, {
29930
30132
  distinctValues: groups.length,
29931
30133
  agreement: groups.length === 0 ? "no-data" : agrees ? "unanimous" : "DISAGREEMENT",
29932
30134
  groups,
30135
+ ...staleAfterDays ? { staleAfterDays } : {},
30136
+ ...staleSingles.length ? { staleSingles, staleSinglesNote: `${staleSingles.length} figure(s) appear in exactly one note, untouched ${staleAfterDays}d+ \u2014 a single copy cannot disagree with itself, so re-measure these against the world rather than against other notes.` } : {},
29933
30137
  ...noCapture.length ? { matchedWithoutValue: noCapture } : {},
29934
30138
  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.`
29935
30139
  });
@@ -31103,6 +31307,7 @@ var TOOL_ANNOTATIONS = {
31103
31307
  brain: READ,
31104
31308
  recall: READ,
31105
31309
  domain: READ,
31310
+ read: READ,
31106
31311
  outline: READ,
31107
31312
  inspect: READ,
31108
31313
  template: READ,
@@ -31129,6 +31334,7 @@ var TOOL_ANNOTATIONS = {
31129
31334
  remember: WRITE,
31130
31335
  diary: APPEND,
31131
31336
  revise: WRITE,
31337
+ split: WRITE,
31132
31338
  close: APPEND,
31133
31339
  connect: WRITE,
31134
31340
  label: WRITE,
@@ -32296,7 +32502,7 @@ function createServer(origin = null) {
32296
32502
  const s = new McpServer({
32297
32503
  name: "BrainLLM",
32298
32504
  title: "BrainLLM",
32299
- version: "12.2.0",
32505
+ version: "12.3.0",
32300
32506
  icons: brandingIcons(origin)
32301
32507
  });
32302
32508
  registerTools(s, trilium, brainRef);
package/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "brainllm",
4
4
  "display_name": "BrainLLM",
5
- "version": "12.2.0",
5
+ "version": "12.3.0",
6
6
  "description": "Turns TriliumNext Notes into a persistent, graph-structured second brain for Claude and other LLM clients.",
7
7
  "long_description": "BrainLLM is an MCP server that organises memory into five purpose-built areas (Master, LLM, Memory, Knowledge, Insights). The model supplies content while the server owns placement, format, lifecycle, dates and backups. Includes a full raw ETAPI surface for advanced use. Requires a running TriliumNext instance and the Bun runtime.",
8
8
  "author": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainllm",
3
- "version": "12.2.0",
3
+ "version": "12.3.0",
4
4
  "mcpName": "io.github.miisodev/brainllm",
5
5
  "description": "MCP server that turns TriliumNext Notes into a persistent, graph-structured second brain for Claude and other LLM clients.",
6
6
  "author": "Kevin Miiso Novo <miisodev@gmail.com>",
package/server.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.miisodev/brainllm",
4
4
  "description": "Persistent graph-structured memory for LLMs in TriliumNext Notes — readable and editable by humans.",
5
- "version": "12.2.0",
5
+ "version": "12.3.0",
6
6
  "websiteUrl": "https://github.com/miisodev/BrainLLM#readme",
7
7
  "repository": {
8
8
  "url": "https://github.com/miisodev/BrainLLM",
@@ -12,7 +12,7 @@
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "brainllm",
15
- "version": "12.2.0",
15
+ "version": "12.3.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -39,7 +39,7 @@
39
39
  },
40
40
  {
41
41
  "name": "BRAINLLM_MODE",
42
- "description": "core (default) exposes the 39 brain-aware tools; full additionally exposes the 33 raw ETAPI tools.",
42
+ "description": "core (default) exposes the 44 brain-aware tools; full additionally exposes the 33 raw ETAPI tools.",
43
43
  "isRequired": false,
44
44
  "format": "string",
45
45
  "default": "core"
@@ -30,9 +30,11 @@ DURING remember(...) ← the moment something worth keeping a
30
30
  <surface> / _recall ← read a surface in full, or skim it (section= reads ONE section — the read twin of revise(section=))
31
31
  domain(name) ← surface all content for a domain/topic/project — the RELIABLE retrieval path; reach here first
32
32
  recall(...) ← ranked search; pass domain= whenever you know the area
33
- consistency(pattern) ← does the brain agree with itself? run after correcting a fact recorded in >1 note
33
+ consistency(pattern|subject) ← does the brain agree with itself? pattern= to compare a captured value; subject= to find notes asserting about a fact however phrased; staleAfterDays= to surface single-copy figures that rot silently. Run after correcting a fact recorded in >1 note
34
+ read(ids=[…]) ← batched multi-note read — several bodies, one round trip
34
35
  claim(...) ← does the brain still agree with the WORLD? register a checkable assertion; verify on a schedule
35
36
  revise(...) ← edit a note (section= for whole sections, find=/edits= for surgery, mode=before/after to insert, mode=remove to delete)
37
+ split(noteId, sections, into) ← the trim affordance: lift whole sections into a new note, leaving a pointer — the write half of the oversized-note problem
36
38
  outline(noteId) ← the heading tree + structural check — read BEFORE a section= edit you're guessing at
37
39
  diff(noteId) ← what your last write actually changed, against the revision it snapshotted
38
40
  resolve(...) ← close a thread with its outcome
@@ -155,6 +157,8 @@ Each surface has two read tools: `<surface>` reads in full, `<surface>_recall` s
155
157
 
156
158
  `recall(query, kinds?, domain?, includeArchived?, regex?, fuzzy?)` searches the **whole** brain — use it when you don't know the surface, or for cross-surface lookups. A fuzzy pass runs automatically when the exact strategies come back thin; those hits are marked `fuzzy: true` and are leads to verify, not answers. `regex=` matches a real regular expression against stored bodies — the tool for structural questions keyword search cannot express.
157
159
 
160
+ **`read(ids=[…])` is the batched read — several bodies, one round trip.** The orientation step is a fan-out (a dozen notes, several huge) and N separate calls is N chances to time out mid-read; `read()` collapses it into one call, up to 10 ids, returning each body verbatim regardless of kind. Reach for it when you need whole bodies across kinds; prefer the kinded `knowledge()`/`memory()`/`llm()` for a single note, and a `section=` read for one huge note — `read()` returns whole bodies.
161
+
158
162
  `assembly(area?)` answers **"what do I already know here"** — every note by title, grouped under the surface it lives in, with each surface's own purpose alongside it. This is the orientation read: reach for it *before* deciding whether you need to look something up, not after a search comes back thin. Dated collections (sessions, diary, logs) collapse to a count and a span, because listing sixty `[yyyy-mm-dd]` titles is the bulk of the brain and none of its meaning; domains nest under their book; threads group by status. `area=` zooms into one surface.
159
163
 
160
164
  **`assembly()` and `brain()` answer different questions and are not interchangeable.** Assembly is *awareness* — titles and shape, sized to be read. Brain is *inventory* — ids, kinds, statuses, parents, dates, for auditing or locating one note. Reaching for the inventory when the question was awareness is the common mistake, and it costs several times the tokens to answer worse.
@@ -243,6 +247,8 @@ A revision snapshot is always taken first. Containers are refused; the maintaine
243
247
 
244
248
  **Merge, don't stack — the mechanics.** Master, LLM singletons, and Knowledge notes (including every per-domain Sources note and information note) are clean structured documents, so `revise()`'s *default* append mode is the wrong one for them: reach for `section=` or `mode=replace` to absorb the content into the body, and `find=`/`edits=` for anything smaller than a section. Default append is correct only on sessions, diary entries, logs, and thread day-children. The rule and its rationale are stated in full under Writing.
245
249
 
250
+ **When a note is past the read ceiling, the right move is `split()`, not more surgery.** A note too big to return whole and flagged by `maintain()`'s oversized check has usually grown two or three subjects under one title — that is a structural problem surgery can't fix, only a split can. `split(noteId, sections=[…], into="<title>")` lifts whole sections (heading + body, nested sub-sections with their parent) into a new note under the same parent, typed and labelled like the source, and leaves a pointer plus a `~references` edge in the source. Splitting on a semantic seam (a live queue from a do-not-re-raise index, one sub-category from another) turns two months of brittle `find=` surgery into one call. Outline the note first to name the seams; sections that don't match come back in `missed=` and stay put.
251
+
246
252
  ---
247
253
 
248
254
  ## Completing — `resolve`
@@ -271,7 +277,9 @@ Pick the most specific verb that's true; `relatesTo` is the last resort. `worksW
271
277
 
272
278
  A brain can be wrong in two directions, and they need different tools. **`consistency()` asks whether the brain agrees with itself. `claim()` asks whether it still agrees with the world.** Nothing else in the surface answers either question: `recall()` ranks by relevance and `maintain()` checks structure, so both failures sit in notes that read as authoritative.
273
279
 
274
- **`consistency(pattern, domain?, kinds?)` — the same fact, recorded differently in several places.** Give a regex with one capture group naming the value that should agree — `consistency("([0-9]+) Titan mailboxes")` — and it returns every note asserting a value, grouped, with `agreement: "unanimous"` or `"DISAGREEMENT"`. **Run it after correcting any fact that could be recorded in more than one place**, because revising one note leaves its siblings silently wrong and every copy still looks authoritative. It matches both the stored HTML and a tag-stripped projection, so a phrase broken by an inline `<strong>` is found; it scans every in-scope note by default, because on a contradiction sweep a falsely clean result is worse than a slow one.
280
+ **`consistency(pattern | subject, domain?, kinds?, staleAfterDays?)` — the same fact, recorded differently in several places.** Give a regex with one capture group naming the value that should agree — `consistency("([0-9]+) Titan mailboxes")` — and it returns every note asserting a value, grouped, with `agreement: "unanimous"` or `"DISAGREEMENT"`. **Run it after correcting any fact that could be recorded in more than one place**, because revising one note leaves its siblings silently wrong and every copy still looks authoritative. It matches both the stored HTML and a tag-stripped projection, so a phrase broken by an inline `<strong>` is found; it scans every in-scope note by default, because on a contradiction sweep a falsely clean result is worse than a slow one.
281
+
282
+ **Two additions make it the tool to reach for when you re-measure a fact.** `subject="<prose>"` is the no-regex-guessing mode: give it the fact in plain words and it returns every note asserting about that subject, however phrased — tokenized and matched on significant content words, so the word-order a hand-built pattern anchored on no longer hides the answer. `staleAfterDays=N` reports the **single-copy** figures: values asserted in exactly one note whose sole note was untouched N+ days. Those are the ones that rot silently — a single copy can only agree with itself, so `consistency()`'s verdict of "unanimous" is no evidence of currentness; the `staleSingles` list is where re-measurement is owed.
275
283
 
276
284
  **`claim()` — an assertion that was true when written and may not be now.** Register one with `assertion=` and `check=`; record an outcome with `claimId=`, `holds=` and `evidence=`; read one with `claimId=` alone; list with no arguments at all. `maintain(deep=true)` then surfaces the lapsed, the never-verified and the broken, so "is the brain stale" becomes a query instead of something caught by luck.
277
285
 
@@ -323,20 +331,22 @@ Most of these are computed **server-side** from note properties rather than by r
323
331
  | `day(date?, recap?)` | The new-day sweep payload in one call: previous session in full + that day's log + notes touched since + the current month's deliverables note in full, plus the sweep protocol as `next[]`. Call when start() flags `newDay`. `recap=true` instead returns every addendum written TODAY across sessions, diary and thread children, chronologically and attributed — for arriving mid-day after autonomous runs. |
324
332
  | `session(date?, full?, scope?)` | Mandatory pre-close step. Fetches master and LLM singletons as `{id, lastModified, relations}` stubs (light by default — start() already served them in full; fetch via master()/llm() only what you'll revise), today's diary as `{id, blocks, size}`, and runs the lite maintenance sweep. `full=true` inlines singleton content and the diary body (rarely needed). `scope="agent"` omits the user's-singleton steps for a scoped/autonomous run. Returns `next[]` driving the full end-of-session protocol, ending in `remarks()` → `diary()` → `close()`. Call before `close()`; idempotent. |
325
333
  | `remarks()` | Cue-only diary prompt, called before `diary()` — returns two cue banks (experience: what the session was like / your opinions / your existence here; brainllm: capability walls, bugs, usability & efficiency, roadmap) and satisfies its gate step on the call. Writes nothing; answer as first-person prose in today's diary via `diary()`. |
326
- | `close(summary, title?, identity, learned?, date?, backup?, continuing?, force?)` | Commit the session log ([yyyy-mm-dd] note; identity= REQUIRED — the canonical h3 identification line; title param above Summary) + backup + daily log (regenerated in place, plus a deletion catch-up that regenerates past days' logs when the change feed shows deletions ≤7 days old — a deletion landing after that day's close is no longer invisible) + idempotent session↔log wiring. **Refuses unless `session()`, `addendum()`, `maintain()`, `remarks()`, and `diary()` each ran this session AND `session() → remarks() → diary()` held (last calls)** — returns `{error, detail, hint}` naming what's missing or out of order; `force=true` bypasses the gate (reported back as `bypassed`) but never the identity requirement. `continuing=true` is the SECOND close of a day: skips the ceremonial re-run, and is verified against today's note so it can't stand in for a first close. On success returns `{action, noteId, date, backup, log}` (plus `deletionCatchUp` when one ran) and resets the gate. Once, last. |
334
+ | `close(summary, title?, identity, learned?, date?, backup?, continuing?, force?)` | Commit the session log ([yyyy-mm-dd] note; identity= REQUIRED — the canonical h3 identification line; title param above Summary) + backup + daily log (regenerated in place, plus a deletion catch-up that regenerates past days' logs when the change feed shows deletions inside the configurable window — `deletionCatchupDays`, default 7, Trilium's eraser retention — so a deletion landing after that day's close is no longer invisible) + idempotent session↔log wiring. **Refuses unless `session()`, `addendum()`, `maintain()`, `remarks()`, and `diary()` each ran this session AND `session() → remarks() → diary()` held (last calls)** — returns `{error, detail, hint}` naming what's missing or out of order; `force=true` bypasses the gate (reported back as `bypassed`) but never the identity requirement. `continuing=true` is the SECOND close of a day: skips the ceremonial re-run, and is verified against today's note so it can't stand in for a first close. On success returns `{action, noteId, date, backup, log}` (plus `deletionCatchUp` when one ran) and resets the gate. Once, last. |
327
335
  | `assembly(area?, includeArchived?)` | **What the brain HOLDS**, shaped for awareness rather than audit: every note by title grouped under its surface, with each surface's engraved purpose alongside. Dated collections (sessions, diary, logs) collapse to a count and a date span — listing sixty `[yyyy-mm-dd]` titles is the bulk of the brain and none of its meaning. Domains nest under their book; threads group by status. `area=` zooms into one. Read this before deciding whether you need to look something up. |
328
336
  | `brain(includeArchived?)` | Full content tree: every typed note across all five areas, grouped, with id/kind/status/parent/dates. The **inventory** — reach for it to audit or locate, and for `assembly()` when the question is what you know. |
329
337
  | `bootstrap()` | Initialize the structure if uninitialized, or verify and refresh config if it already exists. Only creates a new tree when the stored root note is confirmed deleted in Trilium (404). Any other error (network, auth, timeout) is surfaced rather than silently creating a duplicate tree. |
330
- | `remember(kind, …)` | Write a note — routed, formatted, deduped server-side. New threads REQUIRE `goal=` (queried from the user); thread appends REQUIRE `identity=`; thread bodies must not carry a Resolution. `connect=[{relation, toNoteId}]` wires relations in the same call; a new connectable note without them returns an orphan-prevention hint. Sources/information receipts include `domainId`. `icon=` sets the display icon. Rejects diary/session/log/domain — each has a dedicated path. |
338
+ | `remember(kind, …)` | Write a note — routed, formatted, deduped server-side. New threads REQUIRE `goal=` (queried from the user); thread appends REQUIRE `identity=`; thread bodies must not carry a Resolution. `connect=[{relation, toNoteId}]` wires relations in the same call; a new connectable note without them returns an orphan-prevention hint. Sources/information receipts include `domainId`. `icon=` sets the display icon. Rejects diary/session/log/domain — each has a dedicated path. `mandate=true` (kind=information) marks a note as a standing brief — adds a `#mandate` flag surfaced in domain() and knowledge_recall, so a scoped autonomous session can find "the thing I must obey" without reading every note's prose to tell it apart from current-state fact. |
331
339
  | `diary(body, identity, icon?, date?)` | Write to today's [yyyy-mm-dd] diary — every write lands as a timestamped `Addendum — HH:mm` block, including the first of the day; identity= ENFORCED (the canonical h3 identification line). The FINAL gate step — close() counts it only when its last call came after session() and remarks(); mid-session writes welcome. |
332
340
  | `template(kind)` | The canonical structure for a content kind: skeleton + top-to-bottom structure + the rules writes are held to. Read before the first write of a kind — then read an existing sibling for the conventions the skeleton can't encode (heading depth, table shape, title style). Template for the schema, sibling for the shape. |
333
341
  | `graph(noteId?, depth?, includeArchived?)` | The graph view — Mermaid flowchart of the relation graph (whole brain, or a note's neighborhood), returned AND upserted into the maintained Insights/Graph mermaid note. On-demand only — reflects the brain as of the call, not auto-refreshed after later writes; a scoped call replaces the note's content, it doesn't merge. |
334
342
  | `recall(query, …)` | BrainLLM-wide ranked search. **Scope it — pass `domain=` whenever you know the area.** Title matches score per-token with an exact-title bonus, but an unscoped query across a large brain still ranks full-text noise alongside the note you want. `orderBy` / `orderDirection` for temporal ordering; `fastSearch` for title/label-only; `regex=` for a body pattern instead of keywords; `fuzzy=` to force the typo-tolerant pass on or off. A thin or odd result is evidence about the **query**, not the brain. |
335
- | `consistency(pattern, domain?, kinds?)` | **Does the brain agree with itself?** Give a regex with one capture group naming the value that should match across notes — `consistency("(\\d+) Titan mailboxes")` — and it returns every note asserting a value, grouped, with `agreement: "unanimous"` or `"DISAGREEMENT"`. This is the check nothing else performs: `recall()` ranks by relevance and `maintain()` checks structure, so a correction applied to one note leaves its siblings silently wrong. **Run it after correcting any fact that could be recorded in more than one place.** Matched against **both** the stored HTML and a tag-stripped projection, so a phrase split by an inline `<strong>` or `<code>` is found and so is a pattern anchored on tags. Scans every in-scope note by default — Trilium's `%=` pre-filter reads a striptags'd copy and silently drops notes, and on a contradiction sweep a falsely clean result is worse than a slow one; `fast=true` opts back into it. |
343
+ | `read(ids=[…])` | **Batched multi-note read — several bodies, one round trip.** The orientation step is structurally a fan-out (a dozen notes, some huge) and N separate surface calls is N chances to time out mid-read; this collapses it into one. Up to 10 ids; returns `{count, notes:[{id, title, kind, content, relations?}]}`. Pure read, no kind restrictions. For a single note prefer the kinded read (knowledge()/memory()/llm()); for one huge note a sectioned read is still cheaper — read() returns whole bodies. |
344
+ | `consistency(pattern \| subject, domain?, kinds?, staleAfterDays?)` | **Does the brain agree with itself?** Two modes. *Pattern:* give a regex with one capture group naming the value that should match across notes — `consistency("(\\d+) Titan mailboxes")` — and it returns every note asserting a value, grouped, with `agreement: "unanimous"` or `"DISAGREEMENT"`. *Subject:* give a fact in prose — `consistency(subject="the two scheduled agents")` — and get back every note asserting about it, however phrased, no regex guessing; the tool tokenizes the prose and matches a majority of its significant words, so the phrasing that defeated a hand-built pattern is found. `staleAfterDays=N` adds the **single-copy** report: figures asserted in exactly one note whose sole note was untouched N+ days — the ones that rot silently, because a single copy can only agree with itself. This is the check nothing else performs: `recall()` ranks by relevance and `maintain()` checks structure, so a correction applied to one note leaves its siblings silently wrong. **Run it after correcting any fact that could be recorded in more than one place.** Matched against **both** the stored HTML and a tag-stripped projection, so a phrase split by an inline `<strong>` or `<code>` is found and so is a pattern anchored on tags. Scans every in-scope note by default — Trilium's `%=` pre-filter reads a striptags'd copy and silently drops notes, and on a contradiction sweep a falsely clean result is worse than a slow one; `fast=true` opts back into it. |
336
345
  | `claim(...)` | **Does the brain still agree with the WORLD?** `consistency()` compares notes to each other; nothing asked whether an assertion is still true of the codebase, config or live surface it describes — so a claim that quietly stopped being true stayed authoritative until something downstream broke. Register one with `assertion=` + `check=`, record an outcome with `claimId=` + `holds=` + `evidence=`, read one with `claimId=` alone, list with no arguments. **BrainLLM never executes the check** — it has no shell, and note content is data rather than instructions; you run it and report back. `maintain(deep=true)` surfaces lapsed, unverified and broken claims. |
337
346
  | `diff(noteId, revisionId?)` | What a write actually changed: the revision snapshot against current content, defaulting to the most recent. Every content write already took a revision and nothing could read one back, so verifying surgical edits meant re-reading the whole note or trusting the receipts. Returns the revision index on every call. |
338
347
  | `<surface>` / `<surface>_recall` | Read a surface in full (`section=` for one section) / skim it (master, llm, memory, knowledge, insights). |
339
348
  | `revise(noteId, …)` | Append / replace / section-edit / insert / find-replace a note. Thread appends REQUIRE `identity=`. Section: h2/h3/h4 tolerant match, `occurrence=` to disambiguate, `mode="before"/"after"` to insert a sibling block around the whole section (section-relative, so "after" lands *past* that section's body, never between the heading and it), `mode="prepend"` to insert at the top of the section body below its heading; returns `matched`/`headingCount`/`available[]` — check them. `find=` + `body=`: exact-string surgery with an attribute- and whitespace-tolerant fallback (`matchMode` in the receipt), `nth=` for one occurrence; `edits=[{find, body}]` for several in one read/write. Misses return a cause-specific hint. `title=` composes with every mode. `strict=true` refuses a section miss AND a body needing structural repair. Retitling a domain book cascades `#domain` to its children. `Last updated` lines bumped server-side. Snapshot taken on content writes (not metadata-only). |
349
+ | `split(noteId, sections=[…], into="<title>")` | **The trim affordance — the write half of the oversized-note problem.** Lifts whole sections (heading + body, nested sub-sections go with their parent) out of one note into a new note created under the same parent, typed and labelled like the source, and leaves a pointer in the source plus a `~references` edge. A note past the read ceiling becomes two readable notes instead of one unusable one. Sections are matched by heading TEXT (the same contract `section=` uses); repeated headings are consumed first-match-per-request; sections that don't exist come back in `missed=` and stay in place. A revision is taken of the source first. Refused on containers and singletons — for content notes. |
340
350
  | `outline(noteId)` | The heading tree without the body: every h2–h4 with level, occurrence index, and — for headings carrying inline markup — `raw`, their stored form (`section=` matches `text`, `find=` matches `raw`). On notes 15k+ each heading also carries a first-block `preview` of its section — the orientation read for oversized notes. Plus the note's section level, any table's key column, size, and structural findings (duplicate headings, unbalanced tags). Read it before a `section=` edit you're guessing at, and after a run of surgical edits to confirm the note is still sound. |
341
351
  | `resolve(noteId, outcome, …)` | Close a thread: outcome + terminal status + archive-in-place. |
342
352
  | `withdraw(noteId, reason?, …)` | Pull an archived/resolved thread back to active (thread kind only — use recover() for other note kinds). |
@@ -58,3 +58,9 @@ A domain's Knowledge surface is exactly: **one maintained Sources note, plus a s
58
58
  Chronological, run-by-run history belongs in Memory/Threads — a thread is the right place for "here's what Run N found"; a domain information note is the right place for "here's what's actually true about this sub-category," kept current by revision, not accumulation. If you find yourself naming a new information note after today's date or a run number, stop — that finding either updates an existing note (revise it) or doesn't belong in Domains at all.
59
59
 
60
60
  The title rule is the same here as everywhere: **four words maximum**, no dates, no run numbers. Sub-category titles are where it's most often stretched, and a title that won't fit is telling you something — a sub-category needing eight words to name is usually two sub-categories, so split the content rather than the title. Merge behaviour follows from the same place: Sources and information notes are maintained current-state documents, so new findings fold into the relevant section (`section=`, `find=`) and never arrive as a dated addendum block.
61
+
62
+ ## Standing briefs vs current-state facts — the mandate marker
63
+
64
+ A domain holds two things that read alike and behave differently: a **standing brief** (instructions a future session must follow — the operating mandate of a venture) and a **current-state fact** (what's true right now). Both are `kind="information"`, and until now telling them apart meant reading each note's prose. A scoped autonomous session that needs "the thing I must obey" should not have to skim every sub-category note to find it.
65
+
66
+ Mark a brief with `remember(kind="information", domain=…, title=…, body=…, mandate=true)` — it adds a `#mandate` flag, surfaced as `mandate: true` in `domain()` group rows and in `knowledge_recall` stubs. A session looking for its brief reads the domain and the mandate-flagged note is the one carrying instructions rather than facts. `label(noteId, "mandate")` / `label(noteId, "mandate", remove=true)` sets or clears the marker on an existing note. One note per mandate, titled by the brief it is ("Founder Brief", "Operating Mandate") — the four-word rule still applies.
@@ -44,6 +44,7 @@ Written by the server — you never set `#noteType`, `#status`, `#created`, `#up
44
44
  | `#topic` | slugged, repeatable | Subject tags (`ai-tooling`, `infra`) — capitalization normalized server-side |
45
45
  | `#domain` | slugged | Knowledge domain — book auto-created on first use |
46
46
  | `#archived` | (flag) | Excludes the note from default `recall()`; content preserved in place |
47
+ | `#mandate` | (flag) | Information notes only — marks a **standing brief** (instructions a future session must follow) as distinct from current-state fact. Set via `remember(…, mandate=true)` or `label(id, "mandate")`; surfaced in `domain()` and `knowledge_recall`. See `references/domains.md` |
47
48
  | `#reviewed` | content blob id | Set by `maintain(ack=[…])` — this note's current content was reviewed and its maintenance findings accepted. Suppresses them until the body actually changes, then all of them return |
48
49
  | `#brainLlmRoot` | (flag) | Marks the brain root — used by auto-discovery |
49
50
  | `#iconClass` | `bx …` | Display icon — set via `icon=` on `remember`/`revise`/`diary`/`close` (class or bare name, normalized) |