brainllm 12.5.0 → 12.6.1

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
@@ -25,6 +25,13 @@ TRILIUM_PASSWORD=
25
25
  # Leave unset to use the host clock — correct when BrainLLM runs on your machine.
26
26
  # BRAINLLM_TZ=
27
27
 
28
+ # ── Deleted-note retention ────────────────────────────────────────────────────
29
+ # Days Trilium keeps a deleted note before erasing it (Options → Other → "Erase
30
+ # notes after"). BrainLLM reports deletions only within this window, and ETAPI
31
+ # cannot read the option, so set this when your instance differs from Trilium's
32
+ # default of 7. A whole number from 1 to 3650; overrides brainllm.json's policy.
33
+ # BRAINLLM_DELETION_CATCHUP_DAYS=7
34
+
28
35
  # ── HTTP connector mode (Railway / remote) ────────────────────────────────────
29
36
  # When PORT is set the server starts an HTTP/SSE connector instead of stdio.
30
37
  # Railway sets PORT automatically; do NOT set it for local Claude Code usage.
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.5.0-f59e0b?style=flat-square)](https://github.com/miisodev/BrainLLM/releases)
13
+ [![Version](https://img.shields.io/badge/version-12.6.1-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)
@@ -296,6 +296,7 @@ Without a volume, leave `BRAINLLM_CONFIG` unset — auto-discovery re-finds the
296
296
  | `TRILIUM_PASSWORD` | ◐ | Alternative to the token: BrainLLM mints one on first start and caches it beside `brainllm.json`, reusing it across restarts. Also used by `bun run init` |
297
297
  | `BRAINLLM_MODE` | — | `core` (default) or `full` (adds the raw ETAPI surface) |
298
298
  | `BRAINLLM_TZ` | — | IANA timezone (e.g. `Africa/Johannesburg`) so dates stamp in *your* day on hosted deploys; unset = host clock |
299
+ | `BRAINLLM_DELETION_CATCHUP_DAYS` | — | How many days your Trilium keeps deleted notes (Options → Other → "Erase notes after"). Deletions are reported only within this window, and ETAPI cannot read the option. Default `7`, Trilium's own default; overrides `policy.deletionCatchupDays` |
299
300
  | `PORT` | — | Presence switches to HTTP-connector mode |
300
301
  | `MCP_AUTH_TOKEN` | — | Static bearer token accepted on `/mcp` — what Claude Code and `mcp-remote` send |
301
302
  | `BRAINLLM_OWNER_PASSWORD` | — | Enables the OAuth 2.1 / CIMD endpoints, required for claude.ai. The password you type on the consent screen |
@@ -322,6 +323,7 @@ A handful of values in this repo reflect the author's own machine. None are secr
322
323
  | What | Where | Make it yours |
323
324
  |---|---|---|
324
325
  | **Timezone** | `BRAINLLM_TZ` in `.env` | Your IANA zone — or unset for the host clock |
326
+ | **Deleted-note retention** | `BRAINLLM_DELETION_CATCHUP_DAYS` in `.env` | Only if your Trilium erases deleted notes after something other than 7 days |
325
327
  | **Config path** | `BRAINLLM_CONFIG` env var | Only needed on persistent-volume deploys (see above) |
326
328
  | **Bundle path** | your MCP client config | The real absolute path to `dist/index.js` on your machine |
327
329
  | **Author · repo · funding** | `package.json`, `.github/FUNDING.yml`, the badges above | Your own details if you fork; the funding links support the original author |
package/SECURITY.md CHANGED
@@ -6,8 +6,8 @@ Security fixes ship on the latest release line. Older versions may remain useful
6
6
 
7
7
  | Version | Supported |
8
8
  |---|---|
9
- | 12.4.x | ✅ |
10
- | 12.3.x and earlier | ❌ |
9
+ | 12.6.x | ✅ |
10
+ | 12.5.x and earlier | ❌ |
11
11
 
12
12
  ## Reporting a vulnerability
13
13
 
package/dist/index.js CHANGED
@@ -21254,6 +21254,19 @@ function saveCachedToken(token) {
21254
21254
  return null;
21255
21255
  }
21256
21256
  }
21257
+ var MAX_CATCHUP_DAYS = 3650;
21258
+ function envDeletionCatchupDays(env = process.env) {
21259
+ const raw = env.BRAINLLM_DELETION_CATCHUP_DAYS?.trim();
21260
+ if (!raw)
21261
+ return {};
21262
+ const days = Number(raw);
21263
+ if (!/^\d+$/.test(raw) || days < 1 || days > MAX_CATCHUP_DAYS)
21264
+ return { invalid: raw };
21265
+ return { days };
21266
+ }
21267
+ function deletionCatchupDays(cfg, env = process.env) {
21268
+ return envDeletionCatchupDays(env).days ?? cfg.policy?.deletionCatchupDays ?? DEFAULT_POLICY.deletionCatchupDays;
21269
+ }
21257
21270
  function loadConfig() {
21258
21271
  const path = configFilePath();
21259
21272
  if (!existsSync(path))
@@ -21425,13 +21438,141 @@ function normalizeIcon(raw) {
21425
21438
  const name = s.replace(/^bx[sl]?-/, "").replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
21426
21439
  return name ? `bx bx-${name}` : "";
21427
21440
  }
21428
- var HTML_TAG = /<\/?[a-z][a-z0-9-]*(\s[^<>]*)?>/i;
21441
+ var HTML_TAG = /<\/?([a-z][a-z0-9-]*)(\s[^<>]*)?>/gi;
21442
+ var HTML_ELEMENTS = new Set([
21443
+ "a",
21444
+ "abbr",
21445
+ "address",
21446
+ "area",
21447
+ "article",
21448
+ "aside",
21449
+ "audio",
21450
+ "b",
21451
+ "base",
21452
+ "bdi",
21453
+ "bdo",
21454
+ "blockquote",
21455
+ "body",
21456
+ "br",
21457
+ "button",
21458
+ "canvas",
21459
+ "caption",
21460
+ "center",
21461
+ "cite",
21462
+ "code",
21463
+ "col",
21464
+ "colgroup",
21465
+ "data",
21466
+ "datalist",
21467
+ "dd",
21468
+ "del",
21469
+ "details",
21470
+ "dfn",
21471
+ "dialog",
21472
+ "div",
21473
+ "dl",
21474
+ "dt",
21475
+ "em",
21476
+ "embed",
21477
+ "fieldset",
21478
+ "figcaption",
21479
+ "figure",
21480
+ "font",
21481
+ "footer",
21482
+ "form",
21483
+ "h1",
21484
+ "h2",
21485
+ "h3",
21486
+ "h4",
21487
+ "h5",
21488
+ "h6",
21489
+ "head",
21490
+ "header",
21491
+ "hgroup",
21492
+ "hr",
21493
+ "html",
21494
+ "i",
21495
+ "iframe",
21496
+ "img",
21497
+ "input",
21498
+ "ins",
21499
+ "kbd",
21500
+ "label",
21501
+ "legend",
21502
+ "li",
21503
+ "link",
21504
+ "main",
21505
+ "map",
21506
+ "mark",
21507
+ "math",
21508
+ "menu",
21509
+ "meta",
21510
+ "meter",
21511
+ "nav",
21512
+ "noscript",
21513
+ "object",
21514
+ "ol",
21515
+ "optgroup",
21516
+ "option",
21517
+ "output",
21518
+ "p",
21519
+ "param",
21520
+ "picture",
21521
+ "pre",
21522
+ "progress",
21523
+ "q",
21524
+ "rp",
21525
+ "rt",
21526
+ "ruby",
21527
+ "s",
21528
+ "samp",
21529
+ "script",
21530
+ "search",
21531
+ "section",
21532
+ "select",
21533
+ "slot",
21534
+ "small",
21535
+ "source",
21536
+ "span",
21537
+ "strike",
21538
+ "strong",
21539
+ "style",
21540
+ "sub",
21541
+ "summary",
21542
+ "sup",
21543
+ "svg",
21544
+ "table",
21545
+ "tbody",
21546
+ "td",
21547
+ "template",
21548
+ "textarea",
21549
+ "tfoot",
21550
+ "th",
21551
+ "thead",
21552
+ "time",
21553
+ "title",
21554
+ "tr",
21555
+ "track",
21556
+ "tt",
21557
+ "u",
21558
+ "ul",
21559
+ "var",
21560
+ "video",
21561
+ "wbr",
21562
+ "applet"
21563
+ ]);
21564
+ function isHtmlElement(name) {
21565
+ return HTML_ELEMENTS.has(name.toLowerCase());
21566
+ }
21429
21567
  function looksLikeHtml(body) {
21430
- return HTML_TAG.test(body);
21568
+ for (const m of body.matchAll(HTML_TAG))
21569
+ if (isHtmlElement(m[1]))
21570
+ return true;
21571
+ return false;
21431
21572
  }
21432
21573
  var ENCODED_TAG = /&lt;\/?[a-z][a-z0-9-]*(?:\s[^&<>]*?)?\s*\/?&gt;/i;
21433
21574
  function looksLikeEncodedHtml(body) {
21434
- return !HTML_TAG.test(body) && ENCODED_TAG.test(body);
21575
+ return !looksLikeHtml(body) && ENCODED_TAG.test(body);
21435
21576
  }
21436
21577
  function decodeEncodedHtml(body) {
21437
21578
  let s = body;
@@ -21492,15 +21633,102 @@ function nearestHeading(requested, available) {
21492
21633
  function inlineMd(escaped) {
21493
21634
  return escaped.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/(^|[\s(])\*([^*\s][^*]*)\*/g, "$1<em>$2</em>").replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2">$1</a>');
21494
21635
  }
21495
- function toHtml(body) {
21636
+ function renderMarkup(raw) {
21637
+ const body = raw.replace(/\u0000/g, "");
21496
21638
  if (!body.trim())
21497
- return "<p></p>";
21639
+ return { html: "<p></p>", convertedRuns: 0 };
21498
21640
  if (looksLikeHtml(body))
21499
- return body;
21641
+ return mixedToHtml(body);
21500
21642
  if (looksLikeEncodedHtml(body))
21501
- return decodeEncodedHtml(body);
21643
+ return { html: decodeEncodedHtml(body), convertedRuns: 0 };
21644
+ return { html: markdownToHtml(body), convertedRuns: 0 };
21645
+ }
21646
+ var BLOCK_ELEMENTS = new Set([
21647
+ "address",
21648
+ "article",
21649
+ "aside",
21650
+ "blockquote",
21651
+ "dd",
21652
+ "details",
21653
+ "div",
21654
+ "dl",
21655
+ "dt",
21656
+ "figcaption",
21657
+ "figure",
21658
+ "footer",
21659
+ "h1",
21660
+ "h2",
21661
+ "h3",
21662
+ "h4",
21663
+ "h5",
21664
+ "h6",
21665
+ "header",
21666
+ "hr",
21667
+ "li",
21668
+ "main",
21669
+ "nav",
21670
+ "ol",
21671
+ "p",
21672
+ "pre",
21673
+ "section",
21674
+ "summary",
21675
+ "table",
21676
+ "tbody",
21677
+ "td",
21678
+ "tfoot",
21679
+ "th",
21680
+ "thead",
21681
+ "tr",
21682
+ "ul"
21683
+ ]);
21684
+ var VOID_BLOCKS = new Set(["hr"]);
21685
+ var LEADING_BLOCK_TAG = /^\s*<([a-z][a-z0-9]*)(?=[\s>/])/i;
21686
+ var BLOCK_TAG = /<(\/?)([a-z][a-z0-9]*)(?=[\s>/])[^>]*?(\/?)>/gi;
21687
+ function mixedToHtml(body) {
21502
21688
  const lines = body.replace(/\r\n/g, `
21503
21689
  `).split(`
21690
+ `);
21691
+ const out = [];
21692
+ let run = [];
21693
+ let depth = 0;
21694
+ let convertedRuns = 0;
21695
+ const flushRun = () => {
21696
+ if (!run.length)
21697
+ return;
21698
+ const text = run.join(`
21699
+ `);
21700
+ if (text.trim()) {
21701
+ out.push(markdownToHtml(text, true));
21702
+ convertedRuns++;
21703
+ } else {
21704
+ out.push(text);
21705
+ }
21706
+ run = [];
21707
+ };
21708
+ for (const line of lines) {
21709
+ const lead = LEADING_BLOCK_TAG.exec(line)?.[1]?.toLowerCase();
21710
+ if (depth === 0 && !(lead && BLOCK_ELEMENTS.has(lead))) {
21711
+ run.push(line);
21712
+ continue;
21713
+ }
21714
+ flushRun();
21715
+ out.push(line);
21716
+ for (const [, close, name, selfClose] of line.matchAll(BLOCK_TAG)) {
21717
+ const tag = name.toLowerCase();
21718
+ if (!BLOCK_ELEMENTS.has(tag) || VOID_BLOCKS.has(tag) || selfClose)
21719
+ continue;
21720
+ depth = Math.max(0, depth + (close ? -1 : 1));
21721
+ }
21722
+ }
21723
+ flushRun();
21724
+ return { html: out.join(`
21725
+ `), convertedRuns };
21726
+ }
21727
+ function markdownToHtml(body, inlineHtml = false) {
21728
+ const kept = [];
21729
+ const source = inlineHtml ? body.replace(HTML_TAG, (tag, name) => isHtmlElement(name) ? `\x00${kept.push(tag) - 1}\x00` : tag) : body;
21730
+ const lines = source.replace(/\r\n/g, `
21731
+ `).split(`
21504
21732
  `);
21505
21733
  const out = [];
21506
21734
  let para = [];
@@ -21598,8 +21826,12 @@ function toHtml(body) {
21598
21826
  flushTable();
21599
21827
  flushPara();
21600
21828
  flushList();
21601
- return out.join(`
21829
+ const html = out.join(`
21602
21830
  `) || "<p></p>";
21831
+ if (!kept.length)
21832
+ return html;
21833
+ const restore = (token) => kept[Number(token)] ?? "";
21834
+ return html.replace(/(<pre><code>[\s\S]*?<\/code><\/pre>)|\u0000(\d+)\u0000/g, (_m, pre, i) => pre ? pre.replace(/\u0000(\d+)\u0000/g, (_x, j) => escapeHtml(restore(j), false)) : restore(i));
21603
21835
  }
21604
21836
  function toText(html, maxLength = 300) {
21605
21837
  const text = decodeEntities(html.replace(/<(style|script)[\s\S]*?<\/\1>/gi, " ").replace(/<(h[1-6]|p|li|div|tr|br)[^>]*>/gi, `
@@ -21800,17 +22032,27 @@ function safeHtmlUrl(value) {
21800
22032
  return null;
21801
22033
  return decoded;
21802
22034
  }
22035
+ function keptAsText(full, warnings) {
22036
+ warnings.push(`Kept ${full} as text \u2014 not an HTML element`);
22037
+ return escapeHtml(full, false);
22038
+ }
21803
22039
  function sanitizeTag(full, warnings) {
21804
22040
  if (full.startsWith("<!--"))
21805
22041
  return "";
21806
22042
  const close = /^<\s*\/\s*([a-z][a-z0-9-]*)\s*>$/i.exec(full);
21807
- if (close)
21808
- return SAFE_TAGS.has(close[1].toLowerCase()) ? `</${close[1].toLowerCase()}>` : "";
22043
+ if (close) {
22044
+ const name = close[1].toLowerCase();
22045
+ if (SAFE_TAGS.has(name))
22046
+ return `</${name}>`;
22047
+ return isHtmlElement(name) ? "" : keptAsText(full, warnings);
22048
+ }
21809
22049
  const open = /^<\s*([a-z][a-z0-9-]*)(\s[\s\S]*?)?\/?\s*>$/i.exec(full);
21810
22050
  if (!open)
21811
22051
  return "";
21812
22052
  const tag = open[1].toLowerCase();
21813
22053
  if (!SAFE_TAGS.has(tag)) {
22054
+ if (!isHtmlElement(tag))
22055
+ return keptAsText(full, warnings);
21814
22056
  warnings.push(`Stripped unsupported <${tag}> element`);
21815
22057
  return "";
21816
22058
  }
@@ -21927,16 +22169,16 @@ function sanitizeHtml(html) {
21927
22169
  }
21928
22170
  function renderBody(body) {
21929
22171
  const wasEncoded = looksLikeEncodedHtml(body);
21930
- const result = sanitizeHtml(toHtml(body));
21931
- if (!wasEncoded)
21932
- return result;
21933
- return {
21934
- html: result.html,
21935
- warnings: [
21936
- "Entity-encoded markup decoded to real HTML \u2014 pass tags as <p>\u2026</p>, not &lt;p&gt;\u2026&lt;/p&gt;; the escaped form would otherwise be stored as literal text",
21937
- ...result.warnings
21938
- ]
21939
- };
22172
+ const { html, convertedRuns } = renderMarkup(body);
22173
+ const result = sanitizeHtml(html);
22174
+ const notes = [];
22175
+ if (wasEncoded) {
22176
+ notes.push("Entity-encoded markup decoded to real HTML \u2014 pass tags as <p>\u2026</p>, not &lt;p&gt;\u2026&lt;/p&gt;; the escaped form would otherwise be stored as literal text");
22177
+ }
22178
+ if (convertedRuns) {
22179
+ notes.push(`Converted ${convertedRuns} text run(s) outside HTML blocks from markdown to HTML \u2014 send a body as all markdown or all HTML to control its shape exactly`);
22180
+ }
22181
+ return notes.length ? { html: result.html, warnings: [...notes, ...result.warnings] } : result;
21940
22182
  }
21941
22183
  function repairedStructure(warnings) {
21942
22184
  return warnings.filter((w) => w.startsWith("Closed unclosed block tag") || w.startsWith("<br> normalized to paragraph separators"));
@@ -22894,7 +23136,7 @@ async function sweep(trilium, cfg, opts = {}) {
22894
23136
  dormantAfterDays: policy.dormantAfterDays,
22895
23137
  archiveDormantAfterDays: policy.archiveDormantAfterDays,
22896
23138
  staleAfterDays: policy.staleAfterDays,
22897
- deletionCatchupDays: policy.deletionCatchupDays ?? 7
23139
+ deletionCatchupDays: deletionCatchupDays(cfg)
22898
23140
  }
22899
23141
  };
22900
23142
  if (!cfg.root)
@@ -23043,7 +23285,7 @@ async function sweep(trilium, cfg, opts = {}) {
23043
23285
  }
23044
23286
  if (deep && !domainSlug) {
23045
23287
  const feed = await trilium.getNoteHistory(cfg.root).catch(() => []);
23046
- const deletionsWindow = isoDaysAgo(policy.deletionCatchupDays ?? 7);
23288
+ const deletionsWindow = isoDaysAgo(deletionCatchupDays(cfg));
23047
23289
  const recent = feed.filter((h) => h.current_isDeleted && h.date.slice(0, 10) >= deletionsWindow);
23048
23290
  report.scanned += recent.length;
23049
23291
  for (const [i, h] of recent.entries()) {
@@ -23177,7 +23419,7 @@ async function sweep(trilium, cfg, opts = {}) {
23177
23419
  { id: cfg.knowledge.domains, kind: "domain", label: "Knowledge/Domains" }
23178
23420
  ];
23179
23421
  for (const { id, kind, label: containerLabel } of dupeContainers) {
23180
- if (!id)
23422
+ if (!id || domainSlug)
23181
23423
  continue;
23182
23424
  const all = await trilium.searchNotes(`#noteType=${kind}`, { ancestorNoteId: id, fastSearch: true, limit: 500, includeArchivedNotes: true }).catch(() => ({ results: [] }));
23183
23425
  report.scanned += all.results.length;
@@ -23197,7 +23439,8 @@ async function sweep(trilium, cfg, opts = {}) {
23197
23439
  }
23198
23440
  if (cfg.knowledge.domains) {
23199
23441
  for (const domainKind of ["information", "sources"]) {
23200
- const all = await trilium.searchNotes(`#noteType=${domainKind}`, { ancestorNoteId: cfg.knowledge.domains, fastSearch: true, limit: 500, includeArchivedNotes: true }).catch(() => ({ results: [] }));
23442
+ const found = await trilium.searchNotes(`#noteType=${domainKind}`, { ancestorNoteId: cfg.knowledge.domains, fastSearch: true, limit: 500, includeArchivedNotes: true }).catch(() => ({ results: [] }));
23443
+ const all = { results: found.results.filter(inScope) };
23201
23444
  report.scanned += all.results.length;
23202
23445
  const byDomainTitle = new Map;
23203
23446
  for (const n of all.results) {
@@ -23369,7 +23612,7 @@ async function hygienePasses(trilium, cfg, report, ctx) {
23369
23612
  for (const n of heavy) {
23370
23613
  if (!eligible(n) || ownedLabel(n, "threadShape") === "collection")
23371
23614
  continue;
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.`);
23615
+ report.flagged.push(`consolidate: ${n.title} [${n.noteId}] \u2014 ${n.childNoteIds.length} day-children. Move what the thread has established into its domain: timeless knowledge into the information notes, measured state into Current State. The book keeps only its goal and the entries stay as the record, so a successor reads the conclusion instead of walking the history.`);
23373
23616
  }
23374
23617
  }
23375
23618
  if (cfg.knowledge.domains) {
@@ -23613,9 +23856,8 @@ async function generateDailyLog(trilium, cfg, date) {
23613
23856
  await trilium.addLabel(logNoteId, "created", date);
23614
23857
  return { date, noteId: logNoteId, ...counts, action: "created" };
23615
23858
  }
23616
- var DELETION_CATCHUP_DAYS = 7;
23617
23859
  async function catchUpDeletions(trilium, cfg, today) {
23618
- const windowDays = cfg.policy.deletionCatchupDays ?? DELETION_CATCHUP_DAYS;
23860
+ const windowDays = deletionCatchupDays(cfg);
23619
23861
  const empty = { windowDays, coverage: "full", deletionsFound: 0, regenerated: [] };
23620
23862
  if (!cfg.root || !cfg.insights.logs)
23621
23863
  return empty;
@@ -23656,6 +23898,11 @@ async function catchUpDeletions(trilium, cfg, today) {
23656
23898
  var txt = (obj) => ({
23657
23899
  content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }]
23658
23900
  });
23901
+ var idParams = {
23902
+ id: stringType().optional().describe("The note's id (noteId is accepted as an alias)"),
23903
+ noteId: stringType().optional().describe("Alias of id")
23904
+ };
23905
+ var missingId = (tool) => txt({ error: "missing_id", detail: `${tool}() needs the note's id.`, hint: "Pass id=<noteId> (noteId= works too)." });
23659
23906
  var labelOf = (n, name) => n.attributes.find((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === name)?.value;
23660
23907
  async function skim(trilium, ancestorNoteId, opts = {}) {
23661
23908
  const { query, kind, limit = 20, includeArchived = false } = opts;
@@ -23771,14 +24018,16 @@ function registerLlmTools(server, trilium, brainRef) {
23771
24018
  const b = () => brainRef.config;
23772
24019
  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.`, {
23773
24020
  which: enumType(["responsibilities", "protocols", "selfcorrection", "diary"]),
23774
- id: stringType().optional().describe("diary only: the entry id from llm_recall"),
24021
+ id: stringType().optional().describe("diary only: the entry id from llm_recall (noteId is accepted as an alias)"),
24022
+ noteId: stringType().optional().describe("Alias of id"),
23775
24023
  section: stringType().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole note"),
23776
24024
  occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)")
23777
- }, async ({ which, id, section, occurrence }) => {
24025
+ }, async ({ which, id, noteId: idAlias, section, occurrence }) => {
23778
24026
  if (which === "diary") {
23779
- if (!id)
24027
+ const entry = id ?? idAlias;
24028
+ if (!entry)
23780
24029
  throw new Error("Reading a diary entry needs its id \u2014 use llm_recall to find one.");
23781
- return txt(await readFull(trilium, id, { section, occurrence }));
24030
+ return txt(await readFull(trilium, entry, { section, occurrence }));
23782
24031
  }
23783
24032
  const noteId = b().llm[which];
23784
24033
  if (!noteId) {
@@ -23804,18 +24053,25 @@ function registerLlmTools(server, trilium, brainRef) {
23804
24053
  }
23805
24054
 
23806
24055
  // src/tools-memory.ts
24056
+ var MEMORY_INDEX_DEFAULT = 30;
24057
+ var MEMORY_INDEX_CHAR_BUDGET = 24000;
23807
24058
  function registerMemoryTools(server, trilium, brainRef) {
23808
24059
  const b = () => brainRef.config;
23809
24060
  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
24061
+ Resolution plus an index of its [yyyy-mm-dd] children (newest first: the latest 30, fewer if their
24062
+ index passes ~24k characters, unless limit= says otherwise; with the total count), each listed by its addendum blocks;
24063
+ date="yyyy-mm-dd" resolves straight to one day. A collection thread returns its
23812
24064
  Context plus its titled entries (alphabetical, with a lead). Read a child with memory(<child id>).
23813
24065
  section="<heading>" reads one section.`, {
23814
- id: stringType(),
24066
+ ...idParams,
23815
24067
  date: stringType().optional().describe("Thread books only: resolve directly to this day's child note (yyyy-mm-dd)"),
24068
+ limit: numberType().int().positive().optional().describe("Dated threads: how many of the newest day-children to index (default 30)"),
23816
24069
  section: stringType().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole note"),
23817
24070
  occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)")
23818
- }, async ({ id, date, section, occurrence }) => {
24071
+ }, async ({ id: idArg, noteId, date, limit, section, occurrence }) => {
24072
+ const id = idArg ?? noteId;
24073
+ if (!id)
24074
+ return missingId("memory");
23819
24075
  const note = await trilium.getNote(id).catch(() => null);
23820
24076
  if (!note || labelOf(note, "noteType") !== "thread")
23821
24077
  return txt(await readFull(trilium, id, { section, occurrence }));
@@ -23836,14 +24092,16 @@ section="<heading>" reads one section.`, {
23836
24092
  return txt(await readFull(trilium, child.results[0].noteId, { section, occurrence }));
23837
24093
  return txt({ id, title: note.title, kind: "thread", note: `No entry for ${date}.` });
23838
24094
  }
24095
+ const shown = limit ?? MEMORY_INDEX_DEFAULT;
24096
+ const totalChildren = note.childNoteIds.length;
23839
24097
  const children = await trilium.searchNotes("#noteType=threadEntry", {
23840
24098
  ancestorNoteId: id,
23841
24099
  fastSearch: true,
23842
- limit: 200,
24100
+ limit: shown,
23843
24101
  orderBy: "dateCreated",
23844
24102
  orderDirection: "desc"
23845
24103
  }).catch(() => ({ results: [] }));
23846
- const entries = await Promise.all(children.results.map(async (c) => {
24104
+ const indexed = await Promise.all(children.results.map(async (c) => {
23847
24105
  const content = await trilium.getNoteContent(c.noteId).catch(() => "");
23848
24106
  const blocks = addendumIndex(content);
23849
24107
  return {
@@ -23852,8 +24110,22 @@ section="<heading>" reads one section.`, {
23852
24110
  ...blocks.length ? { blocks } : { preview: toText(content, 160) }
23853
24111
  };
23854
24112
  }));
24113
+ const entries = [];
24114
+ let used = 0;
24115
+ for (const e of indexed) {
24116
+ const size = JSON.stringify(e).length;
24117
+ if (limit === undefined && entries.length && used + size > MEMORY_INDEX_CHAR_BUDGET)
24118
+ break;
24119
+ entries.push(e);
24120
+ used += size;
24121
+ }
23855
24122
  const full = await readFull(trilium, id, { section, occurrence });
23856
- return txt({ ...full, children: entries });
24123
+ return txt({
24124
+ ...full,
24125
+ totalChildren,
24126
+ children: entries,
24127
+ ...totalChildren > entries.length ? { more: `Showing the newest ${entries.length} of ${totalChildren} day-children. Pass limit= for more, or date= for one day.` } : {}
24128
+ });
23857
24129
  });
23858
24130
  server.tool("memory_recall", "Skim Memory: active threads and recent sessions (ids + previews). An optional query filters threads by title.", { query: stringType().optional(), limit: numberType().optional() }, async ({ query, limit }) => {
23859
24131
  const cfg = b();
@@ -23884,10 +24156,15 @@ section="<heading>" reads one section.`, {
23884
24156
  function registerKnowledgeTools(server, trilium, brainRef) {
23885
24157
  const b = () => brainRef.config;
23886
24158
  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.`, {
23887
- id: stringType(),
24159
+ ...idParams,
23888
24160
  section: stringType().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole note"),
23889
24161
  occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)")
23890
- }, async ({ id, section, occurrence }) => txt(await readFull(trilium, id, { section, occurrence })));
24162
+ }, async ({ id, noteId, section, occurrence }) => {
24163
+ const target = id ?? noteId;
24164
+ if (!target)
24165
+ return missingId("knowledge");
24166
+ return txt(await readFull(trilium, target, { section, occurrence }));
24167
+ });
23891
24168
  server.tool("knowledge_recall", "Skim Knowledge. With a domain: that domain's Sources + information notes. Without: user-knowledge notes plus the list of domains.", { query: stringType().optional(), domain: stringType().optional() }, async ({ query, domain }) => {
23892
24169
  const cfg = b();
23893
24170
  if (domain) {
@@ -23921,7 +24198,7 @@ section="<heading>" reads one section instead of the whole log.`, {
23921
24198
  return txt({ date: d, note: "No log for this day." });
23922
24199
  return txt(await readFull(trilium, found.results[0].noteId, { section, occurrence }));
23923
24200
  });
23924
- server.tool("insights_recall", "Skim recent change logs (days + previews).", { limit: numberType().optional() }, async ({ limit }) => txt(await skim(trilium, b().insights.logs, { kind: "log", limit: limit ?? 14 })));
24201
+ server.tool("insights_recall", "Skim the most recent daily change logs (dates + previews), newest first. limit sets how many days (default 14). For one day's full log use insights(date).", { limit: numberType().optional() }, async ({ limit }) => txt(await skim(trilium, b().insights.logs, { kind: "log", limit: limit ?? 14 })));
23925
24202
  }
23926
24203
 
23927
24204
  // src/tools.ts
@@ -23970,6 +24247,20 @@ function registerTools(server, trilium, brainRef) {
23970
24247
  const found = await trilium.searchNotes(`#noteType=session #created='${date}'`, { ancestorNoteId: cfg.memory.sessions, fastSearch: true, limit: 1 });
23971
24248
  return found.results[0]?.noteId ?? null;
23972
24249
  };
24250
+ const ensureGateNote = async (date) => {
24251
+ const existing = await gateNote(date);
24252
+ if (existing)
24253
+ return existing;
24254
+ const cfg = b();
24255
+ if (!cfg.memory.sessions)
24256
+ return null;
24257
+ const created = await trilium.createNote(cfg.memory.sessions, `[${date}]`, contentFor("session", { date, body: "" }));
24258
+ const noteId = created.note.noteId;
24259
+ await trilium.addLabel(noteId, "noteType", "session");
24260
+ await trilium.addLabel(noteId, "created", date);
24261
+ await ensureIcon(trilium, noteId);
24262
+ return noteId;
24263
+ };
23973
24264
  const parseGate = (raw) => {
23974
24265
  const out = new Map;
23975
24266
  for (const pair of (raw ?? "").split(",")) {
@@ -23982,9 +24273,9 @@ function registerTools(server, trilium, brainRef) {
23982
24273
  const serializeGate = (m) => [...m.entries()].map(([step, seq]) => `${step}:${seq}`).join(",");
23983
24274
  const markStep = async (step, date) => {
23984
24275
  const d = checkedDate(date);
23985
- const noteId = await gateNote(d);
24276
+ const noteId = await ensureGateNote(d);
23986
24277
  if (!noteId)
23987
- throw new Error(`Cannot record pre-close step "${step}": today's session note was not found`);
24278
+ throw new Error(`Cannot record pre-close step "${step}": the brain has no Sessions container \u2014 run bootstrap()`);
23988
24279
  const note = await trilium.getNote(noteId);
23989
24280
  const stored = parseGate(labelOf2(note, "gate"));
23990
24281
  const next = Math.max(preCloseSeq, ...[...stored.values()], 0) + 1;
@@ -24092,7 +24383,7 @@ ${block}` }));
24092
24383
  deleted: [],
24093
24384
  flagged: [`sweep failed: ${e}`],
24094
24385
  dryRun: false,
24095
- policy: { dormantAfterDays: cfg.policy.dormantAfterDays, archiveDormantAfterDays: cfg.policy.archiveDormantAfterDays, staleAfterDays: cfg.policy.staleAfterDays, deletionCatchupDays: cfg.policy.deletionCatchupDays ?? 7 }
24386
+ policy: { dormantAfterDays: cfg.policy.dormantAfterDays, archiveDormantAfterDays: cfg.policy.archiveDormantAfterDays, staleAfterDays: cfg.policy.staleAfterDays, deletionCatchupDays: deletionCatchupDays(cfg) }
24096
24387
  })),
24097
24388
  buildDigest(trilium, cfg, { depth: depth ?? "digest" })
24098
24389
  ]);
@@ -24129,11 +24420,7 @@ ${block}` }));
24129
24420
  sessionPreview = toText(content, 200);
24130
24421
  newDay = !/<h2(?:\s[^>]*)?>\s*Addendum/i.test(content);
24131
24422
  } else {
24132
- const created = await trilium.createNote(cfg.memory.sessions, `[${todayStr}]`, contentFor("session", { date: todayStr, body: "" }));
24133
- sessionNoteId = created.note.noteId;
24134
- await trilium.addLabel(sessionNoteId, "noteType", "session");
24135
- await trilium.addLabel(sessionNoteId, "created", todayStr);
24136
- await ensureIcon(trilium, sessionNoteId);
24423
+ sessionNoteId = await ensureGateNote(todayStr);
24137
24424
  newDay = true;
24138
24425
  }
24139
24426
  } catch {}
@@ -25055,7 +25342,7 @@ ${block}`), d);
25055
25342
 
25056
25343
  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
25344
 
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.`, {
25345
+ Check the receipt: matched=false means a NEW section was written (available= lists real headings; strict=true refuses instead). A section replace swaps everything under the heading \u2014 use find= for anything smaller. find= matches stored HTML (tags literal), not rendered text, and is taken literally: a Windows path's backslash is one backslash, never doubled.`, {
25059
25346
  noteId: stringType().describe("Note to update"),
25060
25347
  body: stringType().optional().describe("Content to add/replace: plain text, markdown, or HTML. With find=, the raw replacement string (no conversion)."),
25061
25348
  title: stringType().optional().describe("New title (normalized server-side)"),
@@ -26218,7 +26505,7 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
26218
26505
  ...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." }
26219
26506
  });
26220
26507
  });
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.`, {
26508
+ 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: structural lint reads at most 40 notes per run and rotates through the rest day by day, so a whole-brain lint spans several runs.`, {
26222
26509
  deep: booleanType().optional().describe("Deep pass: stale-review + orphan/sink + structural lint + duplicate titles across Memory/Threads and Knowledge (default: false)"),
26223
26510
  dryRun: booleanType().optional().describe("Report what would change without changing it"),
26224
26511
  domain: stringType().optional().describe("Narrow the deep passes to one domain's notes (slugged server-side) \u2014 for scoped agents working a single lane"),
@@ -27142,7 +27429,7 @@ note.dateModified >= 'YYYY-MM-DD', AND/OR. Unscoped unless ancestorNoteId is giv
27142
27429
  const note = await trilium.getNote(noteId);
27143
27430
  return txt4(await trilium.getNoteContentResult(noteId, note.type));
27144
27431
  });
27145
- server.tool("create_note", "Create a note at an explicit parent. Types: text/code/book/canvas/mermaid/relationMap/render/search/file/image.", {
27432
+ server.tool("create_note", 'Create a note at an explicit parent. Types: text/code/book/canvas/mermaid/relationMap/render/search/file/image. The note gets no #noteType, icon or placement, so core tools cannot see it until it is typed with label(id, "noteType", <kind>); use remember() for a memory.', {
27146
27433
  parentNoteId: stringType(),
27147
27434
  title: stringType(),
27148
27435
  content: stringType(),
@@ -27159,7 +27446,7 @@ note.dateModified >= 'YYYY-MM-DD', AND/OR. Unscoped unless ancestorNoteId is giv
27159
27446
  throw error;
27160
27447
  }
27161
27448
  });
27162
- server.tool("update_note_content", "Replace a note's full content. Binary input must use encoding=base64; text remains UTF-8.", { noteId: stringType(), content: stringType(), mime: stringType().optional(), encoding: enumType(["auto", "text", "base64"]).optional() }, async ({ noteId, content, mime, encoding }) => {
27449
+ server.tool("update_note_content", "Replace a note's full content. Binary input must use encoding=base64; text remains UTF-8. No revision is taken and nothing is sanitised: call create_revision first to keep the prior content recoverable, and prefer revise() for any BrainLLM note.", { noteId: stringType(), content: stringType(), mime: stringType().optional(), encoding: enumType(["auto", "text", "base64"]).optional() }, async ({ noteId, content, mime, encoding }) => {
27163
27450
  const note = await trilium.getNote(noteId);
27164
27451
  const targetMime = mime ?? note.mime;
27165
27452
  try {
@@ -27183,7 +27470,7 @@ note.dateModified >= 'YYYY-MM-DD', AND/OR. Unscoped unless ancestorNoteId is giv
27183
27470
  const note = await trilium.patchNote(noteId, fields);
27184
27471
  return txt4({ noteId: note.noteId, title: note.title, type: note.type });
27185
27472
  });
27186
- server.tool("delete_note", "Hard-delete a note (and its subtree if this is its last branch). Irreversible.", { noteId: stringType() }, async ({ noteId }) => {
27473
+ server.tool("delete_note", "Hard-delete a single note. Irreversible. Refused (blocked: true) while any relation points at it (backlinks_exist), while it has children or more than one parent (blast_radius), or if the backlink check itself fails. Remove or retarget those first; for a recoverable removal use forget().", { noteId: stringType() }, async ({ noteId }) => {
27187
27474
  const note = await trilium.getNote(noteId);
27188
27475
  let backlinks;
27189
27476
  try {
@@ -28514,6 +28801,12 @@ if (!brain) {
28514
28801
  console.error("[brainllm] BrainLLM not found in Trilium. Run the bootstrap tool to initialize.");
28515
28802
  }
28516
28803
  }
28804
+ {
28805
+ const { invalid } = envDeletionCatchupDays();
28806
+ if (invalid !== undefined) {
28807
+ console.error(`[brainllm] BRAINLLM_DELETION_CATCHUP_DAYS="${invalid}" ignored \u2014 expected a whole number of days from 1 to 3650. Using ${deletionCatchupDays(brain ?? EMPTY_BRAINLLM)}.`);
28808
+ }
28809
+ }
28517
28810
  var brainRef = { config: brain ?? EMPTY_BRAINLLM };
28518
28811
  var port = process.env.PORT ? parseInt(process.env.PORT, 10) : null;
28519
28812
  var authToken = process.env.MCP_AUTH_TOKEN;
@@ -28536,7 +28829,7 @@ function createServer(origin = null) {
28536
28829
  const s = new McpServer({
28537
28830
  name: "BrainLLM",
28538
28831
  title: "BrainLLM",
28539
- version: "12.5.0",
28832
+ version: "12.6.1",
28540
28833
  icons: brandingIcons(origin)
28541
28834
  });
28542
28835
  registerTools(s, trilium, brainRef);
package/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "manifest_version": "0.3",
3
3
  "name": "brainllm",
4
4
  "display_name": "BrainLLM",
5
- "version": "12.5.0",
5
+ "version": "12.6.1",
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.5.0",
3
+ "version": "12.6.1",
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.5.0",
5
+ "version": "12.6.1",
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.5.0",
15
+ "version": "12.6.1",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -80,7 +80,7 @@ worth keeping?
80
80
  └─ passing remark or already known from training ─→ don't capture
81
81
  ```
82
82
 
83
- **Domains** are one maintained **Sources** note plus one information note per sub-category, including **Current State**, revised in place. Every claim traces to a Sources entry (❇️ discovered, ✅ used); `remember(kind="sources", revision=[{source, marker, date}])` upserts the Revision row. Creating a domain creates its Sources note. Read `references/domains.md` before creating a domain, adding a sub-category or refreshing one.
83
+ **Domains are your own working knowledge of a subject as it currently is.** Their scope, depth and size are your call, set by what keeps you confident enough to act and advise well; refresh one whenever reality moves. The structure keeps that knowledge tidy and checkable, and never caps it: one maintained **Sources** note plus one information note per sub-category, including **Current State**, revised in place. Every claim traces to a Sources entry (❇️ discovered, ✅ used); `remember(kind="sources", revision=[{source, marker, date}])` upserts the Revision row. Creating a domain creates its Sources note. Read `references/domains.md` before creating a domain, adding a sub-category or refreshing one.
84
84
 
85
85
  ---
86
86
 
@@ -89,7 +89,7 @@ worth keeping?
89
89
  | Need | Tool |
90
90
  |---|---|
91
91
  | a singleton | `master(which)` / `llm(which)` — `section=` for one section |
92
- | a thread or session | `memory(id)` — dated index or collection entries; `date=` for one day |
92
+ | a thread or session | `memory(id)` — dated index (newest days, up to 30 and ~24k characters; `limit=` for more) or collection entries; `date=` for one day |
93
93
  | a knowledge note | `knowledge(id)` — `section=` for one section |
94
94
  | a day's change log | `insights(date?)` |
95
95
  | skim a surface | `<surface>_recall` |
@@ -122,7 +122,7 @@ Every read that can be large takes `section=`; a note past the read ceiling cann
122
122
  - **Wire at creation:** `connect=[{relation, toNoteId}]` on the same call. An unconnected note is an orphan until wired.
123
123
  - **Identity line** (`"LLM · environment · agent/mode [· Run N]"`) is required on diary, close and dated thread appends.
124
124
  - **Every note carries an icon except logs.** The server sets the kind default at creation (a thread entry takes its thread's icon) and the sweep backfills any missing; `icon=` picks a better one (boxicons class or bare name). Removing an icon is refused.
125
- - Bodies may be text, markdown or HTML; the server normalises them and reports `sanitized[]`.
125
+ - Bodies may be text, markdown or HTML; the server normalises them to editor-native HTML and reports `sanitized[]` (markdown mixed into an HTML body is converted; a placeholder like `<name>` stays as text).
126
126
  - `diary`, `session`, `log`, `claim` and `domain` have dedicated paths; `remember` refuses them.
127
127
 
128
128
  **Four writing rules** (each applies to every note you touch):
@@ -214,6 +214,7 @@ Lite runs inside `start`/`close` (thread aging, label checks). `maintain(deep=tr
214
214
  | timeouts or connection errors | the Trilium instance at `TRILIUM_BASE_URL` is unreachable; tools don't need restarting |
215
215
  | `start()` → `uninitialized` | `bootstrap()` |
216
216
  | dates look off on a hosted deploy | set `BRAINLLM_TZ` (IANA) |
217
+ | deletions older than a week go unreported | Trilium keeps deleted notes longer than its 7-day default: set `BRAINLLM_DELETION_CATCHUP_DAYS` to match |
217
218
  | an informational `{error, detail, hint}` | read `hint` and retry with corrected arguments |
218
219
 
219
220
  ## References
@@ -2,6 +2,8 @@
2
2
 
3
3
  Read this before creating a domain, adding a sub-category note, or refreshing domain content, then follow the one protocol that matches. Never skip the sources gate and never manufacture knowledge.
4
4
 
5
+ **A domain is your own working knowledge of a subject as it currently is.** What it covers, how deep it goes and how large it grows are your call. The measure is confidence: enough that a session reading the domain can act and advise well without re-deriving the subject, and no more. The structure below does not cap that knowledge. It keeps it tidy and makes every claim checkable against a source. When reality moves, refresh the domain rather than waiting to be asked.
6
+
5
7
  ## The shape of a domain
6
8
 
7
9
  A domain is **one Sources note plus one information note per sub-category**, each a maintained document revised in place.
@@ -20,7 +20,7 @@ BrainLLM memories are typed text notes; `code` / `file` / `image` notes aren't a
20
20
  - **Code / structured text** → embed it as a fenced block in an `information` note via core `remember` (fully conformant and full-text searchable), or
21
21
  - **Binary (image, PDF, file)** → core `attach()` it onto the relevant typed note (`role=image` / `file`) — pass standard base64 with `encoding="base64"`; read back with `attach(noteId, title)`, which returns the base64 envelope and MIME.
22
22
 
23
- Create a standalone `type=code` / `file` note only when you specifically need Trilium's native handling of that type. If you do: label it (`add_label noteType <closest kind>`) so `recall` can see it, give it the same `domain` / `topic` labels as its anchor, and `connect` it to a typed note — otherwise it's an orphan with a blueprint-less type, exactly what `maintain(deep=true)` flags.
23
+ Create a standalone `type=code` / `file` note only when you specifically need Trilium's native handling of that type. If you do: label it (`add_label noteType <closest kind>`) so `recall` can see it, give it the same `domain` / `topic` labels as its anchor, and `connect` it to a typed note — otherwise it's an untyped orphan, exactly what `maintain(deep=true)` flags.
24
24
 
25
25
  ### Use-case → tool
26
26
 
@@ -47,4 +47,3 @@
47
47
  | `bootstrap` created a duplicate brain tree | Caused by a transient network/auth error during the existence check — the old catch-all fell through to fresh create. Fixed: only a confirmed 404 (root deleted) now triggers a new tree. To recover: identify the newer duplicate by `dateCreated`, then `forget(rootId, hard=true)` its entire subtree or delete it directly in Trilium. |
48
48
  | Hosted deploy: `ENOENT` on startup / auto-discovery fails with a file-path error | `BRAINLLM_CONFIG` is set to a directory path instead of a file path (e.g. `/home/node/trilium-data` instead of `/vol/brainllm.json`). Mount a persistent volume on the BrainLLM MCP service (not the Trilium service) and set `BRAINLLM_CONFIG` to a file path inside it, or remove `BRAINLLM_CONFIG` entirely to rely on auto-discovery each startup. |
49
49
  | `revise`/`remember` on a thread returns `collection_thread` | It is a collection thread: add entries with `remember(kind="threadEntry", thread=, title=)`, edit them with `revise(<entry id>)` |
50
- | `content is not standard RFC 4648 base64` on a text note | Fixed in V12.5: a blank MIME type is now text. On an older server, rewrite the note with `update_note_content(mime="text/html", encoding="text")` |