brainllm 12.5.0 → 12.6.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
@@ -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.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)
@@ -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()) {
@@ -23369,7 +23611,7 @@ async function hygienePasses(trilium, cfg, report, ctx) {
23369
23611
  for (const n of heavy) {
23370
23612
  if (!eligible(n) || ownedLabel(n, "threadShape") === "collection")
23371
23613
  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.`);
23614
+ 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
23615
  }
23374
23616
  }
23375
23617
  if (cfg.knowledge.domains) {
@@ -23613,9 +23855,8 @@ async function generateDailyLog(trilium, cfg, date) {
23613
23855
  await trilium.addLabel(logNoteId, "created", date);
23614
23856
  return { date, noteId: logNoteId, ...counts, action: "created" };
23615
23857
  }
23616
- var DELETION_CATCHUP_DAYS = 7;
23617
23858
  async function catchUpDeletions(trilium, cfg, today) {
23618
- const windowDays = cfg.policy.deletionCatchupDays ?? DELETION_CATCHUP_DAYS;
23859
+ const windowDays = deletionCatchupDays(cfg);
23619
23860
  const empty = { windowDays, coverage: "full", deletionsFound: 0, regenerated: [] };
23620
23861
  if (!cfg.root || !cfg.insights.logs)
23621
23862
  return empty;
@@ -23656,6 +23897,11 @@ async function catchUpDeletions(trilium, cfg, today) {
23656
23897
  var txt = (obj) => ({
23657
23898
  content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }]
23658
23899
  });
23900
+ var idParams = {
23901
+ id: stringType().optional().describe("The note's id (noteId is accepted as an alias)"),
23902
+ noteId: stringType().optional().describe("Alias of id")
23903
+ };
23904
+ var missingId = (tool) => txt({ error: "missing_id", detail: `${tool}() needs the note's id.`, hint: "Pass id=<noteId> (noteId= works too)." });
23659
23905
  var labelOf = (n, name) => n.attributes.find((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === name)?.value;
23660
23906
  async function skim(trilium, ancestorNoteId, opts = {}) {
23661
23907
  const { query, kind, limit = 20, includeArchived = false } = opts;
@@ -23771,14 +24017,16 @@ function registerLlmTools(server, trilium, brainRef) {
23771
24017
  const b = () => brainRef.config;
23772
24018
  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
24019
  which: enumType(["responsibilities", "protocols", "selfcorrection", "diary"]),
23774
- id: stringType().optional().describe("diary only: the entry id from llm_recall"),
24020
+ id: stringType().optional().describe("diary only: the entry id from llm_recall (noteId is accepted as an alias)"),
24021
+ noteId: stringType().optional().describe("Alias of id"),
23775
24022
  section: stringType().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole note"),
23776
24023
  occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)")
23777
- }, async ({ which, id, section, occurrence }) => {
24024
+ }, async ({ which, id, noteId: idAlias, section, occurrence }) => {
23778
24025
  if (which === "diary") {
23779
- if (!id)
24026
+ const entry = id ?? idAlias;
24027
+ if (!entry)
23780
24028
  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 }));
24029
+ return txt(await readFull(trilium, entry, { section, occurrence }));
23782
24030
  }
23783
24031
  const noteId = b().llm[which];
23784
24032
  if (!noteId) {
@@ -23804,18 +24052,25 @@ function registerLlmTools(server, trilium, brainRef) {
23804
24052
  }
23805
24053
 
23806
24054
  // src/tools-memory.ts
24055
+ var MEMORY_INDEX_DEFAULT = 30;
24056
+ var MEMORY_INDEX_CHAR_BUDGET = 24000;
23807
24057
  function registerMemoryTools(server, trilium, brainRef) {
23808
24058
  const b = () => brainRef.config;
23809
24059
  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
24060
+ Resolution plus an index of its [yyyy-mm-dd] children (newest first: the latest 30, fewer if their
24061
+ index passes ~24k characters, unless limit= says otherwise; with the total count), each listed by its addendum blocks;
24062
+ date="yyyy-mm-dd" resolves straight to one day. A collection thread returns its
23812
24063
  Context plus its titled entries (alphabetical, with a lead). Read a child with memory(<child id>).
23813
24064
  section="<heading>" reads one section.`, {
23814
- id: stringType(),
24065
+ ...idParams,
23815
24066
  date: stringType().optional().describe("Thread books only: resolve directly to this day's child note (yyyy-mm-dd)"),
24067
+ limit: numberType().int().positive().optional().describe("Dated threads: how many of the newest day-children to index (default 30)"),
23816
24068
  section: stringType().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole note"),
23817
24069
  occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)")
23818
- }, async ({ id, date, section, occurrence }) => {
24070
+ }, async ({ id: idArg, noteId, date, limit, section, occurrence }) => {
24071
+ const id = idArg ?? noteId;
24072
+ if (!id)
24073
+ return missingId("memory");
23819
24074
  const note = await trilium.getNote(id).catch(() => null);
23820
24075
  if (!note || labelOf(note, "noteType") !== "thread")
23821
24076
  return txt(await readFull(trilium, id, { section, occurrence }));
@@ -23836,14 +24091,16 @@ section="<heading>" reads one section.`, {
23836
24091
  return txt(await readFull(trilium, child.results[0].noteId, { section, occurrence }));
23837
24092
  return txt({ id, title: note.title, kind: "thread", note: `No entry for ${date}.` });
23838
24093
  }
24094
+ const shown = limit ?? MEMORY_INDEX_DEFAULT;
24095
+ const totalChildren = note.childNoteIds.length;
23839
24096
  const children = await trilium.searchNotes("#noteType=threadEntry", {
23840
24097
  ancestorNoteId: id,
23841
24098
  fastSearch: true,
23842
- limit: 200,
24099
+ limit: shown,
23843
24100
  orderBy: "dateCreated",
23844
24101
  orderDirection: "desc"
23845
24102
  }).catch(() => ({ results: [] }));
23846
- const entries = await Promise.all(children.results.map(async (c) => {
24103
+ const indexed = await Promise.all(children.results.map(async (c) => {
23847
24104
  const content = await trilium.getNoteContent(c.noteId).catch(() => "");
23848
24105
  const blocks = addendumIndex(content);
23849
24106
  return {
@@ -23852,8 +24109,22 @@ section="<heading>" reads one section.`, {
23852
24109
  ...blocks.length ? { blocks } : { preview: toText(content, 160) }
23853
24110
  };
23854
24111
  }));
24112
+ const entries = [];
24113
+ let used = 0;
24114
+ for (const e of indexed) {
24115
+ const size = JSON.stringify(e).length;
24116
+ if (limit === undefined && entries.length && used + size > MEMORY_INDEX_CHAR_BUDGET)
24117
+ break;
24118
+ entries.push(e);
24119
+ used += size;
24120
+ }
23855
24121
  const full = await readFull(trilium, id, { section, occurrence });
23856
- return txt({ ...full, children: entries });
24122
+ return txt({
24123
+ ...full,
24124
+ totalChildren,
24125
+ children: entries,
24126
+ ...totalChildren > entries.length ? { more: `Showing the newest ${entries.length} of ${totalChildren} day-children. Pass limit= for more, or date= for one day.` } : {}
24127
+ });
23857
24128
  });
23858
24129
  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
24130
  const cfg = b();
@@ -23884,10 +24155,15 @@ section="<heading>" reads one section.`, {
23884
24155
  function registerKnowledgeTools(server, trilium, brainRef) {
23885
24156
  const b = () => brainRef.config;
23886
24157
  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(),
24158
+ ...idParams,
23888
24159
  section: stringType().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole note"),
23889
24160
  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 })));
24161
+ }, async ({ id, noteId, section, occurrence }) => {
24162
+ const target = id ?? noteId;
24163
+ if (!target)
24164
+ return missingId("knowledge");
24165
+ return txt(await readFull(trilium, target, { section, occurrence }));
24166
+ });
23891
24167
  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
24168
  const cfg = b();
23893
24169
  if (domain) {
@@ -23921,7 +24197,7 @@ section="<heading>" reads one section instead of the whole log.`, {
23921
24197
  return txt({ date: d, note: "No log for this day." });
23922
24198
  return txt(await readFull(trilium, found.results[0].noteId, { section, occurrence }));
23923
24199
  });
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 })));
24200
+ 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
24201
  }
23926
24202
 
23927
24203
  // src/tools.ts
@@ -23970,6 +24246,20 @@ function registerTools(server, trilium, brainRef) {
23970
24246
  const found = await trilium.searchNotes(`#noteType=session #created='${date}'`, { ancestorNoteId: cfg.memory.sessions, fastSearch: true, limit: 1 });
23971
24247
  return found.results[0]?.noteId ?? null;
23972
24248
  };
24249
+ const ensureGateNote = async (date) => {
24250
+ const existing = await gateNote(date);
24251
+ if (existing)
24252
+ return existing;
24253
+ const cfg = b();
24254
+ if (!cfg.memory.sessions)
24255
+ return null;
24256
+ const created = await trilium.createNote(cfg.memory.sessions, `[${date}]`, contentFor("session", { date, body: "" }));
24257
+ const noteId = created.note.noteId;
24258
+ await trilium.addLabel(noteId, "noteType", "session");
24259
+ await trilium.addLabel(noteId, "created", date);
24260
+ await ensureIcon(trilium, noteId);
24261
+ return noteId;
24262
+ };
23973
24263
  const parseGate = (raw) => {
23974
24264
  const out = new Map;
23975
24265
  for (const pair of (raw ?? "").split(",")) {
@@ -23982,9 +24272,9 @@ function registerTools(server, trilium, brainRef) {
23982
24272
  const serializeGate = (m) => [...m.entries()].map(([step, seq]) => `${step}:${seq}`).join(",");
23983
24273
  const markStep = async (step, date) => {
23984
24274
  const d = checkedDate(date);
23985
- const noteId = await gateNote(d);
24275
+ const noteId = await ensureGateNote(d);
23986
24276
  if (!noteId)
23987
- throw new Error(`Cannot record pre-close step "${step}": today's session note was not found`);
24277
+ throw new Error(`Cannot record pre-close step "${step}": the brain has no Sessions container \u2014 run bootstrap()`);
23988
24278
  const note = await trilium.getNote(noteId);
23989
24279
  const stored = parseGate(labelOf2(note, "gate"));
23990
24280
  const next = Math.max(preCloseSeq, ...[...stored.values()], 0) + 1;
@@ -24092,7 +24382,7 @@ ${block}` }));
24092
24382
  deleted: [],
24093
24383
  flagged: [`sweep failed: ${e}`],
24094
24384
  dryRun: false,
24095
- policy: { dormantAfterDays: cfg.policy.dormantAfterDays, archiveDormantAfterDays: cfg.policy.archiveDormantAfterDays, staleAfterDays: cfg.policy.staleAfterDays, deletionCatchupDays: cfg.policy.deletionCatchupDays ?? 7 }
24385
+ policy: { dormantAfterDays: cfg.policy.dormantAfterDays, archiveDormantAfterDays: cfg.policy.archiveDormantAfterDays, staleAfterDays: cfg.policy.staleAfterDays, deletionCatchupDays: deletionCatchupDays(cfg) }
24096
24386
  })),
24097
24387
  buildDigest(trilium, cfg, { depth: depth ?? "digest" })
24098
24388
  ]);
@@ -24129,11 +24419,7 @@ ${block}` }));
24129
24419
  sessionPreview = toText(content, 200);
24130
24420
  newDay = !/<h2(?:\s[^>]*)?>\s*Addendum/i.test(content);
24131
24421
  } 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);
24422
+ sessionNoteId = await ensureGateNote(todayStr);
24137
24423
  newDay = true;
24138
24424
  }
24139
24425
  } catch {}
@@ -25055,7 +25341,7 @@ ${block}`), d);
25055
25341
 
25056
25342
  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
25343
 
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.`, {
25344
+ 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
25345
  noteId: stringType().describe("Note to update"),
25060
25346
  body: stringType().optional().describe("Content to add/replace: plain text, markdown, or HTML. With find=, the raw replacement string (no conversion)."),
25061
25347
  title: stringType().optional().describe("New title (normalized server-side)"),
@@ -26218,7 +26504,7 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
26218
26504
  ...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
26505
  });
26220
26506
  });
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.`, {
26507
+ 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
26508
  deep: booleanType().optional().describe("Deep pass: stale-review + orphan/sink + structural lint + duplicate titles across Memory/Threads and Knowledge (default: false)"),
26223
26509
  dryRun: booleanType().optional().describe("Report what would change without changing it"),
26224
26510
  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 +27428,7 @@ note.dateModified >= 'YYYY-MM-DD', AND/OR. Unscoped unless ancestorNoteId is giv
27142
27428
  const note = await trilium.getNote(noteId);
27143
27429
  return txt4(await trilium.getNoteContentResult(noteId, note.type));
27144
27430
  });
27145
- server.tool("create_note", "Create a note at an explicit parent. Types: text/code/book/canvas/mermaid/relationMap/render/search/file/image.", {
27431
+ 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
27432
  parentNoteId: stringType(),
27147
27433
  title: stringType(),
27148
27434
  content: stringType(),
@@ -27159,7 +27445,7 @@ note.dateModified >= 'YYYY-MM-DD', AND/OR. Unscoped unless ancestorNoteId is giv
27159
27445
  throw error;
27160
27446
  }
27161
27447
  });
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 }) => {
27448
+ 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
27449
  const note = await trilium.getNote(noteId);
27164
27450
  const targetMime = mime ?? note.mime;
27165
27451
  try {
@@ -27183,7 +27469,7 @@ note.dateModified >= 'YYYY-MM-DD', AND/OR. Unscoped unless ancestorNoteId is giv
27183
27469
  const note = await trilium.patchNote(noteId, fields);
27184
27470
  return txt4({ noteId: note.noteId, title: note.title, type: note.type });
27185
27471
  });
27186
- server.tool("delete_note", "Hard-delete a note (and its subtree if this is its last branch). Irreversible.", { noteId: stringType() }, async ({ noteId }) => {
27472
+ 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
27473
  const note = await trilium.getNote(noteId);
27188
27474
  let backlinks;
27189
27475
  try {
@@ -28514,6 +28800,12 @@ if (!brain) {
28514
28800
  console.error("[brainllm] BrainLLM not found in Trilium. Run the bootstrap tool to initialize.");
28515
28801
  }
28516
28802
  }
28803
+ {
28804
+ const { invalid } = envDeletionCatchupDays();
28805
+ if (invalid !== undefined) {
28806
+ 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)}.`);
28807
+ }
28808
+ }
28517
28809
  var brainRef = { config: brain ?? EMPTY_BRAINLLM };
28518
28810
  var port = process.env.PORT ? parseInt(process.env.PORT, 10) : null;
28519
28811
  var authToken = process.env.MCP_AUTH_TOKEN;
@@ -28536,7 +28828,7 @@ function createServer(origin = null) {
28536
28828
  const s = new McpServer({
28537
28829
  name: "BrainLLM",
28538
28830
  title: "BrainLLM",
28539
- version: "12.5.0",
28831
+ version: "12.6.0",
28540
28832
  icons: brandingIcons(origin)
28541
28833
  });
28542
28834
  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.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.5.0",
3
+ "version": "12.6.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.5.0",
5
+ "version": "12.6.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.5.0",
15
+ "version": "12.6.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -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
@@ -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")` |