brainllm 12.7.1 → 12.9.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/manifest.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "manifest_version": "0.3",
3
3
  "name": "brainllm",
4
4
  "display_name": "BrainLLM",
5
- "version": "12.7.1",
6
- "description": "Turns TriliumNext Notes into a persistent, graph-structured second brain for Claude and other LLM clients.",
5
+ "version": "12.9.0",
6
+ "description": "Self-hosted memory for LLMs in TriliumNext, structured by the server and editable by you.",
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": {
9
9
  "name": "Kevin Miiso",
10
10
  "email": "miisodev@gmail.com",
11
11
  "url": "https://github.com/miisodev"
12
12
  },
13
- "homepage": "https://github.com/miisodev/BrainLLM#readme",
13
+ "homepage": "https://miisodev.github.io/BrainLLM/",
14
14
  "repository": {
15
15
  "type": "git",
16
16
  "url": "https://github.com/miisodev/BrainLLM.git"
17
17
  },
18
18
  "support": "https://github.com/miisodev/BrainLLM/issues",
19
19
  "license": "MIT",
20
- "keywords": ["mcp", "trilium", "trilium-next", "second-brain", "knowledge-graph", "memory", "claude"],
20
+ "keywords": ["mcp", "trilium", "trilium-next", "knowledge-graph", "memory", "claude"],
21
21
  "icon": "public/BrainLLM.png",
22
22
  "server": {
23
23
  "type": "binary",
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "brainllm",
3
- "version": "12.7.1",
3
+ "version": "12.9.0",
4
4
  "mcpName": "io.github.miisodev/brainllm",
5
- "description": "MCP server that turns TriliumNext Notes into a persistent, graph-structured second brain for Claude and other LLM clients.",
5
+ "description": "Self-hosted memory for LLMs in TriliumNext, structured by the server and editable by you.",
6
6
  "author": "Kevin Miiso Novo <miisodev@gmail.com>",
7
7
  "license": "MIT",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "https://github.com/miisodev/BrainLLM.git"
11
11
  },
12
- "homepage": "https://github.com/miisodev/BrainLLM#readme",
12
+ "homepage": "https://miisodev.github.io/BrainLLM/",
13
13
  "bugs": {
14
14
  "url": "https://github.com/miisodev/BrainLLM/issues"
15
15
  },
@@ -22,7 +22,7 @@
22
22
  "model-context-protocol",
23
23
  "trilium",
24
24
  "trilium-next",
25
- "second-brain",
25
+ "memory",
26
26
  "knowledge-graph",
27
27
  "llm",
28
28
  "claude",
@@ -54,6 +54,7 @@
54
54
  "test": "bun run src/test.ts",
55
55
  "test:unit": "bun test",
56
56
  "test:http": "bun run scripts/http-smoke.ts",
57
+ "test:live": "bun run scripts/live-smoke.ts",
57
58
  "init": "bun run src/init.ts",
58
59
  "icons": "bun run scripts/icons.ts",
59
60
  "validate:release": "bun run scripts/validate-release.ts",
@@ -61,14 +62,14 @@
61
62
  "prepublishOnly": "bun run build"
62
63
  },
63
64
  "dependencies": {
64
- "@modelcontextprotocol/sdk": "^1.30.1",
65
+ "@modelcontextprotocol/sdk": "^1.32.0",
65
66
  "zod": "^3.25.76"
66
67
  },
67
68
  "devDependencies": {
68
69
  "@types/bun": "latest",
69
70
  "ajv": "^8.20.0",
70
71
  "ajv-formats": "^3.0.1",
71
- "sharp": "^0.35.4",
72
+ "sharp": "^0.35.5",
72
73
  "typescript": "^5.9.3"
73
74
  }
74
75
  }
