brainllm 13.1.2 → 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 +174 -59
- package/manifest.json +1 -1
- package/package.json +1 -1
- 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
|
@@ -24531,6 +24531,24 @@ function leadingIdentityText(html) {
|
|
|
24531
24531
|
const m = /^(?:\s|<p>(?:\s| )*<\/p>)*<h3(?:\s[^>]*)?>([\s\S]*?)<\/h3>/i.exec(html);
|
|
24532
24532
|
return m ? decodeEntities(m[1].replace(/<[^>]+>/g, "")).trim() : null;
|
|
24533
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
|
+
}
|
|
24534
24552
|
function identityProblem(text) {
|
|
24535
24553
|
const line = text.trim();
|
|
24536
24554
|
if (/[\r\n]/.test(line))
|
|
@@ -24921,7 +24939,7 @@ function hasPlaceholderRow(html, heading) {
|
|
|
24921
24939
|
const rows = tableRows(html, heading);
|
|
24922
24940
|
return rows.length > 0 && rows.every((cells) => PLACEHOLDER_CELL.test(cells[0] ?? ""));
|
|
24923
24941
|
}
|
|
24924
|
-
function upsertTableRow(html, heading, key, cells) {
|
|
24942
|
+
function upsertTableRow(html, heading, key, cells, renameFrom) {
|
|
24925
24943
|
const noop = { html, matched: false, created: false };
|
|
24926
24944
|
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
24927
24945
|
let headingEnd = -1;
|
|
@@ -24952,7 +24970,7 @@ function upsertTableRow(html, heading, key, cells) {
|
|
|
24952
24970
|
const m = /<td[^>]*>([\s\S]*?)<\/td>/i.exec(row);
|
|
24953
24971
|
return m ? decodeEntities(m[1].replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim().toLowerCase() : "";
|
|
24954
24972
|
};
|
|
24955
|
-
const keyNorm = decodeEntities(key).replace(/\s+/g, " ").trim().toLowerCase();
|
|
24973
|
+
const keyNorm = decodeEntities(renameFrom ?? key).replace(/\s+/g, " ").trim().toLowerCase();
|
|
24956
24974
|
const rowRe = /<tr[^>]*>[\s\S]*?<\/tr>/gi;
|
|
24957
24975
|
let matchedRow = null;
|
|
24958
24976
|
let placeholderRow = null;
|
|
@@ -24967,6 +24985,8 @@ function upsertTableRow(html, heading, key, cells) {
|
|
|
24967
24985
|
if (!placeholderRow && (text === "" || text === "\u2014 none yet \u2014"))
|
|
24968
24986
|
placeholderRow = span;
|
|
24969
24987
|
}
|
|
24988
|
+
if (renameFrom !== undefined && !matchedRow)
|
|
24989
|
+
return noop;
|
|
24970
24990
|
const newRow = `<tr>${[key, ...cells].map((c) => `<td>${escapeHtml(c)}</td>`).join("")}</tr>`;
|
|
24971
24991
|
const target = matchedRow ?? placeholderRow;
|
|
24972
24992
|
const newTbody = target ? tbody.slice(0, target.start) + newRow + tbody.slice(target.end) : tbody + newRow;
|
|
@@ -26574,7 +26594,7 @@ function containingElement(html, tag, from, to) {
|
|
|
26574
26594
|
}
|
|
26575
26595
|
return best;
|
|
26576
26596
|
}
|
|
26577
|
-
function editWithin(html, hits, tag, action, body, nth) {
|
|
26597
|
+
function editWithin(html, hits, tag, action, body, nth, all = false) {
|
|
26578
26598
|
if (!hits.length)
|
|
26579
26599
|
return { ok: false, reason: "no_match", matches: 0 };
|
|
26580
26600
|
const seen = new Map;
|
|
@@ -26587,6 +26607,14 @@ function editWithin(html, hits, tag, action, body, nth) {
|
|
|
26587
26607
|
if (!elements.length)
|
|
26588
26608
|
return { ok: false, reason: "no_element", matches: hits.length };
|
|
26589
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
|
+
}
|
|
26590
26618
|
let target;
|
|
26591
26619
|
if (nth !== undefined)
|
|
26592
26620
|
target = elements[nth - 1];
|
|
@@ -26598,6 +26626,19 @@ function editWithin(html, hits, tag, action, body, nth) {
|
|
|
26598
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);
|
|
26599
26627
|
return { ok: true, html: out, element: el, matches: elements.length };
|
|
26600
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
|
+
}
|
|
26601
26642
|
|
|
26602
26643
|
// src/tools-surface.ts
|
|
26603
26644
|
var txt = (obj) => ({
|
|
@@ -26985,6 +27026,8 @@ function applyFindEdit(source, needle, replacement, index) {
|
|
|
26985
27026
|
return inline.length ? spans(inline, "inline-tolerant") : null;
|
|
26986
27027
|
}
|
|
26987
27028
|
var CHANGES_CAP = 25;
|
|
27029
|
+
var SOURCE_KEYS_ECHO = 30;
|
|
27030
|
+
var PREVIOUS_RUN_DAYS = 7;
|
|
26988
27031
|
var boundPreview = (s, max = 1600) => s.length <= max ? s : `${s.slice(0, max)}\u2026 (${s.length - max} more characters)`;
|
|
26989
27032
|
function capChanges(changes, cap = CHANGES_CAP) {
|
|
26990
27033
|
if (changes.length <= cap)
|
|
@@ -27243,6 +27286,7 @@ function registerTools(server, trilium, brainRef) {
|
|
|
27243
27286
|
}
|
|
27244
27287
|
async function appendThreadEntry(threadId, block, d) {
|
|
27245
27288
|
const found = await trilium.searchNotes(`#noteType=threadEntry #created='${d}'`, { ancestorNoteId: threadId, fastSearch: true, limit: 1 });
|
|
27289
|
+
const incomingIdentity = leadingIdentityText(block);
|
|
27246
27290
|
const time = localNowTime();
|
|
27247
27291
|
const marker = `Addendum \u2014 ${time}`;
|
|
27248
27292
|
if (found.results[0]) {
|
|
@@ -27256,7 +27300,8 @@ function registerTools(server, trilium, brainRef) {
|
|
|
27256
27300
|
await trilium.createRevision(noteId);
|
|
27257
27301
|
await trilium.updateNoteContent(noteId, safeAppend(current, `<h2>${marker}</h2>`, block));
|
|
27258
27302
|
await trilium.updateLabelValue(noteId, "updated", d);
|
|
27259
|
-
|
|
27303
|
+
const previousRun = incomingIdentity ? await previousSameAuthor(threadId, incomingIdentity, current, d) : undefined;
|
|
27304
|
+
return { noteId, action: "appended", marker, ...previousRun ? { previousRun } : {} };
|
|
27260
27305
|
}
|
|
27261
27306
|
const created = await trilium.createNote(threadId, `[${d}]`, contentFor("threadEntry", { date: d, body: `<h2>${marker}</h2>
|
|
27262
27307
|
${block}` }));
|
|
@@ -27264,7 +27309,33 @@ ${block}` }));
|
|
|
27264
27309
|
await trilium.addLabel(noteId, "noteType", "threadEntry");
|
|
27265
27310
|
await trilium.addLabel(noteId, "created", d);
|
|
27266
27311
|
await ensureIcon(trilium, noteId);
|
|
27267
|
-
|
|
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;
|
|
27268
27339
|
}
|
|
27269
27340
|
function collectionAppendRefusal(book) {
|
|
27270
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.`);
|
|
@@ -27835,7 +27906,7 @@ ${html}` }));
|
|
|
27835
27906
|
});
|
|
27836
27907
|
server.tool("remember", `Store something the moment it matters; the server owns placement, labels and dedup.
|
|
27837
27908
|
|
|
27838
|
-
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).
|
|
27839
27910
|
|
|
27840
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.`, {
|
|
27841
27912
|
kind: _enum(Kinds).describe("What kind of memory this is"),
|
|
@@ -27849,8 +27920,9 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
27849
27920
|
revision: array(object2({
|
|
27850
27921
|
source: string2().describe("Must exactly match how the source is introduced in the Sources list \u2014 this is the upsert key"),
|
|
27851
27922
|
marker: string2().describe('"\u2747\uFE0F" (discovered/credible) or "\u2705" (used)'),
|
|
27852
|
-
date: string2().optional().describe("ISO date (default: today)")
|
|
27853
|
-
|
|
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"),
|
|
27854
27926
|
topics: array(string2()).optional().describe("Topic tags \u2014 slugged server-side"),
|
|
27855
27927
|
supersedes: string2().optional().describe("noteId this replaces \u2014 old note is archived and wired supersedes"),
|
|
27856
27928
|
mandate: boolean2().optional().describe("kind=information: flag the note as a standing brief a session must follow (#mandate)"),
|
|
@@ -27926,6 +27998,39 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
27926
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.`
|
|
27927
27999
|
};
|
|
27928
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);
|
|
27929
28034
|
if (isSingleton(kind)) {
|
|
27930
28035
|
const id = kindHome(b(), kind);
|
|
27931
28036
|
if (!id)
|
|
@@ -27954,25 +28059,18 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
27954
28059
|
else {
|
|
27955
28060
|
const content = await trilium.getNoteContent(sidPreview);
|
|
27956
28061
|
const existingKeys = tableRows(content, "Revision").map((cells) => cells[0]).filter(Boolean);
|
|
27957
|
-
const
|
|
27958
|
-
|
|
27959
|
-
|
|
27960
|
-
|
|
27961
|
-
|
|
27962
|
-
if (result.matched || result.created) {
|
|
27963
|
-
sim = result.html;
|
|
27964
|
-
previewChanges.push(`${result.matched ? "would update" : "would add"}: ${row.source}`);
|
|
27965
|
-
if (!result.matched)
|
|
27966
|
-
previewAdded.push(row.source);
|
|
27967
|
-
}
|
|
27968
|
-
}
|
|
27969
|
-
if (previewChanges.length)
|
|
27970
|
-
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;
|
|
27971
28067
|
if (html && toText(html, 50) && !content.includes(html)) {
|
|
27972
28068
|
const merged = mergeUnderSection(content, html, "Sources");
|
|
27973
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.";
|
|
27974
28072
|
}
|
|
27975
|
-
const hints = sourceKeyHints(
|
|
28073
|
+
const hints = sourceKeyHints(plan.added, existingKeys);
|
|
27976
28074
|
if (hints) {
|
|
27977
28075
|
preview.didYouMean = hints.didYouMean;
|
|
27978
28076
|
preview.didYouMeanNote = hints.didYouMeanNote;
|
|
@@ -28005,26 +28103,20 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
28005
28103
|
wrote = true;
|
|
28006
28104
|
}
|
|
28007
28105
|
}
|
|
28008
|
-
|
|
28009
|
-
|
|
28106
|
+
let revisionChanges = [];
|
|
28107
|
+
let addedKeys = [];
|
|
28108
|
+
let refusedRows = [];
|
|
28010
28109
|
let existingKeys = [];
|
|
28011
28110
|
if (sid && revision?.length) {
|
|
28012
|
-
|
|
28013
|
-
existingKeys = tableRows(
|
|
28014
|
-
|
|
28015
|
-
|
|
28016
|
-
|
|
28017
|
-
|
|
28018
|
-
|
|
28019
|
-
changed = true;
|
|
28020
|
-
revisionChanges.push(`${result.matched ? "updated" : "added"}: ${row.source}`);
|
|
28021
|
-
if (!result.matched)
|
|
28022
|
-
addedKeys.push(row.source);
|
|
28023
|
-
}
|
|
28024
|
-
}
|
|
28025
|
-
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) {
|
|
28026
28118
|
await trilium.createRevision(sid);
|
|
28027
|
-
const stamped = bumpLastUpdated(
|
|
28119
|
+
const stamped = bumpLastUpdated(plan.html, d);
|
|
28028
28120
|
await trilium.updateNoteContent(sid, stamped.html);
|
|
28029
28121
|
await trilium.updateLabelValue(sid, "updated", d);
|
|
28030
28122
|
wrote = true;
|
|
@@ -28038,7 +28130,7 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
28038
28130
|
const revisionKeys = tableRows(finalHtml, "Revision").map((cells) => cells[0]).filter(Boolean);
|
|
28039
28131
|
const placeholderLeft = hasPlaceholderRow(finalHtml, "Revision");
|
|
28040
28132
|
const bodyWritten = !!html && !!toText(html, 50);
|
|
28041
|
-
const looseProse = bodyWritten && !createdDomain &&
|
|
28133
|
+
const looseProse = bodyWritten && !createdDomain && isLooseProse(html);
|
|
28042
28134
|
const keyHints = sourceKeyHints(addedKeys, existingKeys);
|
|
28043
28135
|
return txt3({
|
|
28044
28136
|
action: wrote ? "maintained" : "already_written",
|
|
@@ -28048,9 +28140,10 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
28048
28140
|
location: locationLabel(kind, domainTitle),
|
|
28049
28141
|
...createdDomain ? { createdDomain: domainTitle } : {},
|
|
28050
28142
|
...revisionChanges.length ? { revision: revisionChanges } : {},
|
|
28143
|
+
...refusedRows.length ? { refused: refusedRows } : {},
|
|
28051
28144
|
...sourceMerge,
|
|
28052
28145
|
...revisionKeys.length ? { revisionRows: revisionKeys.length } : {},
|
|
28053
|
-
...
|
|
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}].` } : {},
|
|
28054
28147
|
...keyHints ? { didYouMean: keyHints.didYouMean, didYouMeanNote: keyHints.didYouMeanNote } : {},
|
|
28055
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." } : {},
|
|
28056
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." } : {},
|
|
@@ -28172,7 +28265,7 @@ ${html}` : html;
|
|
|
28172
28265
|
const connected = await wireRequested(existing.noteId);
|
|
28173
28266
|
const iconSet = await applyIcon(existing.noteId, icon);
|
|
28174
28267
|
const relations = relationSnippet(existing);
|
|
28175
|
-
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 } : {} });
|
|
28176
28269
|
}
|
|
28177
28270
|
const current = await trilium.getNoteContent(existing.noteId);
|
|
28178
28271
|
if (isDuplicateAppend(current, block))
|
|
@@ -28569,12 +28662,15 @@ ${block}`), d);
|
|
|
28569
28662
|
});
|
|
28570
28663
|
});
|
|
28571
28664
|
const READ_BUDGET = 80000;
|
|
28572
|
-
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.`, {
|
|
28573
28666
|
ids: array(string2()).min(1).max(10).describe("Note ids to read \u2014 one body per id, one round trip (cap 10)"),
|
|
28574
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[]"),
|
|
28575
28668
|
occurrence: number2().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)"),
|
|
28576
|
-
text: boolean2().optional().describe("Return readable plain text instead of stored HTML (default false; with section=, the section only)")
|
|
28577
|
-
|
|
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.");
|
|
28578
28674
|
const fetched = await Promise.all(ids.map(async (id) => {
|
|
28579
28675
|
try {
|
|
28580
28676
|
const note = await trilium.getNote(id);
|
|
@@ -28582,6 +28678,10 @@ ${block}`), d);
|
|
|
28582
28678
|
const relations = relationSnippet(note);
|
|
28583
28679
|
const raw = typeof contentResult === "string" ? contentResult : contentResult.content;
|
|
28584
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
|
+
}
|
|
28585
28685
|
if (section && typeof contentResult !== "string") {
|
|
28586
28686
|
return { ...identity, content: "", section, matched: false, hint: "Binary content cannot be sectioned as text; read it without section=." };
|
|
28587
28687
|
}
|
|
@@ -28710,7 +28810,7 @@ ${block}`), d);
|
|
|
28710
28810
|
};
|
|
28711
28811
|
server.tool("revise", `Edit a note by id. A revision is taken first; "Last updated" lines are bumped on content writes.
|
|
28712
28812
|
|
|
28713
|
-
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.
|
|
28714
28814
|
|
|
28715
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=).`, {
|
|
28716
28816
|
noteId: string2().optional().describe("Note to update (omit only with domain=)"),
|
|
@@ -28725,6 +28825,7 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
28725
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."),
|
|
28726
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"),
|
|
28727
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="),
|
|
28728
28829
|
closure: object2({
|
|
28729
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='),
|
|
28730
28831
|
body: string2().optional().describe("Thread variant: the closure prose: what settled it, who ruled, and where its durable substance went"),
|
|
@@ -28749,9 +28850,9 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
28749
28850
|
identity: string2().optional().describe('Appending to a dated thread: the identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" (required)'),
|
|
28750
28851
|
icon: string2().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name; normalized server-side'),
|
|
28751
28852
|
date: string2().optional().describe("ISO date (default: today)")
|
|
28752
|
-
}, 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 }) {
|
|
28753
28854
|
if (moves) {
|
|
28754
|
-
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)
|
|
28755
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.");
|
|
28756
28857
|
const results = [];
|
|
28757
28858
|
const elements = new Set;
|
|
@@ -28774,7 +28875,8 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
28774
28875
|
edits: undefined,
|
|
28775
28876
|
moves: undefined,
|
|
28776
28877
|
icon: undefined,
|
|
28777
|
-
strict: undefined
|
|
28878
|
+
strict: undefined,
|
|
28879
|
+
all: undefined
|
|
28778
28880
|
});
|
|
28779
28881
|
for (const move of moves) {
|
|
28780
28882
|
const r = JSON.parse((await one(move, true)).content[0].text);
|
|
@@ -28831,6 +28933,10 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
28831
28933
|
const noteId = noteIdArg;
|
|
28832
28934
|
if (isContainer(b(), noteId))
|
|
28833
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.");
|
|
28834
28940
|
if (within && find === undefined)
|
|
28835
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>").');
|
|
28836
28942
|
if (append) {
|
|
@@ -28950,9 +29056,9 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
28950
29056
|
const inlineHits = exact.length || tolerantHits.length ? [] : inlineTolerantSpans(current, find);
|
|
28951
29057
|
const visibleHits = exact.length || tolerantHits.length || inlineHits.length ? [] : visibleTextSpans(current, find, within);
|
|
28952
29058
|
const hits = exact.length ? exact : tolerantHits.length ? tolerantHits : inlineHits.length ? inlineHits : visibleHits;
|
|
28953
|
-
const res = editWithin(current, hits, within, action, body ?? "", nth);
|
|
29059
|
+
const res = editWithin(current, hits, within, action, body ?? "", nth, all === true);
|
|
28954
29060
|
if (!res.ok) {
|
|
28955
|
-
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.`;
|
|
28956
29062
|
return txt3({
|
|
28957
29063
|
ok: false,
|
|
28958
29064
|
noteId,
|
|
@@ -29046,6 +29152,7 @@ ${closureHtml}`;
|
|
|
29046
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" } } : {}
|
|
29047
29153
|
},
|
|
29048
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 } : {} } : {},
|
|
29049
29156
|
next: "Nothing was written and no revision was taken. Re-run with dryRun=false to apply exactly this change."
|
|
29050
29157
|
});
|
|
29051
29158
|
}
|
|
@@ -29065,7 +29172,8 @@ ${closureHtml}`;
|
|
|
29065
29172
|
entryId: entry.noteId,
|
|
29066
29173
|
action: entry.action,
|
|
29067
29174
|
...entry.marker ? { entryMarker: entry.marker } : {},
|
|
29068
|
-
runHeading: leadingIdentityText(closureBlock)
|
|
29175
|
+
runHeading: leadingIdentityText(closureBlock),
|
|
29176
|
+
...entry.previousRun ? { previousRun: entry.previousRun } : {}
|
|
29069
29177
|
};
|
|
29070
29178
|
await trilium.updateLabelValue(closureThread.noteId, "updated", d);
|
|
29071
29179
|
}
|
|
@@ -29138,7 +29246,7 @@ ${closureHtml}`;
|
|
|
29138
29246
|
noteId,
|
|
29139
29247
|
mode: `within:${within}:${action}`,
|
|
29140
29248
|
date: d,
|
|
29141
|
-
element: toText(res.element, 200),
|
|
29249
|
+
...all ? { elements: res.matches } : { element: toText(res.element, 200) },
|
|
29142
29250
|
...closureReceipt ? { closure: closureReceipt } : {},
|
|
29143
29251
|
...titledWithin.retitled ? { retitled: titledWithin.retitled } : {},
|
|
29144
29252
|
...iconWithin ? { icon: iconWithin } : {},
|
|
@@ -29382,7 +29490,7 @@ ${block}`), d).html;
|
|
|
29382
29490
|
} : {},
|
|
29383
29491
|
...sectionMiss,
|
|
29384
29492
|
...sectionHint ? { hint: sectionHint } : {},
|
|
29385
|
-
...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` } : {},
|
|
29386
29494
|
...titled.retitled ? { retitled: titled.retitled } : {},
|
|
29387
29495
|
...titled.cascaded ? { domainCascade: `#domain updated on ${titled.cascaded} descendant note(s)` } : {},
|
|
29388
29496
|
...structuralFindings(noteKind, finalContent),
|
|
@@ -30188,15 +30296,18 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
|
|
|
30188
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.' } : {}
|
|
30189
30297
|
});
|
|
30190
30298
|
});
|
|
30191
|
-
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.`, {
|
|
30192
30300
|
noteId: string2().optional().describe("Note to diff (omit with since= to review every note changed since then)"),
|
|
30193
30301
|
revisionId: string2().optional().describe("Compare against this revision (default: the most recent one)"),
|
|
30194
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'),
|
|
30195
30303
|
includeRecords: boolean2().optional().describe("since= without noteId: also list records (sessions, diary, logs, dated thread entries), which only ever gain addenda (default false)"),
|
|
30196
30304
|
limit: number2().int().min(1).max(50).optional().describe("since= without noteId: max notes to diff (default 25)"),
|
|
30197
|
-
context: number2().int().min(0).max(10).optional().describe("Unchanged lines to show around each change (default: 1)")
|
|
30198
|
-
|
|
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 }) => {
|
|
30199
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.');
|
|
30200
30311
|
if (since !== undefined) {
|
|
30201
30312
|
const cutoff = sinceCutoff(since);
|
|
30202
30313
|
if (!cutoff)
|
|
@@ -30225,6 +30336,10 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
|
|
|
30225
30336
|
continue;
|
|
30226
30337
|
}
|
|
30227
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
|
+
}
|
|
30228
30343
|
const d = blockDiff(before, after, pad, 12);
|
|
30229
30344
|
notes.push({ ...stub, writes: revisionsSince(revisions, cutoff).length, ...d.identical ? { identical: true } : { summary: d.summary, removed: d.removed, added: d.added } });
|
|
30230
30345
|
}
|
|
@@ -32991,7 +33106,7 @@ function createServer(origin = null) {
|
|
|
32991
33106
|
const s = new McpServer({
|
|
32992
33107
|
name: BRAND2.name,
|
|
32993
33108
|
title: BRAND2.name,
|
|
32994
|
-
version: "13.
|
|
33109
|
+
version: "13.2.0",
|
|
32995
33110
|
description: BRAND2.description,
|
|
32996
33111
|
websiteUrl: origin ?? BRAND2.website,
|
|
32997
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>",
|
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.
|