brainllm 13.1.1 → 13.2.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/README.md +2 -1
- package/SECURITY.md +2 -2
- package/dist/index.js +183 -59
- package/manifest.json +1 -1
- package/package.json +3 -3
- package/server.json +2 -2
- package/skills/brainllm/SKILL.md +6 -5
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Self-hosted memory for LLMs in [TriliumNext Notes](https://github.com/TriliumNex
|
|
|
10
10
|
|
|
11
11
|
[**Site**](https://miisodev.github.io/BrainLLM/) · [Connect to Claude](https://miisodev.github.io/BrainLLM/connect.html) · [How it works](https://miisodev.github.io/BrainLLM/how-it-works.html) · [Docs](https://miisodev.github.io/BrainLLM/docs.html) · [Privacy](https://miisodev.github.io/BrainLLM/privacy.html)
|
|
12
12
|
|
|
13
|
-
[](https://github.com/miisodev/BrainLLM/releases)
|
|
14
14
|
[](https://github.com/miisodev/BrainLLM/actions/workflows/ci.yml)
|
|
15
15
|
[](./LICENSE)
|
|
16
16
|
[](https://bun.sh)
|
|
@@ -60,6 +60,7 @@ Placement, naming, labels, deduplication, relation bookkeeping, lifecycle aging,
|
|
|
60
60
|
- **A distinct connector identity** — hosted instances advertise BrainLLM and their own origin, with same-origin PNG/SVG icons, explicit page favicon links and a real multi-size ICO. The canonical mark is shared across the connector, consent pages and public assets.
|
|
61
61
|
- **Preview changes and measure coverage** — `revise(noteId, edits=[…], dryRun=true)` shows sequential match counts and sanitized before/after content with no revision, labels or gate writes. Revision failures stop content overwrites. `consistency()` reports read failures and scan coverage; case-insensitive comparisons retain spelling variants without false conflicts. Incomplete scans cannot establish absence or unanimity.
|
|
62
62
|
- **Registers close in one call** — `revise(find, within="tr", mode="remove", closure={thread, body})` removes a register row and records its closure in that thread's entry for the day, `closure={note, table?, row?}` moves a row into an explicitly selected register table; destination cells must match the table's shape. Destination-first ordering preserves recoverability, and `moves=[{find, row?}]` batches closures with preflight and per-row progress, and `append={table, row}` adds a row with no anchor to find; `within=` also accepts a row as a reader sees it (`"Phase 2 | Claude | Open"`), and a table row whose cells disagree with its header is flagged at write time and by `maintain(deep)`.
|
|
63
|
+
- **Bulk edits and lighter receipts** — `revise(find, within="tr", mode="remove", all=true)` removes every row an anchor sits in, in one write and previewable with `dryRun=true`; `remember(kind="sources", revision=[{source, from}])` renames a Sources key when a source moves instead of adding a second row; a dated-thread append returns `previousRun`, its author's previous block and run number; `read(ids, sizes=true)` plans a bulk read from sizes and section sizes alone; and `diff(since="today", summaryOnly=true)` lists what changed without the lines.
|
|
63
64
|
- **Records you can prove** — sessions, diary entries and dated thread entries are append-only, and now verifiably so: every `close()` seals its day's records into a digest chained to the previous day's, and `maintain(deep)` reports any sealed record that was rewritten afterwards. Appends stay legal; edits to history do not go unnoticed.
|
|
64
65
|
|
|
65
66
|
---
|
package/SECURITY.md
CHANGED
package/dist/index.js
CHANGED
|
@@ -20815,6 +20815,15 @@ class McpServer {
|
|
|
20815
20815
|
this._promptHandlersInitialized = false;
|
|
20816
20816
|
this.server = new Server(serverInfo, options);
|
|
20817
20817
|
this._maxToolInputElements = resolveMaxToolInputElements(options?.maxToolInputElements);
|
|
20818
|
+
if (options?.capabilities?.tools) {
|
|
20819
|
+
this.setToolRequestHandlers();
|
|
20820
|
+
}
|
|
20821
|
+
if (options?.capabilities?.resources) {
|
|
20822
|
+
this.setResourceRequestHandlers();
|
|
20823
|
+
}
|
|
20824
|
+
if (options?.capabilities?.prompts) {
|
|
20825
|
+
this.setPromptRequestHandlers();
|
|
20826
|
+
}
|
|
20818
20827
|
}
|
|
20819
20828
|
get experimental() {
|
|
20820
20829
|
if (!this._experimental) {
|
|
@@ -24522,6 +24531,24 @@ function leadingIdentityText(html) {
|
|
|
24522
24531
|
const m = /^(?:\s|<p>(?:\s| )*<\/p>)*<h3(?:\s[^>]*)?>([\s\S]*?)<\/h3>/i.exec(html);
|
|
24523
24532
|
return m ? decodeEntities(m[1].replace(/<[^>]+>/g, "")).trim() : null;
|
|
24524
24533
|
}
|
|
24534
|
+
function identityAuthor(text) {
|
|
24535
|
+
const parts = text.split("\xB7").map((p) => p.replace(/\s+/g, " ").trim().toLowerCase()).filter(Boolean);
|
|
24536
|
+
if (parts.length && /^run\s+\d+\b/.test(parts[parts.length - 1]))
|
|
24537
|
+
parts.pop();
|
|
24538
|
+
return parts.join(" \xB7 ");
|
|
24539
|
+
}
|
|
24540
|
+
function identityRun(text) {
|
|
24541
|
+
const m = /\u00B7\s*run\s+(\d+)\b[^\u00B7]*$/i.exec(text);
|
|
24542
|
+
return m ? Number(m[1]) : null;
|
|
24543
|
+
}
|
|
24544
|
+
function identityHeadings(html) {
|
|
24545
|
+
const out = [];
|
|
24546
|
+
const rx = /<h2>Addendum \u2014 \d{2}:\d{2}<\/h2>\s*<h3(?:\s[^>]*)?>([\s\S]*?)<\/h3>/gi;
|
|
24547
|
+
let m;
|
|
24548
|
+
while ((m = rx.exec(html)) !== null)
|
|
24549
|
+
out.push(decodeEntities(m[1].replace(/<[^>]+>/g, "")).trim());
|
|
24550
|
+
return out;
|
|
24551
|
+
}
|
|
24525
24552
|
function identityProblem(text) {
|
|
24526
24553
|
const line = text.trim();
|
|
24527
24554
|
if (/[\r\n]/.test(line))
|
|
@@ -24912,7 +24939,7 @@ function hasPlaceholderRow(html, heading) {
|
|
|
24912
24939
|
const rows = tableRows(html, heading);
|
|
24913
24940
|
return rows.length > 0 && rows.every((cells) => PLACEHOLDER_CELL.test(cells[0] ?? ""));
|
|
24914
24941
|
}
|
|
24915
|
-
function upsertTableRow(html, heading, key, cells) {
|
|
24942
|
+
function upsertTableRow(html, heading, key, cells, renameFrom) {
|
|
24916
24943
|
const noop = { html, matched: false, created: false };
|
|
24917
24944
|
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
24918
24945
|
let headingEnd = -1;
|
|
@@ -24943,7 +24970,7 @@ function upsertTableRow(html, heading, key, cells) {
|
|
|
24943
24970
|
const m = /<td[^>]*>([\s\S]*?)<\/td>/i.exec(row);
|
|
24944
24971
|
return m ? decodeEntities(m[1].replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim().toLowerCase() : "";
|
|
24945
24972
|
};
|
|
24946
|
-
const keyNorm = decodeEntities(key).replace(/\s+/g, " ").trim().toLowerCase();
|
|
24973
|
+
const keyNorm = decodeEntities(renameFrom ?? key).replace(/\s+/g, " ").trim().toLowerCase();
|
|
24947
24974
|
const rowRe = /<tr[^>]*>[\s\S]*?<\/tr>/gi;
|
|
24948
24975
|
let matchedRow = null;
|
|
24949
24976
|
let placeholderRow = null;
|
|
@@ -24958,6 +24985,8 @@ function upsertTableRow(html, heading, key, cells) {
|
|
|
24958
24985
|
if (!placeholderRow && (text === "" || text === "\u2014 none yet \u2014"))
|
|
24959
24986
|
placeholderRow = span;
|
|
24960
24987
|
}
|
|
24988
|
+
if (renameFrom !== undefined && !matchedRow)
|
|
24989
|
+
return noop;
|
|
24961
24990
|
const newRow = `<tr>${[key, ...cells].map((c) => `<td>${escapeHtml(c)}</td>`).join("")}</tr>`;
|
|
24962
24991
|
const target = matchedRow ?? placeholderRow;
|
|
24963
24992
|
const newTbody = target ? tbody.slice(0, target.start) + newRow + tbody.slice(target.end) : tbody + newRow;
|
|
@@ -26565,7 +26594,7 @@ function containingElement(html, tag, from, to) {
|
|
|
26565
26594
|
}
|
|
26566
26595
|
return best;
|
|
26567
26596
|
}
|
|
26568
|
-
function editWithin(html, hits, tag, action, body, nth) {
|
|
26597
|
+
function editWithin(html, hits, tag, action, body, nth, all = false) {
|
|
26569
26598
|
if (!hits.length)
|
|
26570
26599
|
return { ok: false, reason: "no_match", matches: 0 };
|
|
26571
26600
|
const seen = new Map;
|
|
@@ -26578,6 +26607,14 @@ function editWithin(html, hits, tag, action, body, nth) {
|
|
|
26578
26607
|
if (!elements.length)
|
|
26579
26608
|
return { ok: false, reason: "no_element", matches: hits.length };
|
|
26580
26609
|
const preview = (s) => html.slice(s.start, Math.min(s.end, s.start + 160));
|
|
26610
|
+
if (all) {
|
|
26611
|
+
const outer = elements.filter((e) => !elements.some((o) => o !== e && o.start <= e.start && o.end >= e.end));
|
|
26612
|
+
let out = html;
|
|
26613
|
+
for (const t of [...outer].reverse()) {
|
|
26614
|
+
out = action === "replace" ? out.slice(0, t.start) + body + out.slice(t.end) : action === "remove" ? out.slice(0, t.start) + out.slice(t.end) : action === "before" ? out.slice(0, t.start) + body + out.slice(t.start) : out.slice(0, t.end) + body + out.slice(t.end);
|
|
26615
|
+
}
|
|
26616
|
+
return { ok: true, html: out, element: html.slice(outer[0].start, outer[0].end), matches: outer.length, elements: outer.map((e) => html.slice(e.start, e.end)) };
|
|
26617
|
+
}
|
|
26581
26618
|
let target;
|
|
26582
26619
|
if (nth !== undefined)
|
|
26583
26620
|
target = elements[nth - 1];
|
|
@@ -26589,6 +26626,19 @@ function editWithin(html, hits, tag, action, body, nth) {
|
|
|
26589
26626
|
const out = action === "replace" ? html.slice(0, target.start) + body + html.slice(target.end) : action === "remove" ? html.slice(0, target.start) + html.slice(target.end) : action === "before" ? html.slice(0, target.start) + body + html.slice(target.start) : html.slice(0, target.end) + body + html.slice(target.end);
|
|
26590
26627
|
return { ok: true, html: out, element: el, matches: elements.length };
|
|
26591
26628
|
}
|
|
26629
|
+
function pipeParagraphHint(html, hits, tag) {
|
|
26630
|
+
if (tag !== "tr" && tag !== "td" && tag !== "th")
|
|
26631
|
+
return null;
|
|
26632
|
+
for (const h of hits) {
|
|
26633
|
+
const p = containingElement(html, "p", h.start, h.start + h.length);
|
|
26634
|
+
if (!p)
|
|
26635
|
+
continue;
|
|
26636
|
+
const text = html.slice(p.start, p.end).replace(/<[^>]+>/g, "");
|
|
26637
|
+
if ((text.match(/\|/g) ?? []).length >= 2)
|
|
26638
|
+
return `The anchor sits in a paragraph written with "|" separators, which is text, not a table row. Use within="p" to act on that line (mode="after" adds a sibling line), or convert the roster to a real table.`;
|
|
26639
|
+
}
|
|
26640
|
+
return null;
|
|
26641
|
+
}
|
|
26592
26642
|
|
|
26593
26643
|
// src/tools-surface.ts
|
|
26594
26644
|
var txt = (obj) => ({
|
|
@@ -26976,6 +27026,8 @@ function applyFindEdit(source, needle, replacement, index) {
|
|
|
26976
27026
|
return inline.length ? spans(inline, "inline-tolerant") : null;
|
|
26977
27027
|
}
|
|
26978
27028
|
var CHANGES_CAP = 25;
|
|
27029
|
+
var SOURCE_KEYS_ECHO = 30;
|
|
27030
|
+
var PREVIOUS_RUN_DAYS = 7;
|
|
26979
27031
|
var boundPreview = (s, max = 1600) => s.length <= max ? s : `${s.slice(0, max)}\u2026 (${s.length - max} more characters)`;
|
|
26980
27032
|
function capChanges(changes, cap = CHANGES_CAP) {
|
|
26981
27033
|
if (changes.length <= cap)
|
|
@@ -27234,6 +27286,7 @@ function registerTools(server, trilium, brainRef) {
|
|
|
27234
27286
|
}
|
|
27235
27287
|
async function appendThreadEntry(threadId, block, d) {
|
|
27236
27288
|
const found = await trilium.searchNotes(`#noteType=threadEntry #created='${d}'`, { ancestorNoteId: threadId, fastSearch: true, limit: 1 });
|
|
27289
|
+
const incomingIdentity = leadingIdentityText(block);
|
|
27237
27290
|
const time = localNowTime();
|
|
27238
27291
|
const marker = `Addendum \u2014 ${time}`;
|
|
27239
27292
|
if (found.results[0]) {
|
|
@@ -27247,7 +27300,8 @@ function registerTools(server, trilium, brainRef) {
|
|
|
27247
27300
|
await trilium.createRevision(noteId);
|
|
27248
27301
|
await trilium.updateNoteContent(noteId, safeAppend(current, `<h2>${marker}</h2>`, block));
|
|
27249
27302
|
await trilium.updateLabelValue(noteId, "updated", d);
|
|
27250
|
-
|
|
27303
|
+
const previousRun = incomingIdentity ? await previousSameAuthor(threadId, incomingIdentity, current, d) : undefined;
|
|
27304
|
+
return { noteId, action: "appended", marker, ...previousRun ? { previousRun } : {} };
|
|
27251
27305
|
}
|
|
27252
27306
|
const created = await trilium.createNote(threadId, `[${d}]`, contentFor("threadEntry", { date: d, body: `<h2>${marker}</h2>
|
|
27253
27307
|
${block}` }));
|
|
@@ -27255,7 +27309,33 @@ ${block}` }));
|
|
|
27255
27309
|
await trilium.addLabel(noteId, "noteType", "threadEntry");
|
|
27256
27310
|
await trilium.addLabel(noteId, "created", d);
|
|
27257
27311
|
await ensureIcon(trilium, noteId);
|
|
27258
|
-
|
|
27312
|
+
const previousRun = incomingIdentity ? await previousSameAuthor(threadId, incomingIdentity, "", d) : undefined;
|
|
27313
|
+
return { noteId, action: "created", marker, ...previousRun ? { previousRun } : {} };
|
|
27314
|
+
}
|
|
27315
|
+
async function previousSameAuthor(threadId, identity, todayBefore, d) {
|
|
27316
|
+
const author = identityAuthor(identity);
|
|
27317
|
+
const pick = (html, date) => {
|
|
27318
|
+
const heading = identityHeadings(html).reverse().find((h) => identityAuthor(h) === author);
|
|
27319
|
+
if (!heading)
|
|
27320
|
+
return;
|
|
27321
|
+
const run = identityRun(heading);
|
|
27322
|
+
return { heading, date, ...run !== null ? { run } : {} };
|
|
27323
|
+
};
|
|
27324
|
+
const today = pick(todayBefore, d);
|
|
27325
|
+
if (today || identityRun(identity) === null)
|
|
27326
|
+
return today;
|
|
27327
|
+
try {
|
|
27328
|
+
const entries = await trilium.searchNotes("#noteType=threadEntry", { ancestorNoteId: threadId, fastSearch: true, limit: 500 });
|
|
27329
|
+
const earlier = entries.results.filter((n) => n.parentNoteIds.includes(threadId)).map((n) => ({ n, date: ownedLabel(n, "created") ?? "" })).filter((e) => e.date && e.date < d).sort((a, b) => b.date.localeCompare(a.date)).slice(0, PREVIOUS_RUN_DAYS);
|
|
27330
|
+
for (const e of earlier) {
|
|
27331
|
+
const found = pick(await trilium.getNoteContent(e.n.noteId), e.date);
|
|
27332
|
+
if (found)
|
|
27333
|
+
return found;
|
|
27334
|
+
}
|
|
27335
|
+
} catch {
|
|
27336
|
+
return;
|
|
27337
|
+
}
|
|
27338
|
+
return;
|
|
27259
27339
|
}
|
|
27260
27340
|
function collectionAppendRefusal(book) {
|
|
27261
27341
|
return err("collection_thread", `"${book.title}" is a collection thread \u2014 it holds one titled entry per item, not dated appends.`, `Add an entry: remember(kind="threadEntry", thread="${book.noteId}", title, body). Change one: revise(<entry id>, section=/find=/mode=\u2026) \u2014 memory("${book.noteId}") lists them.`);
|
|
@@ -27826,7 +27906,7 @@ ${html}` }));
|
|
|
27826
27906
|
});
|
|
27827
27907
|
server.tool("remember", `Store something the moment it matters; the server owns placement, labels and dedup.
|
|
27828
27908
|
|
|
27829
|
-
Kinds: biography | goals | preferences | responsibilities | protocols (singletons, upsert) \xB7 thread (goal= required to create; shape="collection" for a titled-entry thread; appends need identity=) \xB7 threadEntry (a titled entry in a collection thread: thread= + title) \xB7 user (knowledge about the user) \xB7 information (domain= + title) \xB7 sources (domain=; revision=[\u2026] upserts rows; dryRun=true previews rows and near-duplicate keys without writing).
|
|
27909
|
+
Kinds: biography | goals | preferences | responsibilities | protocols (singletons, upsert) \xB7 thread (goal= required to create; shape="collection" for a titled-entry thread; appends need identity=) \xB7 threadEntry (a titled entry in a collection thread: thread= + title) \xB7 user (knowledge about the user) \xB7 information (domain= + title) \xB7 sources (domain=; revision=[\u2026] upserts rows, revision[].from= renames one when a source moves; dryRun=true previews rows, body placement and near-duplicate keys without writing).
|
|
27830
27910
|
|
|
27831
27911
|
Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopting an existing note, and check action= on the receipt. connect=[{relation, toNoteId}] wires relations in the same call. diary/session/log/claim/domain have dedicated paths.`, {
|
|
27832
27912
|
kind: _enum(Kinds).describe("What kind of memory this is"),
|
|
@@ -27840,8 +27920,9 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
27840
27920
|
revision: array(object2({
|
|
27841
27921
|
source: string2().describe("Must exactly match how the source is introduced in the Sources list \u2014 this is the upsert key"),
|
|
27842
27922
|
marker: string2().describe('"\u2747\uFE0F" (discovered/credible) or "\u2705" (used)'),
|
|
27843
|
-
date: string2().optional().describe("ISO date (default: today)")
|
|
27844
|
-
|
|
27923
|
+
date: string2().optional().describe("ISO date (default: today)"),
|
|
27924
|
+
from: string2().optional().describe("Rename: the row's current key. The row keyed from= is rekeyed to source= (marker and date updated) instead of a new row being added; refused when source= already has a row")
|
|
27925
|
+
})).optional().describe("kind=sources: upsert Revision rows by source name; from= renames a row when a source moves"),
|
|
27845
27926
|
topics: array(string2()).optional().describe("Topic tags \u2014 slugged server-side"),
|
|
27846
27927
|
supersedes: string2().optional().describe("noteId this replaces \u2014 old note is archived and wired supersedes"),
|
|
27847
27928
|
mandate: boolean2().optional().describe("kind=information: flag the note as a standing brief a session must follow (#mandate)"),
|
|
@@ -27917,6 +27998,39 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
27917
27998
|
didYouMeanNote: `${pairs.map((p) => `"${p.added}" \u2192 "${p.near.key}"`).join("; ")}. New Revision row(s) sit beside an existing source whose key is close \u2014 if one was meant to update that source, its key must match it exactly.`
|
|
27918
27999
|
};
|
|
27919
28000
|
};
|
|
28001
|
+
const planRevision = (content) => {
|
|
28002
|
+
let html = content;
|
|
28003
|
+
const changes = [];
|
|
28004
|
+
const added = [];
|
|
28005
|
+
const refused = [];
|
|
28006
|
+
const norm = (k) => k.replace(/\s+/g, " ").trim().toLowerCase();
|
|
28007
|
+
for (const row of revision ?? []) {
|
|
28008
|
+
const keys = tableRows(html, "Revision").map((cells) => cells[0] ?? "");
|
|
28009
|
+
if (row.from !== undefined && norm(row.from) !== norm(row.source)) {
|
|
28010
|
+
if (keys.some((k) => norm(k) === norm(row.source))) {
|
|
28011
|
+
refused.push(`rename "${row.from}" \u2192 "${row.source}": "${row.source}" already has a row \u2014 update that one, or remove one of the two rows first`);
|
|
28012
|
+
continue;
|
|
28013
|
+
}
|
|
28014
|
+
const result = upsertTableRow(html, "Revision", row.source, [row.marker, row.date ?? d], row.from);
|
|
28015
|
+
if (!result.matched) {
|
|
28016
|
+
refused.push(`rename "${row.from}" \u2192 "${row.source}": no Revision row is keyed "${row.from}"`);
|
|
28017
|
+
continue;
|
|
28018
|
+
}
|
|
28019
|
+
html = result.html;
|
|
28020
|
+
changes.push(`renamed: ${row.from} \u2192 ${row.source}`);
|
|
28021
|
+
continue;
|
|
28022
|
+
}
|
|
28023
|
+
const result = upsertTableRow(html, "Revision", row.source, [row.marker, row.date ?? d]);
|
|
28024
|
+
if (result.matched || result.created) {
|
|
28025
|
+
html = result.html;
|
|
28026
|
+
changes.push(`${result.matched ? "updated" : "added"}: ${row.source}`);
|
|
28027
|
+
if (!result.matched)
|
|
28028
|
+
added.push(row.source);
|
|
28029
|
+
}
|
|
28030
|
+
}
|
|
28031
|
+
return { html, changes, added, refused };
|
|
28032
|
+
};
|
|
28033
|
+
const isLooseProse = (h) => !/<(?:ul|ol|li|h[34]|table)\b/i.test(h);
|
|
27920
28034
|
if (isSingleton(kind)) {
|
|
27921
28035
|
const id = kindHome(b(), kind);
|
|
27922
28036
|
if (!id)
|
|
@@ -27945,25 +28059,18 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
27945
28059
|
else {
|
|
27946
28060
|
const content = await trilium.getNoteContent(sidPreview);
|
|
27947
28061
|
const existingKeys = tableRows(content, "Revision").map((cells) => cells[0]).filter(Boolean);
|
|
27948
|
-
const
|
|
27949
|
-
|
|
27950
|
-
|
|
27951
|
-
|
|
27952
|
-
|
|
27953
|
-
if (result.matched || result.created) {
|
|
27954
|
-
sim = result.html;
|
|
27955
|
-
previewChanges.push(`${result.matched ? "would update" : "would add"}: ${row.source}`);
|
|
27956
|
-
if (!result.matched)
|
|
27957
|
-
previewAdded.push(row.source);
|
|
27958
|
-
}
|
|
27959
|
-
}
|
|
27960
|
-
if (previewChanges.length)
|
|
27961
|
-
preview.revision = previewChanges;
|
|
28062
|
+
const plan = planRevision(content);
|
|
28063
|
+
if (plan.changes.length)
|
|
28064
|
+
preview.revision = plan.changes.map((c) => `would ${c.replace(/^(\w+):/, (_, v) => `${{ updated: "update", added: "add", renamed: "rename" }[v] ?? v}:`)}`);
|
|
28065
|
+
if (plan.refused.length)
|
|
28066
|
+
preview.refused = plan.refused;
|
|
27962
28067
|
if (html && toText(html, 50) && !content.includes(html)) {
|
|
27963
28068
|
const merged = mergeUnderSection(content, html, "Sources");
|
|
27964
28069
|
preview.body = `would merge into the Sources section${merged.mergedInto.length ? `: ${merged.mergedInto.join(", ")}` : ""}${merged.appended.length ? `; new group(s): ${merged.appended.join(", ")}` : ""}`;
|
|
28070
|
+
if (isLooseProse(html))
|
|
28071
|
+
preview.placementHint = "This body has no list or group markup, so it would land as a loose block under Sources. Pass it as list items (or under an existing h3 group) to join the list structurally.";
|
|
27965
28072
|
}
|
|
27966
|
-
const hints = sourceKeyHints(
|
|
28073
|
+
const hints = sourceKeyHints(plan.added, existingKeys);
|
|
27967
28074
|
if (hints) {
|
|
27968
28075
|
preview.didYouMean = hints.didYouMean;
|
|
27969
28076
|
preview.didYouMeanNote = hints.didYouMeanNote;
|
|
@@ -27996,26 +28103,20 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
27996
28103
|
wrote = true;
|
|
27997
28104
|
}
|
|
27998
28105
|
}
|
|
27999
|
-
|
|
28000
|
-
|
|
28106
|
+
let revisionChanges = [];
|
|
28107
|
+
let addedKeys = [];
|
|
28108
|
+
let refusedRows = [];
|
|
28001
28109
|
let existingKeys = [];
|
|
28002
28110
|
if (sid && revision?.length) {
|
|
28003
|
-
|
|
28004
|
-
existingKeys = tableRows(
|
|
28005
|
-
|
|
28006
|
-
|
|
28007
|
-
|
|
28008
|
-
|
|
28009
|
-
|
|
28010
|
-
changed = true;
|
|
28011
|
-
revisionChanges.push(`${result.matched ? "updated" : "added"}: ${row.source}`);
|
|
28012
|
-
if (!result.matched)
|
|
28013
|
-
addedKeys.push(row.source);
|
|
28014
|
-
}
|
|
28015
|
-
}
|
|
28016
|
-
if (changed) {
|
|
28111
|
+
const before = await trilium.getNoteContent(sid);
|
|
28112
|
+
existingKeys = tableRows(before, "Revision").map((cells) => cells[0]).filter(Boolean);
|
|
28113
|
+
const plan = planRevision(before);
|
|
28114
|
+
revisionChanges = plan.changes;
|
|
28115
|
+
addedKeys = plan.added;
|
|
28116
|
+
refusedRows = plan.refused;
|
|
28117
|
+
if (plan.changes.length) {
|
|
28017
28118
|
await trilium.createRevision(sid);
|
|
28018
|
-
const stamped = bumpLastUpdated(
|
|
28119
|
+
const stamped = bumpLastUpdated(plan.html, d);
|
|
28019
28120
|
await trilium.updateNoteContent(sid, stamped.html);
|
|
28020
28121
|
await trilium.updateLabelValue(sid, "updated", d);
|
|
28021
28122
|
wrote = true;
|
|
@@ -28029,7 +28130,7 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
28029
28130
|
const revisionKeys = tableRows(finalHtml, "Revision").map((cells) => cells[0]).filter(Boolean);
|
|
28030
28131
|
const placeholderLeft = hasPlaceholderRow(finalHtml, "Revision");
|
|
28031
28132
|
const bodyWritten = !!html && !!toText(html, 50);
|
|
28032
|
-
const looseProse = bodyWritten && !createdDomain &&
|
|
28133
|
+
const looseProse = bodyWritten && !createdDomain && isLooseProse(html);
|
|
28033
28134
|
const keyHints = sourceKeyHints(addedKeys, existingKeys);
|
|
28034
28135
|
return txt3({
|
|
28035
28136
|
action: wrote ? "maintained" : "already_written",
|
|
@@ -28039,9 +28140,10 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
28039
28140
|
location: locationLabel(kind, domainTitle),
|
|
28040
28141
|
...createdDomain ? { createdDomain: domainTitle } : {},
|
|
28041
28142
|
...revisionChanges.length ? { revision: revisionChanges } : {},
|
|
28143
|
+
...refusedRows.length ? { refused: refusedRows } : {},
|
|
28042
28144
|
...sourceMerge,
|
|
28043
28145
|
...revisionKeys.length ? { revisionRows: revisionKeys.length } : {},
|
|
28044
|
-
...
|
|
28146
|
+
...addedKeys.length && revisionKeys.length && revisionKeys.length <= SOURCE_KEYS_ECHO ? { revisionKeys, revisionKeysNote: "A new Revision row was added. If it was meant to update an existing source, its key must match one of these exactly." } : addedKeys.length && revisionKeys.length ? { revisionKeysNote: `A new Revision row was added beside ${revisionKeys.length - addedKeys.length} existing keys (too many to echo). ${keyHints ? "didYouMean names the nearest existing key." : "No existing key is close to it."} Preview a new key with dryRun=true, and rename a moved source with revision=[{source, from}].` } : {},
|
|
28045
28147
|
...keyHints ? { didYouMean: keyHints.didYouMean, didYouMeanNote: keyHints.didYouMeanNote } : {},
|
|
28046
28148
|
...placeholderLeft ? { structureHint: "The Revision table still holds only its placeholder row. Every source marked \u2705 was verified by someone \u2014 record that with revision=[{source, marker, date}] so the table says so too; marker dates live there, never inline." } : {},
|
|
28047
28149
|
...looseProse ? { placementHint: "The body was appended to the Sources section as a loose block, which is rarely what a maintained source list wants. Fold it into the existing entries with revise(noteId, find=\u2026) \u2014 or pass list markup so it joins the list structurally." } : {},
|
|
@@ -28163,7 +28265,7 @@ ${html}` : html;
|
|
|
28163
28265
|
const connected = await wireRequested(existing.noteId);
|
|
28164
28266
|
const iconSet = await applyIcon(existing.noteId, icon);
|
|
28165
28267
|
const relations = relationSnippet(existing);
|
|
28166
|
-
return txt3({ action: "updated", noteId: existing.noteId, entryId: entry.noteId, entryAction: entry.action, ...entry.marker ? { entryMarker: entry.marker } : {}, ...runHeading ? { runHeading } : {}, entry: `today's [${d}] entry in this thread; revise(entryId) edits it`, kind, title: existing.title, ...connected.length ? { connected } : {}, ...iconSet ? { icon: iconSet } : {}, ...relations ? { relations } : {}, ...sanitizeWarnings.length ? { sanitized: sanitizeWarnings } : {} });
|
|
28268
|
+
return txt3({ action: "updated", noteId: existing.noteId, entryId: entry.noteId, entryAction: entry.action, ...entry.marker ? { entryMarker: entry.marker } : {}, ...runHeading ? { runHeading } : {}, ...entry.previousRun ? { previousRun: entry.previousRun } : {}, entry: `today's [${d}] entry in this thread; revise(entryId) edits it`, kind, title: existing.title, ...connected.length ? { connected } : {}, ...iconSet ? { icon: iconSet } : {}, ...relations ? { relations } : {}, ...sanitizeWarnings.length ? { sanitized: sanitizeWarnings } : {} });
|
|
28167
28269
|
}
|
|
28168
28270
|
const current = await trilium.getNoteContent(existing.noteId);
|
|
28169
28271
|
if (isDuplicateAppend(current, block))
|
|
@@ -28560,12 +28662,15 @@ ${block}`), d);
|
|
|
28560
28662
|
});
|
|
28561
28663
|
});
|
|
28562
28664
|
const READ_BUDGET = 80000;
|
|
28563
|
-
server.tool("read", `Several note bodies in one round trip: ids=[\u2026] (up to 10) \u2192 {notes:[{id, title, kind, chars, content, relations}]}. Every entry carries chars, the size of that note's body in characters, so follow-up reads can be planned from this response alone. Bodies are returned in order until about 80k characters; the rest come back under deferred[] with their sizes, to read in a second call. section="<heading>" reads one section of each note instead of its whole body (h2\u2192h3\u2192h4 by heading text, occurrence= among repeats) \u2014 the cheap read of a huge note; a section holding a table also reports tableRows, its row count. text=true returns readable plain text with the markup removed (headings, list items and table rows kept), smaller and easier to read \u2014 right for orienting, not for building a find= anchor. For one note prefer the kinded read.`, {
|
|
28665
|
+
server.tool("read", `Several note bodies in one round trip: ids=[\u2026] (up to 10) \u2192 {notes:[{id, title, kind, chars, content, relations}]}. Every entry carries chars, the size of that note's body in characters, so follow-up reads can be planned from this response alone. Bodies are returned in order until about 80k characters; the rest come back under deferred[] with their sizes, to read in a second call. section="<heading>" reads one section of each note instead of its whole body (h2\u2192h3\u2192h4 by heading text, occurrence= among repeats) \u2014 the cheap read of a huge note; a section holding a table also reports tableRows, its row count. text=true returns readable plain text with the markup removed (headings, list items and table rows kept), smaller and easier to read \u2014 right for orienting, not for building a find= anchor. sizes=true returns no bodies at all: each note's chars and its h2\u2013h4 headings with their section sizes, so a bulk read can be planned before it is made. For one note prefer the kinded read.`, {
|
|
28564
28666
|
ids: array(string2()).min(1).max(10).describe("Note ids to read \u2014 one body per id, one round trip (cap 10)"),
|
|
28565
28667
|
section: string2().optional().describe("Read only this heading's section of each note (h2/h3/h4, same matching as the singletons' section=) instead of each whole body \u2014 the cheap read of a huge note; a miss returns the note's real headings under available[]"),
|
|
28566
28668
|
occurrence: number2().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)"),
|
|
28567
|
-
text: boolean2().optional().describe("Return readable plain text instead of stored HTML (default false; with section=, the section only)")
|
|
28568
|
-
|
|
28669
|
+
text: boolean2().optional().describe("Return readable plain text instead of stored HTML (default false; with section=, the section only)"),
|
|
28670
|
+
sizes: boolean2().optional().describe("Return sizes only \u2014 chars and each h2\u2013h4 heading with its section's size \u2014 and no bodies: plan a read that would otherwise overflow")
|
|
28671
|
+
}, async ({ ids, text, section, occurrence, sizes }) => {
|
|
28672
|
+
if (sizes && (section !== undefined || text !== undefined))
|
|
28673
|
+
return err("conflicting_params", "sizes=true returns no bodies, so section= and text= do not apply.", "Read the sizes first, then read(ids, section=\u2026) or read(ids, text=true) for the bodies you need.");
|
|
28569
28674
|
const fetched = await Promise.all(ids.map(async (id) => {
|
|
28570
28675
|
try {
|
|
28571
28676
|
const note = await trilium.getNote(id);
|
|
@@ -28573,6 +28678,10 @@ ${block}`), d);
|
|
|
28573
28678
|
const relations = relationSnippet(note);
|
|
28574
28679
|
const raw = typeof contentResult === "string" ? contentResult : contentResult.content;
|
|
28575
28680
|
const identity = { id, title: note.title, kind: ownedLabel(note, "noteType") ?? undefined, chars: raw.length };
|
|
28681
|
+
if (sizes) {
|
|
28682
|
+
const flat = typeof contentResult === "string" ? headingOutline(raw).filter((h) => !/^Last updated\b/i.test(h.text)).map((h) => ({ heading: h.text, level: h.level, chars: getSection(raw, h.text, h.occurrence).content.length })) : [];
|
|
28683
|
+
return { ...identity, sizesOnly: true, ...flat.length ? { headings: flat.slice(0, 60) } : {}, ...flat.length > 60 ? { headingsShown: 60 } : {} };
|
|
28684
|
+
}
|
|
28576
28685
|
if (section && typeof contentResult !== "string") {
|
|
28577
28686
|
return { ...identity, content: "", section, matched: false, hint: "Binary content cannot be sectioned as text; read it without section=." };
|
|
28578
28687
|
}
|
|
@@ -28701,7 +28810,7 @@ ${block}`), d);
|
|
|
28701
28810
|
};
|
|
28702
28811
|
server.tool("revise", `Edit a note by id. A revision is taken first; "Last updated" lines are bumped on content writes.
|
|
28703
28812
|
|
|
28704
|
-
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 the matched span; it is not an insertion anchor. Use within= with mode=before/after for element insertion, or section= with mode=prepend/before/after for section insertion. It replaces every occurrence (nth= for one); edits=[{find, body}] applies several in one write. find= + within="tr" (or li, p, td, \u2026) acts on the element containing a short anchor: replace it, insert a row before/after it, or remove it, so a table row never needs its full stored markup. within= names the element acted on: within="tr" replaces the whole row (body is a full <tr>\u2026</tr>), within="td" one cell (body is a <td>\u2026</td>), and a plain find= inside a cell replaces only that text. dryRun=true with find=, edits=, within= or moves= previews the change first \u2014 the element's full visible text and byte size, and for remove/replace the before/after shape \u2014 with nothing written and no revision taken. Keep anchors short; quotes match in any form, and within= also matches an element by its visible text ("Phase 2 | Claude | Open" for a row). append={table, row} adds one row to the table under that heading with no anchor to locate first \u2014 pipes are cell boundaries, a lone placeholder row is replaced, and append= composes with nothing else. closure= with within="tr" and mode="remove" closes a register row in one call: {thread, body} removes the row and appends the closure (quoting it) to that thread's entry for today (needs identity=); {note, table?, row?} moves a row into another register. Destination cells must match exactly; row= supplies explicit plain-text cells. Ambiguous tables are refused. Destination content is written before source removal, and same-note moves use one write. Partial failures report recovery instructions; exact destination rows and identical thread blocks are deduplicated on retry. moves=[{find, nth?, row?}] batches closures to one target, preflights every row, and stops with per-row progress on failure. "Last updated" lines are server-owned: every content write bumps them, so never find= on one. A replacement equal to its match writes nothing and reports unchanged. title= composes with every mode; retitling a domain book cascades its #domain slug.
|
|
28813
|
+
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 the matched span; it is not an insertion anchor. Use within= with mode=before/after for element insertion, or section= with mode=prepend/before/after for section insertion. It replaces every occurrence (nth= for one); edits=[{find, body}] applies several in one write. find= + within="tr" (or li, p, td, \u2026) acts on the element containing a short anchor: replace it, insert a row before/after it, or remove it, so a table row never needs its full stored markup; all=true acts on every element the anchor sits in (preview with dryRun=true). within= names the element acted on: within="tr" replaces the whole row (body is a full <tr>\u2026</tr>), within="td" one cell (body is a <td>\u2026</td>), and a plain find= inside a cell replaces only that text. dryRun=true with find=, edits=, within= or moves= previews the change first \u2014 the element's full visible text and byte size, and for remove/replace the before/after shape \u2014 with nothing written and no revision taken. Keep anchors short; quotes match in any form, and within= also matches an element by its visible text ("Phase 2 | Claude | Open" for a row). append={table, row} adds one row to the table under that heading with no anchor to locate first \u2014 pipes are cell boundaries, a lone placeholder row is replaced, and append= composes with nothing else. closure= with within="tr" and mode="remove" closes a register row in one call: {thread, body} removes the row and appends the closure (quoting it) to that thread's entry for today (needs identity=); {note, table?, row?} moves a row into another register. Destination cells must match exactly; row= supplies explicit plain-text cells. Ambiguous tables are refused. Destination content is written before source removal, and same-note moves use one write. Partial failures report recovery instructions; exact destination rows and identical thread blocks are deduplicated on retry. moves=[{find, nth?, row?}] batches closures to one target, preflights every row, and stops with per-row progress on failure. "Last updated" lines are server-owned: every content write bumps them, so never find= on one. A replacement equal to its match writes nothing and reports unchanged. title= composes with every mode; retitling a domain book cascades its #domain slug.
|
|
28705
28814
|
|
|
28706
28815
|
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. A find= or within= anchor that matches nothing always names the cause in hint=, and a miss shows the stored text nearby when part of the anchor is still there (matchedUpTo= / storedNearby=).`, {
|
|
28707
28816
|
noteId: string2().optional().describe("Note to update (omit only with domain=)"),
|
|
@@ -28716,6 +28825,7 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
28716
28825
|
find: string2().optional().describe("Exact raw string to replace throughout the body with body= \u2014 targeted surgery without a read+full-replace. Takes precedence over section/mode."),
|
|
28717
28826
|
nth: number2().int().positive().optional().describe("find=: replace only the Nth occurrence, 1-based (default: all of them). With within=: the Nth containing element"),
|
|
28718
28827
|
within: _enum(WITHIN_TAGS).optional().describe('With find=: act on the <tag> element that contains the find text (a short unique anchor) \u2014 mode="replace" (default) swaps it for body=, "before"/"after" insert body= as a sibling (add a table row), "remove" deletes it. dryRun=true previews it first'),
|
|
28828
|
+
all: boolean2().optional().describe("With within=: act on EVERY element containing the anchor instead of refusing an anchor found in several (e.g. remove every Revision row naming a retired source). Preview with dryRun=true first; does not combine with nth= or closure="),
|
|
28719
28829
|
closure: object2({
|
|
28720
28830
|
thread: string2().optional().describe('Thread variant: the dated thread to record the closure in: its id or title (e.g. "Escalations") \u2014 together with body='),
|
|
28721
28831
|
body: string2().optional().describe("Thread variant: the closure prose: what settled it, who ruled, and where its durable substance went"),
|
|
@@ -28740,9 +28850,9 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
28740
28850
|
identity: string2().optional().describe('Appending to a dated thread: the identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" (required)'),
|
|
28741
28851
|
icon: string2().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name; normalized server-side'),
|
|
28742
28852
|
date: string2().optional().describe("ISO date (default: today)")
|
|
28743
|
-
}, async function reviseNote({ noteId: noteIdArg, domain, dryRun, body, title, section, occurrence, mode, find, nth, within, closure, append, edits, moves, identity, icon, date, strict }) {
|
|
28853
|
+
}, async function reviseNote({ noteId: noteIdArg, domain, dryRun, body, title, section, occurrence, mode, find, nth, within, all, closure, append, edits, moves, identity, icon, date, strict }) {
|
|
28744
28854
|
if (moves) {
|
|
28745
|
-
if (!noteIdArg || domain || !closure || within !== "tr" || mode !== "remove" || find !== undefined || edits !== undefined || append || section || body !== undefined || title || icon || nth !== undefined)
|
|
28855
|
+
if (!noteIdArg || domain || !closure || within !== "tr" || mode !== "remove" || find !== undefined || edits !== undefined || append || section || body !== undefined || title || icon || nth !== undefined || all !== undefined)
|
|
28746
28856
|
return err("conflicting_params", "moves= needs noteId, within=tr, mode=remove and closure; it cannot combine with other edit operations.", "Supply one unique find anchor and optional destination row per move.");
|
|
28747
28857
|
const results = [];
|
|
28748
28858
|
const elements = new Set;
|
|
@@ -28765,7 +28875,8 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
28765
28875
|
edits: undefined,
|
|
28766
28876
|
moves: undefined,
|
|
28767
28877
|
icon: undefined,
|
|
28768
|
-
strict: undefined
|
|
28878
|
+
strict: undefined,
|
|
28879
|
+
all: undefined
|
|
28769
28880
|
});
|
|
28770
28881
|
for (const move of moves) {
|
|
28771
28882
|
const r = JSON.parse((await one(move, true)).content[0].text);
|
|
@@ -28822,6 +28933,10 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
28822
28933
|
const noteId = noteIdArg;
|
|
28823
28934
|
if (isContainer(b(), noteId))
|
|
28824
28935
|
return err("protected_note", `Note ${noteId} is a container \u2014 its content cannot be edited directly.`, "Use remember() to write to singletons, or specify a content note id.");
|
|
28936
|
+
if (all !== undefined && !within)
|
|
28937
|
+
return err("conflicting_params", "all= applies to within=; nothing was written.", 'e.g. revise(noteId, find="<anchor>", within="tr", mode="remove", all=true, dryRun=true). A plain find= already replaces every occurrence.');
|
|
28938
|
+
if (all && (nth !== undefined || closure || moves))
|
|
28939
|
+
return err("conflicting_params", "all=true acts on every matching element, so it does not combine with nth=, closure= or moves=; nothing was written.", "Drop all= to target one element, or drop nth=/closure= to act on all of them.");
|
|
28825
28940
|
if (within && find === undefined)
|
|
28826
28941
|
return err("missing_param", "within= needs find= as its anchor.", 'Pass a short text unique to the element, e.g. revise(noteId, find="Phase 2", within="tr", body="<tr>\u2026</tr>").');
|
|
28827
28942
|
if (append) {
|
|
@@ -28941,9 +29056,9 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
28941
29056
|
const inlineHits = exact.length || tolerantHits.length ? [] : inlineTolerantSpans(current, find);
|
|
28942
29057
|
const visibleHits = exact.length || tolerantHits.length || inlineHits.length ? [] : visibleTextSpans(current, find, within);
|
|
28943
29058
|
const hits = exact.length ? exact : tolerantHits.length ? tolerantHits : inlineHits.length ? inlineHits : visibleHits;
|
|
28944
|
-
const res = editWithin(current, hits, within, action, body ?? "", nth);
|
|
29059
|
+
const res = editWithin(current, hits, within, action, body ?? "", nth, all === true);
|
|
28945
29060
|
if (!res.ok) {
|
|
28946
|
-
const hint = res.reason === "no_match" ? missHint(find, current, false) : res.reason === "no_element" ? `The anchor matched ${res.matches} time(s), but never inside a <${within}>. Check the tag, or drop within= to edit the text itself
|
|
29061
|
+
const hint = res.reason === "no_match" ? missHint(find, current, false) : res.reason === "no_element" ? `The anchor matched ${res.matches} time(s), but never inside a <${within}>. ${pipeParagraphHint(current, hits, within) ?? "Check the tag, or drop within= to edit the text itself."}` : `The anchor sits in ${res.matches} different <${within}> elements. Lengthen it until it is unique, pass nth= (1-based, in document order), or all=true to act on every one \u2014 elements[] previews them.`;
|
|
28947
29062
|
return txt3({
|
|
28948
29063
|
ok: false,
|
|
28949
29064
|
noteId,
|
|
@@ -29037,6 +29152,7 @@ ${closureHtml}`;
|
|
|
29037
29152
|
...closureTarget ? { closure: { noteId: closureTarget.note.noteId, note: closureTarget.note.title, table: closureTarget.heading ?? "(first table)", row: closureCells.map((c) => toText(c, 80)).join(" | "), action: "would-append" } } : {}
|
|
29038
29153
|
},
|
|
29039
29154
|
matchedElements: res.matches,
|
|
29155
|
+
...all && res.elements ? { elements: res.elements.slice(0, 20).map((e) => toText(e, 160)), ...res.elements.length > 20 ? { elementsShown: 20 } : {} } : {},
|
|
29040
29156
|
next: "Nothing was written and no revision was taken. Re-run with dryRun=false to apply exactly this change."
|
|
29041
29157
|
});
|
|
29042
29158
|
}
|
|
@@ -29056,7 +29172,8 @@ ${closureHtml}`;
|
|
|
29056
29172
|
entryId: entry.noteId,
|
|
29057
29173
|
action: entry.action,
|
|
29058
29174
|
...entry.marker ? { entryMarker: entry.marker } : {},
|
|
29059
|
-
runHeading: leadingIdentityText(closureBlock)
|
|
29175
|
+
runHeading: leadingIdentityText(closureBlock),
|
|
29176
|
+
...entry.previousRun ? { previousRun: entry.previousRun } : {}
|
|
29060
29177
|
};
|
|
29061
29178
|
await trilium.updateLabelValue(closureThread.noteId, "updated", d);
|
|
29062
29179
|
}
|
|
@@ -29129,7 +29246,7 @@ ${closureHtml}`;
|
|
|
29129
29246
|
noteId,
|
|
29130
29247
|
mode: `within:${within}:${action}`,
|
|
29131
29248
|
date: d,
|
|
29132
|
-
element: toText(res.element, 200),
|
|
29249
|
+
...all ? { elements: res.matches } : { element: toText(res.element, 200) },
|
|
29133
29250
|
...closureReceipt ? { closure: closureReceipt } : {},
|
|
29134
29251
|
...titledWithin.retitled ? { retitled: titledWithin.retitled } : {},
|
|
29135
29252
|
...iconWithin ? { icon: iconWithin } : {},
|
|
@@ -29373,7 +29490,7 @@ ${block}`), d).html;
|
|
|
29373
29490
|
} : {},
|
|
29374
29491
|
...sectionMiss,
|
|
29375
29492
|
...sectionHint ? { hint: sectionHint } : {},
|
|
29376
|
-
...threadEntryResult ? { entryId: threadEntryResult.noteId, entryAction: threadEntryResult.action, ...threadEntryResult.marker ? { entryMarker: threadEntryResult.marker } : {}, ...threadRunHeading ? { runHeading: threadRunHeading } : {}, entry: `today's [${d}] entry in this thread; revise(entryId) edits it` } : {},
|
|
29493
|
+
...threadEntryResult ? { entryId: threadEntryResult.noteId, entryAction: threadEntryResult.action, ...threadEntryResult.marker ? { entryMarker: threadEntryResult.marker } : {}, ...threadRunHeading ? { runHeading: threadRunHeading } : {}, ...threadEntryResult.previousRun ? { previousRun: threadEntryResult.previousRun } : {}, entry: `today's [${d}] entry in this thread; revise(entryId) edits it` } : {},
|
|
29377
29494
|
...titled.retitled ? { retitled: titled.retitled } : {},
|
|
29378
29495
|
...titled.cascaded ? { domainCascade: `#domain updated on ${titled.cascaded} descendant note(s)` } : {},
|
|
29379
29496
|
...structuralFindings(noteKind, finalContent),
|
|
@@ -30179,15 +30296,18 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
|
|
|
30179
30296
|
...all.length === 0 ? { note: 'No claims registered. Register one with claim(assertion="\u2026", check="\u2026") \u2014 the assertions worth registering are the ones that would be expensive to discover had gone stale.' } : {}
|
|
30180
30297
|
});
|
|
30181
30298
|
});
|
|
30182
|
-
server.tool("diff", `What a write actually changed: a revision snapshot (default the latest) against the note as it stands, as changed lines with context and added/removed counts. The revision list comes back on every call; pass revisionId to compare against an earlier one. since="today" (or a date, or "YYYY-MM-DD HH:mm") reviews everything changed since then in one call: with noteId, that note's net change across all its writes since; without, every maintained note changed since, each diffed against its state before the first of those writes. Run it before close to check today's corrections agree with each other.`, {
|
|
30299
|
+
server.tool("diff", `What a write actually changed: a revision snapshot (default the latest) against the note as it stands, as changed lines with context and added/removed counts. The revision list comes back on every call; pass revisionId to compare against an earlier one. since="today" (or a date, or "YYYY-MM-DD HH:mm") reviews everything changed since then in one call: with noteId, that note's net change across all its writes since; without, every maintained note changed since, each diffed against its state before the first of those writes; summaryOnly=true lists those notes with their write counts and sizes and no changed lines, the cheap sweep for a recap. Run it before close to check today's corrections agree with each other.`, {
|
|
30183
30300
|
noteId: string2().optional().describe("Note to diff (omit with since= to review every note changed since then)"),
|
|
30184
30301
|
revisionId: string2().optional().describe("Compare against this revision (default: the most recent one)"),
|
|
30185
30302
|
since: string2().optional().describe('"today", a date (YYYY-MM-DD) or a local datetime (YYYY-MM-DD HH:mm): the net change since then, across all writes'),
|
|
30186
30303
|
includeRecords: boolean2().optional().describe("since= without noteId: also list records (sessions, diary, logs, dated thread entries), which only ever gain addenda (default false)"),
|
|
30187
30304
|
limit: number2().int().min(1).max(50).optional().describe("since= without noteId: max notes to diff (default 25)"),
|
|
30188
|
-
context: number2().int().min(0).max(10).optional().describe("Unchanged lines to show around each change (default: 1)")
|
|
30189
|
-
|
|
30305
|
+
context: number2().int().min(0).max(10).optional().describe("Unchanged lines to show around each change (default: 1)"),
|
|
30306
|
+
summaryOnly: boolean2().optional().describe("since= without noteId: list each changed note with writes, chars and sizeChange (characters gained or lost), without the changed lines")
|
|
30307
|
+
}, async ({ noteId, revisionId, since, includeRecords, limit, context, summaryOnly }) => {
|
|
30190
30308
|
const pad = context ?? 1;
|
|
30309
|
+
if (summaryOnly !== undefined && (since === undefined || noteId !== undefined))
|
|
30310
|
+
return err("conflicting_params", "summaryOnly= applies to since= without noteId.", 'e.g. diff(since="today", summaryOnly=true, includeRecords=true); diff one note with noteId= for its lines.');
|
|
30191
30311
|
if (since !== undefined) {
|
|
30192
30312
|
const cutoff = sinceCutoff(since);
|
|
30193
30313
|
if (!cutoff)
|
|
@@ -30216,6 +30336,10 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
|
|
|
30216
30336
|
continue;
|
|
30217
30337
|
}
|
|
30218
30338
|
const [before, after] = await Promise.all([trilium.getRevisionContent(first.revisionId), trilium.getNoteContent(n.noteId)]);
|
|
30339
|
+
if (summaryOnly) {
|
|
30340
|
+
notes.push({ ...stub, writes: revisionsSince(revisions, cutoff).length, chars: after.length, sizeChange: after.length - before.length, ...before === after ? { identical: true } : {} });
|
|
30341
|
+
continue;
|
|
30342
|
+
}
|
|
30219
30343
|
const d = blockDiff(before, after, pad, 12);
|
|
30220
30344
|
notes.push({ ...stub, writes: revisionsSince(revisions, cutoff).length, ...d.identical ? { identical: true } : { summary: d.summary, removed: d.removed, added: d.added } });
|
|
30221
30345
|
}
|
|
@@ -32982,7 +33106,7 @@ function createServer(origin = null) {
|
|
|
32982
33106
|
const s = new McpServer({
|
|
32983
33107
|
name: BRAND2.name,
|
|
32984
33108
|
title: BRAND2.name,
|
|
32985
|
-
version: "13.
|
|
33109
|
+
version: "13.2.0",
|
|
32986
33110
|
description: BRAND2.description,
|
|
32987
33111
|
websiteUrl: origin ?? BRAND2.website,
|
|
32988
33112
|
icons: brandIcons(origin)
|
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": "13.
|
|
5
|
+
"version": "13.2.0",
|
|
6
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": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainllm",
|
|
3
|
-
"version": "13.
|
|
3
|
+
"version": "13.2.0",
|
|
4
4
|
"mcpName": "io.github.miisodev/brainllm",
|
|
5
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>",
|
|
@@ -62,11 +62,11 @@
|
|
|
62
62
|
"prepublishOnly": "bun run build"
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@modelcontextprotocol/sdk": "^1.32.
|
|
65
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
66
66
|
"zod": "^4.6.6"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
|
-
"@types/bun": "
|
|
69
|
+
"@types/bun": "^1.4.4",
|
|
70
70
|
"ajv": "^8.20.0",
|
|
71
71
|
"ajv-formats": "^3.0.1",
|
|
72
72
|
"sharp": "^0.35.5",
|
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": "Self-hosted memory for LLMs in TriliumNext, structured by the server and editable by you.",
|
|
5
|
-
"version": "13.
|
|
5
|
+
"version": "13.2.0",
|
|
6
6
|
"websiteUrl": "https://miisodev.github.io/BrainLLM/",
|
|
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": "13.
|
|
15
|
+
"version": "13.2.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
package/skills/brainllm/SKILL.md
CHANGED
|
@@ -95,7 +95,7 @@ worth keeping?
|
|
|
95
95
|
| skim a surface | `<surface>_recall` |
|
|
96
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
97
|
| search | `recall(query, domain=…)` — scope it; `since=`/`until=` keep only notes last modified inside the window (date-only bounds cover whole days); every hit names `matchedBy` (`label`/`title`/`text`/`fuzzy`/`ordered`/`regex`) and the top hits carry the `evidence` passage; `regex=` for structure, evidenced on every hit (records included; `kinds=` narrows); fuzzy hits are leads; recent notes rank slightly higher. A receipt that saturated a strategy's 30-candidate cap says so; `searchFailed: true` is a backend failure, never a "nothing matched" verdict — retry |
|
|
98
|
-
| several bodies at once | `read(ids=[…])` (≤ 10). Every entry carries `chars` (the full stored size, even when a section is returned); `section=` (+ `occurrence=`) reads one heading's body from each note — a table section reports `tableRows`, and a miss follows the singleton miss shape (`missing: true` for an unknown id; `available[]` lists the real headings for a section miss). 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) |
|
|
98
|
+
| several bodies at once | `read(ids=[…])` (≤ 10). Every entry carries `chars` (the full stored size, even when a section is returned); `section=` (+ `occurrence=`) reads one heading's body from each note — a table section reports `tableRows`, and a miss follows the singleton miss shape (`missing: true` for an unknown id; `available[]` lists the real headings for a section miss). 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). `sizes=true` returns no bodies, only each note's `chars` and its headings with their section sizes: plan a bulk read before it overflows |
|
|
99
99
|
| storage and growth | `health()` — Trilium version, estimated database size, heaviest notes and their revisions, named backups |
|
|
100
100
|
| what the brain holds | `assembly(area?)` — titles by surface with purposes |
|
|
101
101
|
| inventory / locate by id | `brain()` — read `parent`, not position |
|
|
@@ -120,7 +120,7 @@ Every read that can be large takes `section=` — `read(ids)` too, across each n
|
|
|
120
120
|
| the session log | `close(summary, identity, title?, learned?)` |
|
|
121
121
|
|
|
122
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.
|
|
123
|
-
- **Sources preview before they land.** `remember(kind="sources", …, revision=)` takes `dryRun=true`, which previews each Revision row addition/update without writing; a new key near an existing one returns `didYouMean` (with `didYouMeanNote`) on the dry run and on the real call, so a qualifier added to a name updates its row instead of creating a second one.
|
|
123
|
+
- **Sources preview before they land.** `remember(kind="sources", …, revision=)` takes `dryRun=true`, which previews each Revision row addition/update without writing; a new key near an existing one returns `didYouMean` (with `didYouMeanNote`) on the dry run and on the real call, so a qualifier added to a name updates its row instead of creating a second one. When a source moves or is renamed, `revision=[{source: "<new>", from: "<old>", marker}]` rekeys its row instead of adding one (refused when `<new>` already has a row). The dry run also previews where a `body=` lands and warns before loose prose joins the list.
|
|
124
124
|
- **Wire at creation:** `connect=[{relation, toNoteId}]` on the same call. An unconnected note is an orphan until wired.
|
|
125
125
|
- **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.
|
|
126
126
|
- **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.
|
|
@@ -151,17 +151,18 @@ Read `template(kind)` before your first write of a kind, then read a sibling.
|
|
|
151
151
|
| `find="<text>"` | replace every occurrence (`nth=` for one) — exact first, then through re-serialized tags, then ignoring inline formatting (`matchMode` says which) |
|
|
152
152
|
| `edits=[{find, body}]` | several surgeries in one read and one write |
|
|
153
153
|
| `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 |
|
|
154
|
+
| `within=` + `all=true` | act on **every** element the anchor sits in (remove every Revision row naming a retired source, in one write) instead of refusing an ambiguous anchor; preview with `dryRun=true`, which lists them. Does not combine with `nth=` or `closure=` |
|
|
154
155
|
| `within=` + `dryRun=true` | preview the element first: its full visible extent and byte size, plus the before/after shape (for a `closure=`, what would be appended or moved) — nothing written, no revision. `find=` and `edits=` also support `dryRun=true` using the same replacement and sanitizer path; metadata changes cannot be combined with a preview. `domain=` previews by default. Unsupported preview modes are refused |
|
|
155
156
|
| `within="tr"` + `mode="remove"` + `closure=` | close a register row in one call: `{thread, body}` sends the row, quoted, into that thread's entry for today (needs `identity=`); `{note, table?, row?}` moves the row into another register. Column counts must match exactly; `row=["cell", …]` provides explicitly shaped plain-text destination cells. Name `table` when several tables exist. Destination content lands before source removal; same-note moves use one write. On a partial failure, read both notes before retrying; exact destination rows and identical dated blocks are deduplicated. Run `consistency()` after on any fact the row asserted |
|
|
156
157
|
| `append={table, row}` | append one row to the table under that heading with no anchor to locate first — pipes are cell boundaries, a lone placeholder row is replaced; composes with nothing else |
|
|
157
158
|
| `moves=[{find, nth?, row?}]` + `within="tr"`, `mode="remove"`, `closure=` | batch row closures to one target; all rows are preflighted, then applied in order. `dryRun=true` previews without writes. Stops on failure with completed and remaining rows; it is not an atomic transaction |
|
|
158
159
|
| `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 |
|
|
159
160
|
|
|
160
|
-
- **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`.
|
|
161
|
+
- **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, `all=true` takes them all. A `within="tr"` miss on a line written as `a | b | c` says it is a paragraph, not a row — use `within="p"`. Write one `<td>` per column — a row whose cells disagree with its header comes back as `tableShape`.
|
|
161
162
|
- **"Last updated" lines are server-owned.** Every content write bumps them (the receipt says `lastUpdated`); never `find=` on one.
|
|
162
163
|
- A replacement equal to its match writes nothing and reports `unchanged`.
|
|
163
|
-
- A failed revision snapshot stops the content overwrite. System errors are not success receipts. 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.
|
|
164
|
-
- **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. A record append (`remember`/`revise`/`closure`) names where it landed: `entryMarker` (the `Addendum — HH:mm` block heading written) and `runHeading` (the identity heading, when the body opened with one).
|
|
164
|
+
- A failed revision snapshot stops the content overwrite. System errors are not success receipts. 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. `summaryOnly=true` lists the changed notes with write counts and size changes and no lines, the cheap sweep for a recap.
|
|
165
|
+
- **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. A record append (`remember`/`revise`/`closure`) names where it landed: `entryMarker` (the `Addendum — HH:mm` block heading written) and `runHeading` (the identity heading, when the body opened with one); a dated-thread append adds `previousRun`, the same author's previous block (`heading`, `date`, and `run` when it carries `Run N`), so the next run number needs no walk-back read.
|
|
165
166
|
- **`find=` replaces text; it does not insert beside an anchor.** To insert, use `within=` with `mode="before"/"after"`, or `section=` with an insertion mode. **`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 in every shape: drifted text shows the stored text nearby (`storedNearby`), `nth=` past the last occurrence says so, and an all-miss `edits=` returns a hint per edit.
|
|
166
167
|
- A section replace swaps everything under the heading — use `find=` for anything smaller.
|
|
167
168
|
- **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.
|