package/server.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.miisodev/brainllm",
4
- "description": "Persistent graph-structured memory for LLMs in TriliumNext Notes — readable and editable by humans.",
5
- "version": "12.7.1",
6
- "websiteUrl": "https://github.com/miisodev/BrainLLM#readme",
4
+ "description": "Self-hosted memory for LLMs in TriliumNext, structured by the server and editable by you.",
5
+ "version": "12.9.0",
6
+ "websiteUrl": "https://miisodev.github.io/BrainLLM/",
7
7
  "repository": {
8
8
  "url": "https://github.com/miisodev/BrainLLM",
9
9
  "source": "github"
@@ -12,7 +12,7 @@
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "brainllm",
15
- "version": "12.7.1",
15
+ "version": "12.9.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -39,7 +39,7 @@
39
39
  },
40
40
  {
41
41
  "name": "BRAINLLM_MODE",
42
- "description": "core (default) exposes the 44 brain-aware tools; full additionally exposes the 33 raw ETAPI tools.",
42
+ "description": "core (default) exposes the 45 brain-aware tools; full additionally exposes the 33 raw ETAPI tools.",
43
43
  "isRequired": false,
44
44
  "format": "string",
45
45
  "default": "core"
@@ -30,13 +30,13 @@ DURING remember(...) the moment something is worth keeping
30
30
  resolve / withdraw / recover thread and note lifecycle
31
31
  connect(...) wire a real relation when you see one
32
32
  read: <surface>, domain, recall, read(ids), outline, inspect, assembly, brain
33
- verify: consistency(...), claim(...), diff(since="today")
33
+ verify: consistency(...), claim(...), diff(since="today"), health()
34
34
  END session() → [update singletons] → addendum() → maintain() → remarks() → diary() → close()
35
35
  ```
36
36
 
37
37
  `start()` returns the date, **preferences and protocols in full**, the other singletons as section headings (pull one with `master(which)`/`llm(which)`, `section=` for one section; `depth="full"` inlines all), today's diary and session ids, active and dormant threads, the previous session and notes changed since. `newDay: true` on the first session of a day → call `day()` (previous session, its log, notes touched since). `day(recap=true)` returns everything written today, in order, across sessions, diary and thread entries.
38
38
 
39
- **Close protocol.** `session()` returns singleton stubs, `pending=` (what each remaining step has to do), `audit=` (do the singletons agree, and do the LLM ones still serve the master ones) and `next[]`. Update master singletons with what you learned about the user and LLM singletons with what you learned about yourself, fold any addenda (`addendum()`), `maintain()`, `remarks()` for diary cues, `diary()` your closing record, then `close(summary, identity)`. `close()` refuses until those steps ran and `session → remarks → diary` held; the gate is durable across restarts. `force=true` bypasses a step with nothing to do (reported). `continuing=true` is a second close on the same day. Scoped or autonomous runs pass `session(scope="agent")`.
39
+ **Close protocol.** `session()` returns singleton stubs, `changedToday` (the maintained notes this day's writes touched: check every figure you changed agrees everywhere it is recorded, with `diff(since="today")` and `consistency()`), `pending=` (what each remaining step has to do), `audit=` (do the singletons agree, and do the LLM ones still serve the master ones) and `next[]`. Update master singletons with what you learned about the user and LLM singletons with what you learned about yourself, fold any addenda (`addendum()`), `maintain()`, `remarks()` for diary cues, `diary()` your closing record, then `close(summary, identity)`. `close()` refuses until those steps ran and `session → remarks → diary` held; the gate is durable across restarts. `force=true` bypasses a step with nothing to do (reported). `continuing=true` is a second close on the same day. Scoped or autonomous runs pass `session(scope="agent")`. **Light close:** a session that wrote no content gets `closeMode: "light"` from `session()` — only `remarks()`, a one-paragraph `diary()` and `close()` follow; any content write since the last close brings the full protocol back. Every `close()` also seals its day's records, which `maintain(deep)` later verifies.
40
40
 
41
41
  **Write during the session, not at the end** — a fact written mid-conversation survives a crash.
42
42
 
@@ -89,17 +89,18 @@ 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 (newest days, up to 30 and ~24k characters; `limit=` for more) or collection entries; `date=` for one day; `index=true` for ids, dates and block markers only |
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; `index=true` for ids, dates and block markers only; `block="14:05"` (or `"-1"`, the newest) for one addendum of a session or day entry — `llm("diary", id, block=)` likewise |
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` |
96
- | everything about an area | `domain(name)` — the reliable path; reach here first |
97
- | search | `recall(query, domain=…)` — scope it; `regex=` for structure; fuzzy hits are leads |
98
- | several bodies at once | `read(ids=[…])` (≤ 10) |
96
+ | everything about an area | `domain(name)` — the reliable path; reach here first. `outline=true` adds every maintained note's headings and size, for planning a multi-note rewrite |
97
+ | search | `recall(query, domain=…)` — scope it; `regex=` for structure, each hit with the matched passage as `evidence` (records included; `kinds=` narrows); fuzzy hits are leads; recent notes rank slightly higher |
98
+ | several bodies at once | `read(ids=[…])` (≤ 10). Bodies stop at ~80k characters; the rest come back in `deferred[]` for a second call. `text=true` returns readable plain text for orienting (not for building a `find=` anchor) |
99
+ | storage and growth | `health()` — Trilium version, estimated database size, heaviest notes and their revisions, named backups |
99
100
  | what the brain holds | `assembly(area?)` — titles by surface with purposes |
100
101
  | inventory / locate by id | `brain()` — read `parent`, not position |
101
102
  | a note's heading tree | `outline(id)` — before any `section=` edit you're unsure of |
102
- | raw labels, relations, body | `inspect(id, content?, section?, find?)` |
103
+ | raw labels, relations, body | `inspect(id, content?, section?, find?)` — `find=` also returns the stored element (row, item, paragraph) around each match |
103
104
 
104
105
  Every read that can be large takes `section=`; a note past the read ceiling cannot be returned whole.
105
106
 
@@ -120,9 +121,9 @@ Every read that can be large takes `section=`; a note past the read ceiling cann
120
121
 
121
122
  - **Dedup is by title.** Generic titles (Current State, Sources) exist in many domains — pass `mustCreate=true` when you mean to create, and read `action` on every receipt.
122
123
  - **Wire at creation:** `connect=[{relation, toNoteId}]` on the same call. An unconnected note is an orphan until wired.
123
- - **Identity line** (`"LLM · environment · agent/mode [· Run N]"`) is required on diary, close and dated thread appends.
124
+ - **Identity line** (`"LLM · environment · agent/mode [· Run N]"`) is required on diary, close and dated thread appends. It is checked on write: at least three `·`-separated parts, one line, no draft text — a malformed one is refused with nothing written.
124
125
  - **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 to editor-native HTML and reports `sanitized[]` (markdown mixed into an HTML body is converted; a placeholder like `<name>` stays as text).
126
+ - 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>` — even one named like an element, `<template>`, `<select>` — stays as text and never truncates what follows).
126
127
  - `diary`, `session`, `log`, `claim` and `domain` have dedicated paths; `remember` refuses them.
127
128
 
128
129
  **Four writing rules** (each applies to every note you touch):
@@ -146,15 +147,18 @@ Read `template(kind)` before your first write of a kind, then read a sibling.
146
147
  | `section=` + `mode="before"/"after"` | insert a sibling block around the whole section |
147
148
  | `section=` + `mode="prepend"` | insert at the top of the section's body |
148
149
  | `section=` + `mode="remove"` | delete the section |
149
- | `find="<exact stored text>"` | replace every occurrence (`nth=` for one) |
150
+ | `find="<text>"` | replace every occurrence (`nth=` for one) — exact first, then through re-serialized tags, then ignoring inline formatting (`matchMode` says which) |
150
151
  | `edits=[{find, body}]` | several surgeries in one read and one write |
151
- | `find="<short anchor>"` + `within="tr"` | act on the element containing the anchor: replace it (default), `mode="before"/"after"` to insert a row beside it, `mode="remove"` to delete it (`li`, `p`, `td` … too) |
152
+ | `find="<short anchor>"` + `within="tr"` | act on the element containing the anchor: replace it (default), `mode="before"/"after"` to insert a row beside it, `mode="remove"` to delete it (`li`, `p`, `td` … too). `within` names what is replaced: `"tr"` takes a whole `<tr>…</tr>` body, `"td"` one `<td>…</td>`; a plain `find=` inside a cell replaces only that text |
153
+ | `within="tr"` + `mode="remove"` + `closure={thread, body}` | close a register row in one call: the row goes, and the closure (quoting it) lands in that thread's entry for today. Needs `identity=`; run `consistency()` after on any fact the row asserted |
154
+ | `domain="<name>"` + `find=`/`edits=` (no noteId) | the same find/replace across every maintained note in a domain; a dry run listing each match unless `dryRun=false`. Records are never touched. `dryRun` anywhere else is refused |
152
155
 
153
- - **Tables are edited by row.** Anchor on a few words unique to the row and pass `within="tr"`; never copy a whole stored row into `find=`. An anchor in several rows is refused with previews; `nth=` picks one.
156
+ - **Tables are edited by row.** Anchor on a few words unique to the row and pass `within="tr"`; never copy a whole stored row into `find=`. The row's visible text works too (`"Phase 2 | Claude | Open"`), and quotes match in any form. An anchor in several rows is refused with previews; `nth=` picks one. Write one `<td>` per column — a row whose cells disagree with its header comes back as `tableShape`.
157
+ - **"Last updated" lines are server-owned.** Every content write bumps them (the receipt says `lastUpdated`); never `find=` on one.
154
158
  - A replacement equal to its match writes nothing and reports `unchanged`.
155
159
  - A revision is taken before every content write; `diff(noteId)` shows what the last write changed, and `diff(since="today")` reviews every note you changed today in one call — run it before closing.
156
160
  - **Check the receipt.** `matched: false` means a new section was written — `available[]` lists real headings and `didYouMean` catches typos; `strict=true` refuses instead. `headingCount > 1` means only the first match was touched. `replacedSubsections[]` names nested headings a section replace took with it.
157
- - **`find=` matches stored HTML, not rendered text.** Pass tags literally; `outline()` gives the `raw` form of headings with inline markup. On a miss the hint names the cause and shows the stored text nearby.
161
+ - **`find=` prefers stored HTML.** Plain text also matches through inline formatting (`<strong>`, `<code>`, `<em>`, entities), so a short anchor rarely needs its markup; a span that opens or closes formatting is widened to keep it balanced. Block tags still match literally; `outline()` gives the `raw` form of headings. On a miss the hint names the cause and shows the stored text nearby.
158
162
  - A section replace swaps everything under the heading — use `find=` for anything smaller.
159
163
  - **Oversized note (past the read ceiling)?** It usually holds two subjects: `split(noteId, sections=[…], into="<title>")` moves whole sections to a new sibling and leaves a pointer.
160
164
  - Concurrent writers are serialised server-side; reads stay parallel.
@@ -178,7 +182,7 @@ Read `template(kind)` before your first write of a kind, then read a sibling.
178
182
 
179
183
  ## Verification — `consistency` and `claim`
180
184
 
181
- - **`consistency(pattern | subject)`** — does the brain agree with itself? A regex with one capture group (or a fact in prose) returns every asserting note grouped by value. **Run it after correcting any fact that could be recorded in more than one place.** `staleAfterDays=N` reports values held in only one note untouched N+ days. Records (sessions, diary, logs, dated thread entries) are skipped because they are never rewritten; `includeRecords=true` brings them back.
185
+ - **`consistency(pattern | subject)`** — does the brain agree with itself? A regex with one capture group (or a fact in prose) returns every asserting note grouped by value. **Run it after correcting any fact that could be recorded in more than one place.** `staleAfterDays=N` reports values held in only one note untouched N+ days. Records (sessions, diary, logs, dated thread entries) are skipped because they are never rewritten; `includeRecords=true` brings them back. Without a capture group the whole match is the value; matching ignores case unless `caseSensitive=true`; `patterns=[…]` checks several facts over one read of each note.
182
186
  - **`claim(...)`** — does the brain still agree with the world? Register `assertion` + `check`; verify with `claimId` + `holds` + `evidence` (evidence required); read with `claimId`; list with no arguments. BrainLLM never runs the check — you do. Register claims that would be expensive to discover had gone stale.
183
187
 
184
188
  ---
@@ -191,8 +195,9 @@ Lite runs inside `start`/`close` (thread aging, label checks). `maintain(deep=tr
191
195
  |---|---|
192
196
  | stale | revise, resolve, or `ack=[id]` if correct as it stands |
193
197
  | orphan / sink | `connect()` |
194
- | dated prose | a timeless note carrying dates — move state to Current State, history to a thread entry |
195
- | duplicate heading / unbalanced tags / incomplete | fix with `revise` |
198
+ | dated prose | a timeless note carrying dates — move state to Current State, history to a thread entry. A register (a backlog, a defects list) whose rows carry evidence dates takes `label(id, "register", value="")`, which exempts its tables but not its prose |
199
+ | duplicate heading / unbalanced tags / incomplete / table shape | fix with `revise` (a row: `within="tr"`) |
200
+ | record seal | a record of a sealed day was rewritten after `close()` sealed it — read its revisions and `diff(noteId)`; records are append-only, so restore it unless the change was intended |
196
201
  | oversized / section-edit-risk / size trajectory | `section=` reads, `split()` |
197
202
  | dated / long title | retitle, fold into the note it should have updated, or split |
198
203
  | stub | write it or `forget()` it |
@@ -208,7 +213,7 @@ Lite runs inside `start`/`close` (thread aging, label checks). `maintain(deep=tr
208
213
 
209
214
  ## Other tools
210
215
 
211
- `attach`/`detach` — raw artifacts on a note. `backup(name)` — milestone snapshot (close already backs up into a rolling weekday slot; every named backup is a full database copy kept on Trilium's volume, so name them sparingly). `bootstrap()` — create or refresh the structure. `template(kind)` — the canonical skeleton. **Full mode** (`BRAINLLM_MODE=full`) adds raw ETAPI tools with none of the server's guarantees. Read `references/fullmode.md` before any raw work.
216
+ `attach`/`detach` — raw artifacts on a note. `backup(name)` — milestone snapshot (close already backs up into a rolling weekday slot; every named backup is a full database copy kept on Trilium's volume, so name them sparingly; `health()` lists the named ones). `health()` — storage health: estimated database size, revision-heavy notes, named backups, with flags on growth. `bootstrap()` — create or refresh the structure. `template(kind)` — the canonical skeleton. **Full mode** (`BRAINLLM_MODE=full`) adds raw ETAPI tools with none of the server's guarantees. Read `references/fullmode.md` before any raw work.
212
217
 
213
218
  ## Quick-fix
214
219
 
@@ -28,11 +28,19 @@
28
28
  |---|---|
29
29
  | BrainLLM tools time out or return connection errors | The backend is unreachable, not the tool — every ETAPI call is bounded at 30 s with one retry on idempotent reads, so a hang surfaces as a failed tool call rather than a stuck session. Check the instance `TRILIUM_BASE_URL` names: a hosted deploy should answer at its own URL, a local desktop install needs the Trilium app running. Then retry. |
30
30
  | `start` → `uninitialized` | `bootstrap` |
31
+ | A result ends with `[BrainLLM: … cut at 140,000 to fit the client's limit]` | The result passed the server's size cap. Narrow it: `section=`, `block=`, `limit=`, or fewer `ids=` |
32
+ | `revise` refuses `dryRun=` | `dryRun` exists only for `revise(domain=)`. To preview one note's edit, read the note (or `outline()`) first; `diff(noteId)` shows what a write changed |
33
+ | `read(ids=[…])` returns `deferred[]` | The bodies passed ~80k characters together; read the deferred ids in a second call, or pass `text=true` |
34
+ | `health()` flags a note's revisions | Revision history is most of the database's weight. Splitting the note, or tightening Trilium's revision-snapshot interval, bounds it |
31
35
  | Deep maintenance flags the same items every session | Act on them, or `maintain(ack=[…])` the ones you reviewed and found correct |
32
- | `revise(find=)` returns `replaced: 0` | Read the hint — it names the cause. Escaped search string (`&lt;h3&gt;` against stored real tags): pass the tag literally. Spans a block boundary (`</h3>` then `<ol>`): anchor inside one element, or use `section=` with `mode="before"/"after"`. Otherwise it was already replaced, or the text genuinely differs — check with `inspect(noteId, find="<shorter substring>")` |
36
+ | `revise(find=)` returns `replaced: 0` | Read the hint — it names the cause. Escaped search string (`&lt;h3&gt;` against stored real tags): pass the tag literally. Spans a block boundary (`</h3>` then `<ol>`): anchor inside one element, or use `section=` with `mode="before"/"after"`. Ends in a closing tag (`…</strong></li>`): use `within="li"` with a short plain anchor. Otherwise it was already replaced, or the text genuinely differs — check with `inspect(noteId, find="<shorter substring>")` |
33
37
  | `revise(section=)` returns `matched: false` | No heading matched, so a NEW section was written — at the note's own level, with the note's real headings in `available[]`. Re-target from that list; `outline(noteId)` gives the same tree plus levels and occurrence indices before you write |
34
38
  | Two sections legitimately share a heading | `revise(section=…, occurrence=2)`. Repeating a closing heading under every category is the consistency rule working, not drift — `occurrence=` is how you reach the second one without rewriting the whole section |
35
39
  | Note body shows literal `&lt;p&gt;` text in Trilium | Entity-corrupted: `maintain(repair=[noteId])` unwinds one level in place |
40
+ | `malformed_identity` | The identification line needs three or more `·`-separated parts on one line (`"Claude Opus 5.5 · Claude Code · Interactive"`), no question marks, not an `Addendum —` marker. Nothing was written; retry with a clean `identity=` |
41
+ | `close()` asks for only `session`, `remarks`, `diary` | Nothing was written this session (light close). After any content write it asks for `addendum()` and `maintain()` too |
42
+ | `maintain(deep)` reports `record seal` | A session, diary or dated thread entry of a sealed day was rewritten after its close. Appends are fine; this is a change to earlier text. `diff(noteId)` and the note's revisions show it; restore unless intended. The finding leaves after seven sealed days |
43
+ | A regex `recall` returns nothing although the text is there | Trilium strips diacritic characters (`·`, accents) from note text before its pre-filter matches, so BrainLLM normalises the pattern the same way and the result says so; if it still misses, the 30-candidate cap may be the reason (named in the note) — narrow with `domain=`/`kinds=` or use `consistency()`, which scans exhaustively |
36
44
  | Unsure whether a note is still structurally sound after several edits | `outline(noteId)` — duplicate headings, unbalanced tags and size in one call, no body read. `maintain(deep=true)` runs the same check across the brain |
37
45
  | `recall` returns odd results | It already filters untyped notes; if it persists, `maintain(deep=true)` then retry |
38
46
  | Deep maintenance flags something unfamiliar | Every finding names its own fix in the flag text — read it rather than guessing |