brainllm 12.7.1 → 12.8.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/dist/index.js CHANGED
@@ -21881,6 +21881,36 @@ function addendumIndex(html) {
21881
21881
  }
21882
21882
  return out;
21883
21883
  }
21884
+ function addendumBlock(html, block) {
21885
+ const markerRe = /<h2(?:\s[^>]*)?>\s*((?:Addendum|Withdrawn|Recovered|Reopened)[^<]*)<\/h2>/gi;
21886
+ const hits = [...html.matchAll(markerRe)];
21887
+ const markers = hits.map((m) => decodeEntities(m[1]).replace(/\s+/g, " ").trim());
21888
+ const want = block.trim();
21889
+ let i = -1;
21890
+ if (/^-?\d+$/.test(want)) {
21891
+ const n = parseInt(want, 10);
21892
+ i = n < 0 ? hits.length + n : n - 1;
21893
+ } else {
21894
+ i = markers.findIndex((m) => m.endsWith(want) || m.includes(`\u2014 ${want}`) || m === want);
21895
+ }
21896
+ if (i < 0 || i >= hits.length)
21897
+ return { matched: false, available: markers };
21898
+ const from = hits[i].index;
21899
+ const to = i + 1 < hits.length ? hits[i + 1].index : html.length;
21900
+ const content = html.slice(from, to).trim();
21901
+ const idMatch = content.match(/<h3(?:\s[^>]*)?>([\s\S]*?)<\/h3>/i);
21902
+ const identity = idMatch ? decodeEntities(idMatch[1].replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim() : undefined;
21903
+ return { matched: true, marker: markers[i], ...identity ? { identity } : {}, content, position: i + 1, of: hits.length };
21904
+ }
21905
+ function readableText(html) {
21906
+ return decodeEntities(html.replace(/<(style|script)[\s\S]*?<\/\1>/gi, "").replace(/<h([1-6])[^>]*>/gi, (_, n) => `
21907
+ ` + "#".repeat(Number(n)) + " ").replace(/<li[^>]*>/gi, `
21908
+ - `).replace(/<\/t[dh]>\s*<t[dh][^>]*>/gi, " | ").replace(/<(tr|p|div|br|blockquote|figure|table|ul|ol|hr)[^>]*>/gi, `
21909
+ `).replace(/<[^>]+>/g, "")).replace(/[ \t]+/g, " ").replace(/ *\n */g, `
21910
+ `).replace(/\n{3,}/g, `
21911
+
21912
+ `).trim();
21913
+ }
21884
21914
  function nearestContext(source, needle, radius = 40) {
21885
21915
  if (!source || needle.length < 4)
21886
21916
  return null;
@@ -22258,6 +22288,74 @@ function tolerantFindRegex(find) {
22258
22288
  function spansBlockBoundary(find) {
22259
22289
  return /<\/[a-zA-Z][a-zA-Z0-9-]*\s*>\s*<[a-zA-Z]/.test(find);
22260
22290
  }
22291
+ var INLINE_TAGS = "strong|em|b|i|u|s|del|ins|code|a|span|mark|sub|sup|kbd|small";
22292
+ var INLINE_TAG_RX = new RegExp(`<\\/?(?:${INLINE_TAGS})(?:\\s[^<>]*)?\\/?>`, "gi");
22293
+ var ENTITY_FORMS = {
22294
+ "&": "&(?:amp;)?",
22295
+ "<": "&lt;",
22296
+ ">": "&gt;",
22297
+ '"': '(?:"|&quot;)',
22298
+ "'": "(?:'|&#39;|&apos;)"
22299
+ };
22300
+ function inlineTolerantSpans(source, needle) {
22301
+ if (/<\/?(?!(?:strong|em|b|i|u|s|del|ins|code|a|span|mark|sub|sup|kbd|small)\b)[a-zA-Z]/.test(needle))
22302
+ return [];
22303
+ const text = decodeEntities(needle.replace(INLINE_TAG_RX, "")).trim();
22304
+ if (text.length < 2)
22305
+ return [];
22306
+ const gap = `(?:<\\/?(?:${INLINE_TAGS})(?:\\s[^<>]*)?\\/?>)*`;
22307
+ const esc = (c) => c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22308
+ const parts = [];
22309
+ for (const ch of text.replace(/\s+/g, " ")) {
22310
+ parts.push(ch === " " ? "(?:\\s|&nbsp;|&#160;)+" : ENTITY_FORMS[ch] ?? esc(ch));
22311
+ }
22312
+ let rx;
22313
+ try {
22314
+ rx = new RegExp(parts.join(gap), "g");
22315
+ } catch {
22316
+ return [];
22317
+ }
22318
+ const out = [];
22319
+ for (const m of source.matchAll(rx)) {
22320
+ const span = balanceInline(source, m.index, m.index + m[0].length);
22321
+ if (span)
22322
+ out.push({ start: span.start, length: span.end - span.start });
22323
+ }
22324
+ return out.filter((s, i) => i === 0 || s.start >= out[i - 1].start + out[i - 1].length);
22325
+ }
22326
+ function balanceInline(src, start, end) {
22327
+ for (let guard = 0;guard < 8; guard++) {
22328
+ const stack = [];
22329
+ let strayClose = null;
22330
+ for (const t of src.slice(start, end).matchAll(INLINE_TAG_RX)) {
22331
+ const name = /^<\/?([a-z]+)/i.exec(t[0])[1].toLowerCase();
22332
+ if (t[0].endsWith("/>"))
22333
+ continue;
22334
+ if (t[0].startsWith("</")) {
22335
+ if (stack[stack.length - 1] === name)
22336
+ stack.pop();
22337
+ else if (!strayClose)
22338
+ strayClose = name;
22339
+ } else
22340
+ stack.push(name);
22341
+ }
22342
+ if (!strayClose && !stack.length)
22343
+ return { start, end };
22344
+ if (strayClose) {
22345
+ const before = new RegExp(`<${strayClose}(?:\\s[^<>]*)?>$`, "i").exec(src.slice(0, start));
22346
+ if (!before)
22347
+ return null;
22348
+ start -= before[0].length;
22349
+ continue;
22350
+ }
22351
+ const open = stack[stack.length - 1];
22352
+ const after = new RegExp(`^<\\/${open}\\s*>`, "i").exec(src.slice(end));
22353
+ if (!after)
22354
+ return null;
22355
+ end += after[0].length;
22356
+ }
22357
+ return null;
22358
+ }
22261
22359
  function looksEntityEscaped(find) {
22262
22360
  return !/<[a-zA-Z/]/.test(find) && /&lt;|&gt;/.test(find);
22263
22361
  }
@@ -23136,8 +23234,10 @@ var TIMELESS_KINDS = new Set(["thread", "user", "information", "domain"]);
23136
23234
  var DATED_PROSE_LIMIT = 4;
23137
23235
  var MONTHS = "Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|June?|July?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?";
23138
23236
  var DATED_REF = new RegExp(`\\b20\\d\\d-\\d\\d-\\d\\d\\b|\\b\\d{1,2} (?:${MONTHS}) 20\\d\\d\\b|\\b(?:${MONTHS}) \\d{1,2},? 20\\d\\d\\b`, "g");
23139
- function datedReferences(html) {
23140
- return (toText(html, Number.MAX_SAFE_INTEGER).match(DATED_REF) ?? []).length;
23237
+ function datedReferences(html, opts = {}) {
23238
+ const source = opts.register ? html.replace(/<table[\s\S]*?<\/table>/gi, " ") : html;
23239
+ const spaced = source.replace(/<\/?t[dh](?=[\s>])[^>]*>/gi, " ");
23240
+ return (toText(spaced, Number.MAX_SAFE_INTEGER).match(DATED_REF) ?? []).length;
23141
23241
  }
23142
23242
  var STRUCTURE_LINT_LIMIT = 40;
23143
23243
  var SECTION_EDIT_RISK_COUNT = 25;
@@ -23402,9 +23502,10 @@ async function sweep(trilium, cfg, opts = {}) {
23402
23502
  const kindHere = ownedLabel(n, "noteType");
23403
23503
  const isCurrentState = kindHere === "information" && /^current state$/i.test(n.title.trim());
23404
23504
  if (kindHere && TIMELESS_KINDS.has(kindHere) && !isCurrentState) {
23405
- const dated = datedReferences(content);
23505
+ const register = n.attributes.some((a) => a.noteId === n.noteId && a.type === "label" && a.name === "register");
23506
+ const dated = datedReferences(content, { register });
23406
23507
  if (dated >= DATED_PROSE_LIMIT) {
23407
- report.flagged.push(`dated prose: ${n.title} [${n.noteId}] \u2014 ${dated} dated references in a timeless ${kindHere} note. ` + `Keep what is true regardless of date; move state to the domain's Current State and history or decisions to a thread entry.`);
23508
+ report.flagged.push(`dated prose: ${n.title} [${n.noteId}] \u2014 ${dated} dated references in a timeless ${kindHere} note${register ? " (outside its register tables)" : ""}. ` + `Keep what is true regardless of date; move state to the domain's Current State and history or decisions to a thread entry.` + (register ? "" : ` If the dates sit in a register's rows as evidence (a backlog, a defects list), label(noteId, "register", value="") exempts its tables.`));
23408
23509
  }
23409
23510
  }
23410
23511
  const lintKind = ownedLabel(n, "noteType");
@@ -23942,8 +24043,10 @@ function blockDiff(before, after, pad = 1, cap = 40) {
23942
24043
  const midA = a.slice(head, a.length - tail);
23943
24044
  const midB = b.slice(head, b.length - tail);
23944
24045
  const lcs = changedLines(midA, midB);
23945
- const removed = lcs ? lcs.removed : midA;
23946
- const added = lcs ? lcs.added : midB;
24046
+ const meaningful = (arr) => arr.filter((l) => !/^<\/[a-zA-Z0-9]+>$/.test(l.trim()));
24047
+ const removed = meaningful(lcs ? lcs.removed : midA);
24048
+ const added = meaningful(lcs ? lcs.added : midB);
24049
+ const tables = tableDiff(before, after);
23947
24050
  const bound = (arr) => arr.length > cap ? [...arr.slice(0, cap), `\u2026 ${arr.length - cap} more line(s)`] : arr;
23948
24051
  const delta = after.length - before.length;
23949
24052
  return {
@@ -23954,6 +24057,7 @@ function blockDiff(before, after, pad = 1, cap = 40) {
23954
24057
  removed: bound(removed),
23955
24058
  added: bound(added),
23956
24059
  contextAfter: bound(a.slice(a.length - tail, a.length - tail + pad)),
24060
+ ...tables ? { tables } : {},
23957
24061
  summary: `${lcs ? `${lcs.hunks} change(s): ` : ""}${removed.length} block(s) removed, ${added.length} added, ${delta >= 0 ? "+" : ""}${delta} characters.`
23958
24062
  };
23959
24063
  }
@@ -23991,6 +24095,57 @@ function changedLines(a, b, budget = 4000000) {
23991
24095
  }
23992
24096
  return { removed, added, hunks };
23993
24097
  }
24098
+ function tableRows2(html) {
24099
+ const rows = [];
24100
+ for (const tr of html.matchAll(/<tr(?=[\s>])[^>]*>([\s\S]*?)<\/tr>/gi)) {
24101
+ const cells = [...tr[1].matchAll(/<t[dh](?=[\s>])[^>]*>([\s\S]*?)<\/t[dh]>/gi)].map((c) => c[1].replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/\s+/g, " ").trim());
24102
+ rows.push(cells);
24103
+ }
24104
+ return rows;
24105
+ }
24106
+ function tableDiff(before, after, cap = 30) {
24107
+ const key = (r) => r.join("\x00");
24108
+ const a = tableRows2(before);
24109
+ const b = tableRows2(after);
24110
+ const inB = new Map;
24111
+ for (const r of b)
24112
+ inB.set(key(r), (inB.get(key(r)) ?? 0) + 1);
24113
+ const inA = new Map;
24114
+ for (const r of a)
24115
+ inA.set(key(r), (inA.get(key(r)) ?? 0) + 1);
24116
+ const onlyIn = (rows, other) => {
24117
+ const left = new Map(other);
24118
+ return rows.filter((r) => {
24119
+ const n = left.get(key(r)) ?? 0;
24120
+ if (n > 0) {
24121
+ left.set(key(r), n - 1);
24122
+ return false;
24123
+ }
24124
+ return true;
24125
+ });
24126
+ };
24127
+ const gone = onlyIn(a, inB);
24128
+ const fresh = onlyIn(b, inA);
24129
+ if (!gone.length && !fresh.length)
24130
+ return null;
24131
+ const changedCells = [];
24132
+ const rowsRemoved = [];
24133
+ const unpaired = [...fresh];
24134
+ for (const r of gone) {
24135
+ const i = unpaired.findIndex((x) => x[0] === r[0] && x.length === r.length);
24136
+ if (i < 0) {
24137
+ rowsRemoved.push(r.join(" | "));
24138
+ continue;
24139
+ }
24140
+ const [pair] = unpaired.splice(i, 1);
24141
+ r.forEach((cell, col) => {
24142
+ if (cell !== pair[col])
24143
+ changedCells.push({ row: r[0].slice(0, 80), column: col + 1, before: cell, after: pair[col] });
24144
+ });
24145
+ }
24146
+ const clip = (arr) => arr.slice(0, cap);
24147
+ return { changedCells: clip(changedCells), rowsAdded: clip(unpaired.map((r) => r.join(" | "))), rowsRemoved: clip(rowsRemoved) };
24148
+ }
23994
24149
 
23995
24150
  // src/elements.ts
23996
24151
  var WITHIN_TAGS = ["tr", "td", "th", "li", "p", "blockquote", "figure", "table", "ul", "ol"];
@@ -24082,6 +24237,9 @@ async function skim(trilium, ancestorNoteId, opts = {}) {
24082
24237
  };
24083
24238
  }));
24084
24239
  }
24240
+ var blockParam = {
24241
+ block: stringType().optional().describe('Read one addendum block of a record instead of the whole note: its marker time ("14:05") or 1-based position ("-1" = newest)')
24242
+ };
24085
24243
  async function readFull(trilium, id, opts = {}) {
24086
24244
  const note = await trilium.getNote(id);
24087
24245
  const contentResult = await trilium.getNoteContentResult(id, note.type).catch(() => "");
@@ -24094,6 +24252,27 @@ async function readFull(trilium, id, opts = {}) {
24094
24252
  return { ...base, content: contentResult.content, contentEncoding: "base64", mime: contentResult.mime };
24095
24253
  }
24096
24254
  const content = contentResult;
24255
+ if (opts.block) {
24256
+ const found = addendumBlock(content, opts.block);
24257
+ if (!found.matched) {
24258
+ return {
24259
+ ...base,
24260
+ content: "",
24261
+ block: opts.block,
24262
+ matched: false,
24263
+ available: found.available,
24264
+ hint: found.available.length ? `No block "${opts.block}". available= lists this record's block markers, oldest first.` : "This note has no addendum blocks; read it without block=."
24265
+ };
24266
+ }
24267
+ return {
24268
+ ...base,
24269
+ content: found.content,
24270
+ block: found.marker,
24271
+ matched: true,
24272
+ ...found.identity ? { identity: found.identity } : {},
24273
+ position: `${found.position} of ${found.of}`
24274
+ };
24275
+ }
24097
24276
  if (!opts.section) {
24098
24277
  return {
24099
24278
  ...base,
@@ -24165,18 +24344,19 @@ function registerMasterTools(server, trilium, brainRef) {
24165
24344
  // src/tools-llm.ts
24166
24345
  function registerLlmTools(server, trilium, brainRef) {
24167
24346
  const b = () => brainRef.config;
24168
- server.tool("llm", `Read an LLM singleton \u2014 responsibilities, protocols or selfcorrection \u2014 or a diary entry by id. Self-correction holds general rules learned from mistakes; read it before work that has gone wrong before (audits, large edits, completeness claims). section="<heading>" reads one section.`, {
24347
+ server.tool("llm", `Read an LLM singleton \u2014 responsibilities, protocols or selfcorrection \u2014 or a diary entry by id (block="14:05" or "-1" reads one of its addendum blocks). Self-correction holds general rules learned from mistakes; read it before work that has gone wrong before (audits, large edits, completeness claims). section="<heading>" reads one section.`, {
24169
24348
  which: enumType(["responsibilities", "protocols", "selfcorrection", "diary"]),
24170
24349
  id: stringType().optional().describe("diary only: the entry id from llm_recall (noteId is accepted as an alias)"),
24171
24350
  noteId: stringType().optional().describe("Alias of id"),
24172
24351
  section: stringType().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole note"),
24173
- occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)")
24174
- }, async ({ which, id, noteId: idAlias, section, occurrence }) => {
24352
+ occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)"),
24353
+ ...blockParam
24354
+ }, async ({ which, id, noteId: idAlias, section, occurrence, block }) => {
24175
24355
  if (which === "diary") {
24176
24356
  const entry = id ?? idAlias;
24177
24357
  if (!entry)
24178
24358
  throw new Error("Reading a diary entry needs its id \u2014 use llm_recall to find one.");
24179
- return txt(await readFull(trilium, entry, { section, occurrence }));
24359
+ return txt(await readFull(trilium, entry, { section, occurrence, block }));
24180
24360
  }
24181
24361
  const noteId = b().llm[which];
24182
24362
  if (!noteId) {
@@ -24212,20 +24392,22 @@ index passes ~24k characters, unless limit= says otherwise; with the total count
24212
24392
  date="yyyy-mm-dd" resolves straight to one day. A collection thread returns its
24213
24393
  Context plus its titled entries (alphabetical, with a lead). Read a child with memory(<child id>).
24214
24394
  section="<heading>" reads one section. index=true on a dated thread returns only child ids,
24215
- dates and block markers (no book body, no leads): the cheap answer to "what is the newest entry".`, {
24395
+ dates and block markers (no book body, no leads): the cheap answer to "what is the newest entry".
24396
+ block="14:05" (or "-1" for the newest) on a session or day entry returns that one addendum block.`, {
24216
24397
  ...idParams,
24217
24398
  date: stringType().optional().describe("Thread books only: resolve directly to this day's child note (yyyy-mm-dd)"),
24218
24399
  limit: numberType().int().positive().optional().describe("Dated threads: how many of the newest day-children to index (default 30)"),
24219
24400
  index: booleanType().optional().describe("Dated threads: ids, dates and block markers only \u2014 no book body, no block leads"),
24220
24401
  section: stringType().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole note"),
24221
- occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)")
24222
- }, async ({ id: idArg, noteId, date, limit, index, section, occurrence }) => {
24402
+ occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)"),
24403
+ ...blockParam
24404
+ }, async ({ id: idArg, noteId, date, limit, index, section, occurrence, block }) => {
24223
24405
  const id = idArg ?? noteId;
24224
24406
  if (!id)
24225
24407
  return missingId("memory");
24226
24408
  const note = await trilium.getNote(id).catch(() => null);
24227
24409
  if (!note || labelOf(note, "noteType") !== "thread")
24228
- return txt(await readFull(trilium, id, { section, occurrence }));
24410
+ return txt(await readFull(trilium, id, { section, occurrence, block }));
24229
24411
  if (isCollectionThread(note)) {
24230
24412
  const kids = await trilium.searchNotes("#noteType=threadEntry", { ancestorNoteId: id, fastSearch: true, limit: 500 }).catch(() => ({ results: [] }));
24231
24413
  const entries = await Promise.all(kids.results.filter((c) => c.parentNoteIds.includes(id)).sort((a, z) => a.title.localeCompare(z.title)).map(async (c) => ({
@@ -24240,7 +24422,7 @@ dates and block markers (no book body, no leads): the cheap answer to "what is t
24240
24422
  if (date) {
24241
24423
  const child = await trilium.searchNotes(`#noteType=threadEntry #created='${date}'`, { ancestorNoteId: id, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
24242
24424
  if (child.results[0])
24243
- return txt(await readFull(trilium, child.results[0].noteId, { section, occurrence }));
24425
+ return txt(await readFull(trilium, child.results[0].noteId, { section, occurrence, block }));
24244
24426
  return txt({ id, title: note.title, kind: "thread", note: `No entry for ${date}.` });
24245
24427
  }
24246
24428
  const shown = limit ?? MEMORY_INDEX_DEFAULT;
@@ -24372,6 +24554,35 @@ var txt3 = (obj) => ({
24372
24554
  content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }]
24373
24555
  });
24374
24556
  var today = () => localToday();
24557
+ function applyFindEdit(source, needle, replacement, index) {
24558
+ const spans = (hits, matchMode) => {
24559
+ if (index === undefined) {
24560
+ let out = "";
24561
+ let cursor = 0;
24562
+ for (const h of hits) {
24563
+ out += source.slice(cursor, h.start) + replacement;
24564
+ cursor = h.start + h.length;
24565
+ }
24566
+ return { html: out + source.slice(cursor), count: hits.length, matchMode };
24567
+ }
24568
+ const hit = hits[index - 1];
24569
+ if (!hit)
24570
+ return null;
24571
+ return { html: source.slice(0, hit.start) + replacement + source.slice(hit.start + hit.length), count: 1, matchMode };
24572
+ };
24573
+ const exact = [];
24574
+ for (let at = source.indexOf(needle);at !== -1; at = source.indexOf(needle, at + needle.length)) {
24575
+ exact.push({ start: at, length: needle.length });
24576
+ }
24577
+ if (exact.length)
24578
+ return spans(exact, "exact");
24579
+ const rx = tolerantFindRegex(needle);
24580
+ const tolerant = rx ? [...source.matchAll(rx)].map((m) => ({ start: m.index, length: m[0].length })) : [];
24581
+ if (tolerant.length)
24582
+ return spans(tolerant, "attribute-tolerant");
24583
+ const inline = inlineTolerantSpans(source, needle);
24584
+ return inline.length ? spans(inline, "inline-tolerant") : null;
24585
+ }
24375
24586
  var err = (code, detail, hint) => txt3({ error: code, detail, ...hint ? { hint } : {} });
24376
24587
  var labelOf2 = (n, name) => n.attributes.find((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === name)?.value;
24377
24588
  var hasLabel = (n, name) => n.attributes.some((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === name);
@@ -24540,7 +24751,7 @@ ${block}` }));
24540
24751
  function collectionAppendRefusal(book) {
24541
24752
  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.`);
24542
24753
  }
24543
- server.tool("start", `Boot BrainLLM \u2014 once, before responding to the first message. Runs lite maintenance, opens today's diary and session notes, and returns: date and weekday, preferences and protocols in full, the other singletons as section headings with a preview (depth="full" inlines everything), today's diary and session ids, active and dormant threads with idle ages, the previous session's summary, notes changed since, and newDay on the first session of a day.`, {
24754
+ server.tool("start", `Opens a BrainLLM session: runs the lite maintenance sweep, creates today's diary and session notes if they do not exist yet, and returns the date and weekday, the user's stored preferences and protocols in full, the other singletons as section headings with a preview (depth="full" inlines everything), today's diary and session ids, active and dormant threads with idle ages, the previous session's summary, notes changed since, and newDay on the first session of a day.`, {
24544
24755
  depth: enumType(["digest", "full"]).optional().describe('Singleton detail: "digest" (default \u2014 section headings + preview + size) or "full" (every singleton inline; token-heavy)')
24545
24756
  }, async ({ depth }) => {
24546
24757
  const cfg = b();
@@ -24633,9 +24844,9 @@ ${block}` }));
24633
24844
  hygiene: { scanned: hygiene.scanned, fixed: hygiene.fixed.length, transitions: hygiene.transitions, flagged: hygiene.flagged, ...hygiene.suppressed ? { suppressed: hygiene.suppressed } : {} }
24634
24845
  });
24635
24846
  });
24636
- server.tool("session", `Mandatory pre-close step: call before close(). Returns the six singletons as {id, lastModified} stubs (full=true inlines them), today's diary as {id, blocks, size}, runs the lite maintenance sweep, and returns pending= (what each remaining step actually has to do), audit= (do the singletons agree, and do the LLM singletons still serve the master ones) and next[]. Idempotent.
24847
+ server.tool("session", `Pre-close review. Returns the six singletons as {id, lastModified} stubs (full=true inlines them), today's diary as {id, blocks, size}, the maintained notes changed today with their write counts, runs the lite maintenance sweep, and returns pending= (what each remaining close step has to do), audit= (do the singletons agree, and do the LLM singletons still serve the master ones) and next[]. Records the session step of the close gate. Idempotent.
24637
24848
 
24638
- Protocol: 1. update master singletons with what the session taught about the user; 2. update LLM singletons with what it taught about yourself; 3. addendum(); 4. maintain(); 5. remarks(); 6. diary(); 7. close(). close() refuses until 3\u20136 ran and session \u2192 remarks \u2192 diary held. scope="agent" drops steps 1\u20132 for scoped or autonomous runs.`, {
24849
+ The close sequence next[] lays out: singleton updates from the session, then addendum(), maintain(), remarks(), diary() and close(). close() checks that addendum, maintain, remarks and diary ran, in the order session \u2192 remarks \u2192 diary. scope="agent" omits the singleton updates for scoped or autonomous runs.`, {
24639
24850
  date: stringType().optional().describe("ISO date YYYY-MM-DD (default: today)"),
24640
24851
  full: booleanType().optional().describe("Inline every singleton's content and the diary body (default: stubs only)"),
24641
24852
  scope: enumType(["interactive", "agent"]).optional().describe(`"agent" for a scoped/autonomous run whose brief excludes the user's personal singletons \u2014 omits those steps from next[] (default: "interactive")`),
@@ -24693,6 +24904,12 @@ Protocol: 1. update master singletons with what the session taught about the use
24693
24904
  selfcorrection
24694
24905
  };
24695
24906
  const touchedToday = Object.keys(singletonStubs).filter((k) => singletonStubs[k]?.lastModified === d);
24907
+ const cutoff = `${d} 00:00`;
24908
+ const changedToday = await trilium.searchNotes(`#noteType note.dateModified >= '${cutoff}'`, { ancestorNoteId: cfg.root, limit: 100, orderBy: "dateModified", orderDirection: "desc" }).then((r) => r.results.filter((n) => !isRecordNote(n))).catch(() => null);
24909
+ const changedNotes = changedToday ? await Promise.all(changedToday.slice(0, 20).map(async (n) => {
24910
+ const revisions = await trilium.getNoteRevisions(n.noteId).catch(() => []);
24911
+ return { id: n.noteId, title: n.title, kind: ownedLabel(n, "noteType") ?? "", writes: revisionsSince(revisions, cutoff).length };
24912
+ })) : null;
24696
24913
  const pending = {
24697
24914
  addendums: pendingAddendums === null ? "unknown" : pendingAddendums,
24698
24915
  maintenanceFlags: hygiene?.flagged.length ?? 0,
@@ -24710,6 +24927,12 @@ Protocol: 1. update master singletons with what the session taught about the use
24710
24927
  diary: diaryEntry,
24711
24928
  maintenance: hygiene ? { scanned: hygiene.scanned, fixed: hygiene.fixed.length, transitions: hygiene.transitions, flagged: hygiene.flagged, ...hygiene.suppressed ? { suppressed: hygiene.suppressed } : {} } : "skipped",
24712
24929
  pending,
24930
+ changedToday: changedNotes ? {
24931
+ notes: changedToday.length,
24932
+ ...changedToday.length > changedNotes.length ? { shown: changedNotes.length } : {},
24933
+ list: changedNotes,
24934
+ check: changedNotes.length ? 'Which measured figures did these writes change? Each must now agree everywhere it is recorded: diff(since="today") shows the changes, consistency() finds other notes still asserting the old value.' : "No maintained note changed today."
24935
+ } : "unavailable",
24713
24936
  audit: scoped ? undefined : {
24714
24937
  consistency: "Read every singleton and check for ambiguity, internal contradiction, or claims that disagree across them. Fix what you find with revise() BEFORE close(), so the log records a brain that already agrees with itself.",
24715
24938
  alignment: "Then check correlation, not just agreement: do responsibilities and protocols actually serve what biography, goals and preferences describe? A protocol can be perfectly consistent and still be serving a goal that has moved.",
@@ -24724,12 +24947,12 @@ Protocol: 1. update master singletons with what the session taught about the use
24724
24947
  scoped ? `Call addendum() \u2014 fold only what is in your lane; leaving out-of-scope addendums for the next interactive session is correct, and the call itself satisfies the gate. (${pending.addendums} note(s) currently carry addendum markers.)` : pending.addendums === 0 ? "Call addendum() \u2014 0 notes currently carry addendum markers, so this should come back clean; the call still satisfies the gate." : `Call addendum() \u2014 ${pending.addendums} note(s) carry addendum markers; fold each into its section.`,
24725
24948
  scoped ? "Call maintain() \u2014 audit brain hygiene; pass domain= to keep the flags in your lane." : pending.maintenanceFlags === 0 ? "Call maintain() \u2014 the lite sweep just ran clean, so this is a formality unless you want deep=true." : `Call maintain() \u2014 the lite sweep already flagged ${pending.maintenanceFlags} item(s), listed above.`,
24726
24949
  "Call remarks() \u2014 get the diary cues: your experience, opinions, and existence this session, plus BrainLLM remarks.",
24727
- "Call diary() \u2014 write the day's unfiltered record with the cues in hand; the gate counts it only after remarks().",
24950
+ "Call diary() \u2014 write the day's record with the cues in hand; the gate counts it only after remarks().",
24728
24951
  "Call close() \u2014 commit the session log (mandatory, last). Refuses until every step ran and session \u2192 remarks \u2192 diary held; pass force=true only when a skipped step genuinely has nothing to log."
24729
24952
  ]
24730
24953
  });
24731
24954
  });
24732
- server.tool("remarks", `Diary cues, called after session() and before diary(): an experience bank (the session from the inside, your opinions, being what you are here) and a BrainLLM bank (walls, bugs, usability, roadmap). Answer them as prose in diary(); skip a cue rather than pad it. The call marks its close-gate step.`, {}, async () => {
24955
+ server.tool("remarks", `Returns writing prompts for the diary: questions about how the session went and about BrainLLM itself (problems, usability, ideas). Records the remarks step of the close gate, which close() checks came after session() and before diary(). Writes no note content.`, {}, async () => {
24733
24956
  const cfg = b();
24734
24957
  if (!cfg.root)
24735
24958
  return txt3({ status: "uninitialized", action: "Run bootstrap first." });
@@ -24754,7 +24977,7 @@ Protocol: 1. update master singletons with what the session taught about the use
24754
24977
  ]
24755
24978
  });
24756
24979
  });
24757
- server.tool("close", `Commit the session log \u2014 once, last, after the session() protocol. Refuses unless session(), addendum(), maintain(), remarks() and diary() ran and session \u2192 remarks \u2192 diary held (force=true bypasses a step with genuinely nothing to do; bypassed steps are reported). identity= is required. Writes a timestamped block to today's [yyyy-mm-dd] session note (title= becomes its heading), regenerates the daily log, backs up the database and resets the gate. continuing=true is a second close the same day, skipping the ceremonial re-run.`, {
24980
+ server.tool("close", `Writes the session log, normally the last call of a session. Refuses unless session(), addendum(), maintain(), remarks() and diary() ran and session \u2192 remarks \u2192 diary held (force=true bypasses a step with genuinely nothing to do; bypassed steps are reported). identity= is required. Writes a timestamped block to today's [yyyy-mm-dd] session note (title= becomes its heading), regenerates the daily log, backs up the database and resets the gate. continuing=true is a second close the same day.`, {
24758
24981
  summary: stringType().describe("What happened this session \u2014 factual, concise prose"),
24759
24982
  title: stringType().optional().describe("Short session title \u2014 appears as an <h2> heading above Summary"),
24760
24983
  identity: stringType().describe(`Identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" \u2014 rendered as the block's h3 (required)`),
@@ -24881,13 +25104,78 @@ ${contentBlock}` }));
24881
25104
  const backupName = name ?? rollingBackupName(d);
24882
25105
  try {
24883
25106
  await trilium.createBackup(backupName);
24884
- return txt3({ ok: true, backup: backupName, backupStatus: "completed", date: d });
25107
+ const named = name && name !== rollingBackupName(d);
25108
+ if (named) {
25109
+ const cfg = b();
25110
+ (cfg.backups ??= {})[backupName] = d;
25111
+ try {
25112
+ saveConfig(cfg);
25113
+ } catch {}
25114
+ }
25115
+ const kept = Object.keys(b().backups ?? {}).length;
25116
+ return txt3({
25117
+ ok: true,
25118
+ backup: backupName,
25119
+ backupStatus: "completed",
25120
+ date: d,
25121
+ ...named ? { namedBackups: kept, note: `A named backup stays on Trilium's volume until deleted there. ${kept} named backup(s) recorded; health() reports them.` } : {}
25122
+ });
24885
25123
  } catch (error) {
24886
25124
  return txt3({ ok: false, backup: backupName, backupStatus: "failed", error: error instanceof Error ? error.message : String(error), date: d });
24887
25125
  }
24888
25126
  });
24889
- server.tool("diary", `Write to today's diary: your unfiltered first-person record of the session \u2014 experience, opinions, remarks on existing here \u2014 then remarks on BrainLLM itself. identity= required. Every write lands as a timestamped block. The close gate counts it only when its last call came after session() and remarks().`, {
24890
- body: stringType().describe("What to record \u2014 first-person prose, honest and unfiltered"),
25127
+ const SIZE_BANDS = [
25128
+ { min: 1e7, max: 50000000 },
25129
+ { min: 1e6, max: 1e7 },
25130
+ { min: 1e5, max: 1e6 },
25131
+ { min: 1e4, max: 1e5 }
25132
+ ];
25133
+ const NAMED_BACKUP_WARN = 6;
25134
+ server.tool("health", `The brain's storage and runtime health: Trilium's version, an estimate of the database's size (content, attachments and revisions), the heaviest notes with their revision counts, and the named backups this server has taken. Each backup is a full database copy kept on Trilium's volume, which ETAPI cannot list, so growth there is flagged here rather than discovered when the disk fills. Read-only.`, {}, async () => {
25135
+ const cfg = b();
25136
+ const info = await trilium.getAppInfo().catch(() => null);
25137
+ const count = (q) => trilium.searchNotes(q, { limit: 1e5, fastSearch: true, includeArchivedNotes: true }).then((r) => r.results.length).catch(() => null);
25138
+ const bandCounts = await Promise.all(SIZE_BANDS.map((band) => count(`note.contentAndAttachmentsAndRevisionsSize >= ${band.min} AND note.contentAndAttachmentsAndRevisionsSize < ${band.max}`)));
25139
+ const typed = await count("#noteType");
25140
+ let estimate = 0;
25141
+ SIZE_BANDS.forEach((band, i) => {
25142
+ estimate += (bandCounts[i] ?? 0) * Math.sqrt(band.min * band.max);
25143
+ });
25144
+ const heavy = await trilium.searchNotes("note.contentAndAttachmentsAndRevisionsSize >= 1000000", {
25145
+ limit: 5,
25146
+ orderBy: "contentAndAttachmentsAndRevisionsSize",
25147
+ orderDirection: "desc",
25148
+ includeArchivedNotes: true
25149
+ }).then((r) => r.results).catch(() => []);
25150
+ const heaviest = await Promise.all(heavy.map(async (n) => {
25151
+ const revisions = await trilium.getNoteRevisions(n.noteId).catch(() => []);
25152
+ const revisionChars = revisions.reduce((sum, r) => sum + (r.contentLength ?? 0), 0);
25153
+ return { id: n.noteId, title: n.title, revisions: revisions.length, revisionChars };
25154
+ }));
25155
+ const named = Object.entries(cfg.backups ?? {}).sort((x, y) => x[1].localeCompare(y[1]));
25156
+ const mb = (n) => `${(n / 1e6).toFixed(1)} MB`;
25157
+ const flags = [];
25158
+ if (named.length >= NAMED_BACKUP_WARN)
25159
+ flags.push(`${named.length} named backups recorded, each a full database copy (~${mb(estimate)}) kept until deleted on Trilium's volume. Delete the ones no longer needed there.`);
25160
+ for (const h of heaviest) {
25161
+ if (h.revisions >= 200)
25162
+ flags.push(`${h.title} [${h.id}] carries ${h.revisions} revisions (${mb(h.revisionChars)}). Revision history is most of its weight; Trilium's revision-snapshot settings or a split bound it.`);
25163
+ }
25164
+ return txt3({
25165
+ trilium: info ? { appVersion: info.appVersion, dbVersion: info.dbVersion, buildDate: info.buildDate } : { error: "app-info unreachable" },
25166
+ notes: { typed: typed ?? undefined, bySize: SIZE_BANDS.map((band, i) => ({ atLeast: mb(band.min), below: mb(band.max), notes: bandCounts[i] })) },
25167
+ estimatedSize: `${mb(estimate)} (content, attachments and revisions of notes over 10 kB; an estimate from size bands, not a measurement)`,
25168
+ heaviest,
25169
+ backups: {
25170
+ rollingSlots: "7 (brainllm-mon \u2026 brainllm-sun), overwritten weekly by close()",
25171
+ named: named.map(([name, date]) => ({ name, date })),
25172
+ note: "Only backups taken through this server are listed. Each is roughly the size of the database."
25173
+ },
25174
+ ...flags.length ? { flags } : { flags: [], status: "No growth findings." }
25175
+ });
25176
+ });
25177
+ server.tool("diary", `Appends a timestamped block to today's diary note: the assistant's own first-person account of the session, followed by notes on BrainLLM. identity= is required. The close gate counts the diary only when its latest block was written after session() and remarks().`, {
25178
+ body: stringType().describe("The diary text, in the first person"),
24891
25179
  identity: stringType().describe(`Identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" \u2014 rendered as the block's h3 (required)`),
24892
25180
  icon: stringType().optional().describe("Display icon for the day's entry \u2014 a boxicons class or bare name; normalized server-side"),
24893
25181
  date: stringType().optional().describe("ISO date YYYY-MM-DD (default: today)")
@@ -25508,19 +25796,23 @@ ${block}`), d);
25508
25796
  ...total === 0 && !knowledgeDomain ? { note: `No content found for "${slug}". Create a Knowledge domain with remember(kind="information", domain="${name}") or tag notes with topics=["${slug}"].` } : {}
25509
25797
  });
25510
25798
  });
25511
- server.tool("read", `Several note bodies in one round trip: ids=[\u2026] (up to 10) \u2192 {notes:[{id, title, kind, content, relations}]}. For one note prefer the kinded read; for a huge note prefer a section= read.`, {
25512
- ids: arrayType(stringType()).min(1).max(10).describe("Note ids to read \u2014 one body per id, one round trip (cap 10)")
25513
- }, async ({ ids }) => {
25514
- const notes = await Promise.all(ids.map(async (id) => {
25799
+ const READ_BUDGET = 80000;
25800
+ server.tool("read", `Several note bodies in one round trip: ids=[\u2026] (up to 10) \u2192 {notes:[{id, title, kind, content, relations}]}. Bodies are returned in order until about 80k characters; the rest come back under deferred[] with their sizes, to read in a second call. 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; for a huge note prefer a section= read.`, {
25801
+ ids: arrayType(stringType()).min(1).max(10).describe("Note ids to read \u2014 one body per id, one round trip (cap 10)"),
25802
+ text: booleanType().optional().describe("Return readable plain text instead of stored HTML (default false)")
25803
+ }, async ({ ids, text }) => {
25804
+ const fetched = await Promise.all(ids.map(async (id) => {
25515
25805
  try {
25516
25806
  const note = await trilium.getNote(id);
25517
25807
  const contentResult = await trilium.getNoteContentResult(id, note.type);
25518
25808
  const relations = relationSnippet(note);
25809
+ const raw = typeof contentResult === "string" ? contentResult : contentResult.content;
25810
+ const content = text && typeof contentResult === "string" ? readableText(raw) : raw;
25519
25811
  return {
25520
25812
  id,
25521
25813
  title: note.title,
25522
25814
  kind: ownedLabel(note, "noteType") ?? undefined,
25523
- content: typeof contentResult === "string" ? contentResult : contentResult.content,
25815
+ content,
25524
25816
  ...typeof contentResult === "string" ? {} : { contentEncoding: contentResult.encoding, mime: contentResult.mime },
25525
25817
  ...relations ? { relations } : {}
25526
25818
  };
@@ -25528,14 +25820,102 @@ ${block}`), d);
25528
25820
  return { id, missing: true };
25529
25821
  }
25530
25822
  }));
25531
- return txt3({ count: notes.length, notes });
25823
+ const notes = [];
25824
+ const deferred = [];
25825
+ let used = 0;
25826
+ for (const n of fetched) {
25827
+ const size = "content" in n ? n.content.length : 0;
25828
+ if ("content" in n && notes.length && used + size > READ_BUDGET) {
25829
+ deferred.push({ id: n.id, title: n.title, size });
25830
+ continue;
25831
+ }
25832
+ notes.push(n);
25833
+ used += size;
25834
+ }
25835
+ return txt3({
25836
+ count: notes.length,
25837
+ notes,
25838
+ ...deferred.length ? { deferred, hint: `${deferred.length} note(s) held back to keep this result under ~${READ_BUDGET / 1000}k characters. Read them with read(ids=[\u2026]) again${text ? "" : ", or pass text=true, which is smaller"}.` } : {}
25839
+ });
25532
25840
  });
25841
+ const reviseDomain = async (name, plan, dry, d) => {
25842
+ if (plan.some((e) => !e.find))
25843
+ return err("missing_param", "Every find must be non-empty.");
25844
+ const cfg = b();
25845
+ const slug = slugify(name);
25846
+ const CAP = 300;
25847
+ const search = (q) => trilium.searchNotes(q, { ancestorNoteId: cfg.root, limit: CAP, fastSearch: true }).then((r) => r.results).catch(() => []);
25848
+ const [byDomain, byTopic] = await Promise.all([search(`#domain='${slug}'`), search(`#topic='${slug}'`)]);
25849
+ const RECORDS = new Set(["session", "diary", "log", "threadEntry", "domain"]);
25850
+ const seen = new Set;
25851
+ const targets = [...byDomain, ...byTopic].filter((n) => {
25852
+ if (seen.has(n.noteId))
25853
+ return false;
25854
+ seen.add(n.noteId);
25855
+ const kind = ownedLabel(n, "noteType");
25856
+ return !!kind && !RECORDS.has(kind) && !isContainer(cfg, n.noteId) && !hasLabel(n, "archived");
25857
+ });
25858
+ if (!targets.length)
25859
+ return txt3({ ok: true, domain: name, slug, scanned: 0, note: `No maintained notes carry #domain or #topic "${slug}".` });
25860
+ const changes = [];
25861
+ let total = 0;
25862
+ for (const n of targets) {
25863
+ let content;
25864
+ try {
25865
+ content = await trilium.getNoteContent(n.noteId);
25866
+ } catch {
25867
+ continue;
25868
+ }
25869
+ let working = content;
25870
+ let count = 0;
25871
+ const previews = [];
25872
+ for (const e of plan) {
25873
+ const applied = applyFindEdit(working, e.find, e.body, e.nth);
25874
+ if (!applied)
25875
+ continue;
25876
+ if (previews.length < 3) {
25877
+ const plain = toText(working, Number.MAX_SAFE_INTEGER);
25878
+ const at = plain.indexOf(toText(e.find, Number.MAX_SAFE_INTEGER).slice(0, 40));
25879
+ previews.push(at >= 0 ? `\u2026${plain.slice(Math.max(0, at - 50), at + 90)}\u2026` : toText(e.find, 120));
25880
+ }
25881
+ working = applied.html;
25882
+ count += applied.count;
25883
+ }
25884
+ if (!count || working === content)
25885
+ continue;
25886
+ total += count;
25887
+ const row = { id: n.noteId, title: n.title, replaced: count, previews };
25888
+ if (!dry) {
25889
+ await trilium.createRevision(n.noteId).catch(() => null);
25890
+ const clean = sanitizeHtml(working);
25891
+ await trilium.updateNoteContent(n.noteId, bumpLastUpdated(clean.html, d).html);
25892
+ await trilium.updateLabelValue(n.noteId, "updated", d);
25893
+ row.written = true;
25894
+ }
25895
+ changes.push(row);
25896
+ }
25897
+ return txt3({
25898
+ ok: true,
25899
+ domain: name,
25900
+ slug,
25901
+ dryRun: dry,
25902
+ scanned: targets.length,
25903
+ notesMatched: changes.length,
25904
+ replaced: total,
25905
+ date: d,
25906
+ ...targets.length >= CAP ? { truncated: `A query hit its ${CAP}-note cap; some notes in the domain were not scanned.` } : {},
25907
+ changes,
25908
+ next: dry ? changes.length ? "Nothing was written. Re-run with dryRun=false to apply exactly these changes." : "No note matched; nothing would change." : "Written, with a revision taken first on each note. Run consistency() on the old and new wording to confirm nothing outside the domain still asserts the old form."
25909
+ });
25910
+ };
25533
25911
  server.tool("revise", `Edit a note by id. A revision is taken first; "Last updated" lines are bumped on content writes.
25534
25912
 
25535
25913
  Modes: default append (a dated addendum \u2014 right only for records; a dated thread's append lands in today's child), mode=replace (whole body), section="<heading>" (replace that section's whole body, h2\u2192h3\u2192h4, occurrence= for repeats), section= + mode=before|after (insert a sibling block around the whole section), mode=prepend (top of the section body), mode=remove (delete the section). find="<exact stored text>" replaces every occurrence (nth= for one); edits=[{find, body}] applies several in one write. 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. A replacement equal to its match writes nothing and reports unchanged. title= composes with every mode; retitling a domain book cascades its #domain slug.
25536
25914
 
25537
25915
  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.`, {
25538
- noteId: stringType().describe("Note to update"),
25916
+ noteId: stringType().optional().describe("Note to update (omit only with domain=)"),
25917
+ domain: stringType().optional().describe("Instead of noteId: apply find=/edits= to every maintained note in this domain (#domain or #topic slug). Records (sessions, diary, logs, dated thread entries) are never touched. Dry run unless dryRun=false"),
25918
+ dryRun: booleanType().optional().describe("domain=: preview the matches without writing (default true). Re-run with dryRun=false to apply"),
25539
25919
  body: stringType().optional().describe("Content to add/replace: plain text, markdown, or HTML. Send real tags: entity-escaped markup (&lt;p&gt;) is decoded and reported in sanitized[]. With find=, the raw replacement string (no conversion)."),
25540
25920
  title: stringType().optional().describe("New title (normalized server-side)"),
25541
25921
  section: stringType().optional().describe("Target a section by heading text (h2/h3/h4, in that order); omit for whole-note append/replace"),
@@ -25553,7 +25933,21 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25553
25933
  identity: stringType().optional().describe('Appending to a dated thread: the identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" (required)'),
25554
25934
  icon: stringType().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name; normalized server-side'),
25555
25935
  date: stringType().optional().describe("ISO date (default: today)")
25556
- }, async ({ noteId, body, title, section, occurrence, mode, find, nth, within, edits, identity, icon, date, strict }) => {
25936
+ }, async ({ noteId: noteIdArg, domain, dryRun, body, title, section, occurrence, mode, find, nth, within, edits, identity, icon, date, strict }) => {
25937
+ if (domain !== undefined) {
25938
+ if (noteIdArg)
25939
+ return err("conflicting_params", "Pass noteId= or domain=, not both.", "domain= edits every note in the domain; noteId= edits one.");
25940
+ if (find === undefined && edits === undefined)
25941
+ return err("missing_param", "domain= needs find= (with body=) or edits=.", 'e.g. revise(domain="myClerkBook", find="Code Priority Queue", body="Backlog")');
25942
+ if (within || section || title || mode || identity || icon)
25943
+ return err("conflicting_params", "domain= takes only find=/body=, edits=, nth= and dryRun=.", "Use noteId= for element, section, title or icon edits.");
25944
+ return reviseDomain(domain, edits ?? [{ find, body: body ?? "", nth }], dryRun ?? true, checkedDate(date));
25945
+ }
25946
+ if (dryRun !== undefined)
25947
+ return err("conflicting_params", "dryRun= applies only to revise(domain=); nothing was written.", "For one note, read it first (or outline()) and then revise without dryRun. diff(noteId) shows what a write changed.");
25948
+ if (!noteIdArg)
25949
+ return err("missing_param", "revise needs noteId= (or domain= for a domain-wide find/replace).", "Pass the id of the note to edit.");
25950
+ const noteId = noteIdArg;
25557
25951
  if (isContainer(b(), noteId))
25558
25952
  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.");
25559
25953
  if (within && find === undefined)
@@ -25600,38 +25994,6 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25600
25994
  if (e.body === undefined && !within)
25601
25995
  return err("missing_param", `edits[${i}] requires body as the replacement string.`, 'Call revise(noteId, find="<exact text>", body="<replacement>").');
25602
25996
  }
25603
- const applyEdit = (source, needle, replacement, index) => {
25604
- const spans = (hits, matchMode) => {
25605
- if (index === undefined) {
25606
- let out = "";
25607
- let cursor = 0;
25608
- for (const h of hits) {
25609
- out += source.slice(cursor, h.start) + replacement;
25610
- cursor = h.start + h.length;
25611
- }
25612
- return { html: out + source.slice(cursor), count: hits.length, matchMode };
25613
- }
25614
- const hit = hits[index - 1];
25615
- if (!hit)
25616
- return null;
25617
- return {
25618
- html: source.slice(0, hit.start) + replacement + source.slice(hit.start + hit.length),
25619
- count: 1,
25620
- matchMode
25621
- };
25622
- };
25623
- const exact = [];
25624
- for (let at = source.indexOf(needle);at !== -1; at = source.indexOf(needle, at + needle.length)) {
25625
- exact.push({ start: at, length: needle.length });
25626
- }
25627
- if (exact.length)
25628
- return spans(exact, "exact");
25629
- const rx = tolerantFindRegex(needle);
25630
- if (!rx)
25631
- return null;
25632
- const tolerant = [...source.matchAll(rx)].map((m) => ({ start: m.index, length: m[0].length }));
25633
- return tolerant.length ? spans(tolerant, "attribute-tolerant") : null;
25634
- };
25635
25997
  const missHint = (needle, source, alreadyConsumed) => {
25636
25998
  if (alreadyConsumed)
25637
25999
  return `Not found \u2014 but an EARLIER edit in this same edits= array already replaced this exact string. The array is applied in order against one body, so the second pass had nothing left to match. This is the expected result, not a failure.`;
@@ -25639,7 +26001,7 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25639
26001
  return `Not found \u2014 the search string carries escaped markup (&lt;\u2026&gt;) while note bodies store real tags. Pass the tag literally, e.g. "<h3>Typography</h3>".`;
25640
26002
  if (spansBlockBoundary(needle))
25641
26003
  return `Not found \u2014 this string spans an element boundary (a closing tag followed by an opening one). Anchor the find INSIDE a single element instead, or target the heading directly with section= (with mode="before"/"after" to insert around it).`;
25642
- return `Not found in the note body (exact or attribute-tolerant) \u2014 already replaced on a retry, or the text genuinely differs.`;
26004
+ return `Not found in the note body (exact, attribute-tolerant or ignoring inline tags) \u2014 already replaced on a retry, or the text genuinely differs.`;
25643
26005
  };
25644
26006
  const missContext = (needle, source) => {
25645
26007
  const near = nearestContext(source, needle);
@@ -25658,7 +26020,8 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25658
26020
  for (let at = current.indexOf(find);at !== -1; at = current.indexOf(find, at + find.length))
25659
26021
  exact.push({ start: at, length: find.length });
25660
26022
  const rx = exact.length ? null : tolerantFindRegex(find);
25661
- const hits = exact.length ? exact : rx ? [...current.matchAll(rx)].map((m) => ({ start: m.index, length: m[0].length })) : [];
26023
+ const tolerantHits = rx ? [...current.matchAll(rx)].map((m) => ({ start: m.index, length: m[0].length })) : [];
26024
+ const hits = exact.length ? exact : tolerantHits.length ? tolerantHits : inlineTolerantSpans(current, find);
25662
26025
  const res = editWithin(current, hits, within, action, body ?? "", nth);
25663
26026
  if (!res.ok) {
25664
26027
  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.` : `The anchor sits in ${res.matches} different <${within}> elements. Lengthen it until it is unique, or pass nth= (1-based, in document order) \u2014 elements[] previews them.`;
@@ -25704,7 +26067,7 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25704
26067
  let total = 0;
25705
26068
  const consumed = new Set;
25706
26069
  for (const e of plan) {
25707
- const applied = applyEdit(working, e.find, e.body, e.nth);
26070
+ const applied = applyFindEdit(working, e.find, e.body, e.nth);
25708
26071
  if (!applied) {
25709
26072
  const alreadyConsumed = consumed.has(e.find);
25710
26073
  results.push({
@@ -26045,7 +26408,7 @@ ${result.html}`);
26045
26408
  ...warnings.length ? { sanitized: warnings } : {}
26046
26409
  });
26047
26410
  });
26048
- server.tool("label", `Set or remove one label (remove=true). Refused on containers. noteType cannot be changed or removed, but can be set on an untyped note to repair it. status must be one of ${Statuses.join(" | ")}; domain and topic are slugged. Bumps updated unless you are setting it.`, {
26411
+ server.tool("label", `Set or remove one label (remove=true). Refused on containers. noteType cannot be changed or removed, but can be set on an untyped note to repair it. status must be one of ${Statuses.join(" | ")}; domain and topic are slugged. register (value "") marks a register \u2014 a maintained table whose rows carry evidence dates \u2014 so maintain's dated-prose lint skips its tables. Bumps updated unless you are setting it.`, {
26049
26412
  noteId: stringType().describe("Note to edit"),
26050
26413
  name: stringType().describe("Label name, no # prefix (e.g. status, domain, topic, created)"),
26051
26414
  value: stringType().optional().describe("New value \u2014 required unless remove=true"),
@@ -27401,6 +27764,7 @@ var TOOL_ANNOTATIONS = {
27401
27764
  consistency: READ,
27402
27765
  assembly: READ,
27403
27766
  diff: READ,
27767
+ health: READ,
27404
27768
  master: READ,
27405
27769
  master_recall: READ,
27406
27770
  llm: READ,
@@ -27419,11 +27783,11 @@ var TOOL_ANNOTATIONS = {
27419
27783
  claim: APPEND,
27420
27784
  remember: WRITE,
27421
27785
  diary: APPEND,
27422
- revise: WRITE,
27786
+ revise: DESTRUCTIVE,
27423
27787
  split: WRITE,
27424
27788
  close: APPEND,
27425
- connect: WRITE,
27426
- label: WRITE,
27789
+ connect: DESTRUCTIVE,
27790
+ label: DESTRUCTIVE,
27427
27791
  attach: WRITE,
27428
27792
  backup: APPEND,
27429
27793
  bootstrap: WRITE,
@@ -27449,16 +27813,16 @@ var TOOL_ANNOTATIONS = {
27449
27813
  get_year_note: WRITE,
27450
27814
  get_inbox_note: WRITE,
27451
27815
  create_note: APPEND,
27452
- patch_note: WRITE,
27453
- update_note_content: WRITE,
27816
+ patch_note: DESTRUCTIVE,
27817
+ update_note_content: DESTRUCTIVE,
27454
27818
  clone_note: APPEND,
27455
27819
  move_note: WRITE,
27456
27820
  undelete_note: WRITE,
27457
27821
  add_label: WRITE,
27458
27822
  add_relation: WRITE,
27459
- update_attribute: WRITE,
27823
+ update_attribute: DESTRUCTIVE,
27460
27824
  create_attachment: APPEND,
27461
- update_attachment: WRITE,
27825
+ update_attachment: DESTRUCTIVE,
27462
27826
  create_revision: APPEND,
27463
27827
  create_backup: APPEND,
27464
27828
  delete_note: DESTRUCTIVE,
@@ -27466,6 +27830,86 @@ var TOOL_ANNOTATIONS = {
27466
27830
  delete_branch: DESTRUCTIVE,
27467
27831
  delete_attachment: DESTRUCTIVE
27468
27832
  };
27833
+ var TOOL_TITLES = {
27834
+ start: "Open the brain for this session",
27835
+ session: "Review the session before closing",
27836
+ remarks: "Get diary prompts",
27837
+ close: "Close the session and write its log",
27838
+ backup: "Back up the brain",
27839
+ health: "Check storage and runtime health",
27840
+ diary: "Write the daily diary",
27841
+ remember: "Remember something",
27842
+ recall: "Search the brain",
27843
+ domain: "Read everything about an area",
27844
+ read: "Read several notes",
27845
+ revise: "Edit a note",
27846
+ resolve: "Resolve a thread",
27847
+ split: "Split a note into two",
27848
+ withdraw: "Reopen a thread",
27849
+ label: "Set or remove a label",
27850
+ connect: "Link two notes",
27851
+ explore: "Explore a note's links",
27852
+ consistency: "Check the brain agrees with itself",
27853
+ outline: "Show a note's headings",
27854
+ inspect: "Inspect a note's raw form",
27855
+ claim: "Register or verify a claim",
27856
+ diff: "Show what changed",
27857
+ attach: "Attach a file to a note",
27858
+ detach: "Remove an attachment",
27859
+ addendum: "Find notes with pending addenda",
27860
+ maintain: "Run brain maintenance",
27861
+ forget: "Archive or delete a note",
27862
+ recover: "Restore an archived note",
27863
+ template: "Show a note kind's template",
27864
+ graph: "Render the relation graph",
27865
+ day: "Summarise the day",
27866
+ brain: "List the brain's inventory",
27867
+ assembly: "List what the brain holds",
27868
+ bootstrap: "Create or refresh the brain's structure",
27869
+ master: "Read a note about the user",
27870
+ master_recall: "Skim notes about the user",
27871
+ llm: "Read the model's operating notes",
27872
+ llm_recall: "Skim the model's operating notes",
27873
+ memory: "Read a thread or session",
27874
+ memory_recall: "Skim threads and sessions",
27875
+ knowledge: "Read a knowledge note",
27876
+ knowledge_recall: "Skim knowledge",
27877
+ insights: "Read a day's change log",
27878
+ insights_recall: "Skim insights",
27879
+ get_note: "Get note metadata (raw)",
27880
+ get_note_content: "Get note content (raw)",
27881
+ get_attachments: "List attachments (raw)",
27882
+ get_attachment_content: "Get attachment content (raw)",
27883
+ get_attribute: "Get an attribute (raw)",
27884
+ get_branch: "Get a branch (raw)",
27885
+ get_revisions: "List revisions (raw)",
27886
+ get_revision_content: "Get revision content (raw)",
27887
+ search_notes: "Search notes (raw query)",
27888
+ note_history: "List recent changes (raw)",
27889
+ get_app_info: "Get Trilium version (raw)",
27890
+ get_day_note: "Get or create a day note (raw)",
27891
+ get_week_note: "Get or create a week note (raw)",
27892
+ get_month_note: "Get or create a month note (raw)",
27893
+ get_year_note: "Get or create a year note (raw)",
27894
+ get_inbox_note: "Get or create the inbox note (raw)",
27895
+ create_note: "Create a note (raw)",
27896
+ patch_note: "Change note properties (raw)",
27897
+ update_note_content: "Replace note content (raw)",
27898
+ clone_note: "Clone a note (raw)",
27899
+ move_note: "Move a note (raw)",
27900
+ undelete_note: "Undelete a note (raw)",
27901
+ add_label: "Add a label (raw)",
27902
+ add_relation: "Add a relation (raw)",
27903
+ update_attribute: "Update an attribute (raw)",
27904
+ create_attachment: "Create an attachment (raw)",
27905
+ update_attachment: "Update an attachment (raw)",
27906
+ create_revision: "Snapshot a revision (raw)",
27907
+ create_backup: "Create a backup (raw)",
27908
+ delete_note: "Delete a note (raw)",
27909
+ delete_attribute: "Delete an attribute (raw)",
27910
+ delete_branch: "Delete a branch (raw)",
27911
+ delete_attachment: "Delete an attachment (raw)"
27912
+ };
27469
27913
  function applyToolAnnotations(server) {
27470
27914
  const registry = server._registeredTools;
27471
27915
  if (!registry)
@@ -27475,11 +27919,15 @@ function applyToolAnnotations(server) {
27475
27919
  for (const [name, tool] of Object.entries(registry)) {
27476
27920
  const hints = TOOL_ANNOTATIONS[name];
27477
27921
  if (!hints) {
27478
- tool.annotations = { ...tool.annotations, ...APPEND, title: name };
27922
+ const fallback = TOOL_TITLES[name] ?? name;
27923
+ tool.annotations = { ...tool.annotations, ...APPEND, title: fallback };
27924
+ tool.title = fallback;
27479
27925
  unclassified.push(name);
27480
27926
  continue;
27481
27927
  }
27482
- tool.annotations = { ...tool.annotations, ...hints, title: name };
27928
+ const title = TOOL_TITLES[name] ?? name;
27929
+ tool.annotations = { ...tool.annotations, ...hints, title };
27930
+ tool.title = title;
27483
27931
  annotated++;
27484
27932
  }
27485
27933
  return { annotated, unclassified };
@@ -27971,6 +28419,93 @@ note.dateModified >= 'YYYY-MM-DD', AND/OR. Unscoped unless ancestorNoteId is giv
27971
28419
  });
27972
28420
  }
27973
28421
 
28422
+ // src/cap.ts
28423
+ var RESULT_CHAR_CAP = 140000;
28424
+ function capResult(result, tool, cap = RESULT_CHAR_CAP) {
28425
+ const blocks = result?.content;
28426
+ if (!Array.isArray(blocks))
28427
+ return result;
28428
+ const total = blocks.reduce((n, b) => n + (b.type === "text" ? b.text?.length ?? 0 : 0), 0);
28429
+ if (total <= cap)
28430
+ return result;
28431
+ let budget = cap;
28432
+ const content = blocks.map((b) => {
28433
+ if (b.type !== "text" || !b.text)
28434
+ return b;
28435
+ if (budget <= 0)
28436
+ return { ...b, text: "" };
28437
+ const text = b.text.length > budget ? b.text.slice(0, budget) : b.text;
28438
+ budget -= text.length;
28439
+ return { ...b, text };
28440
+ });
28441
+ const note = `
28442
+
28443
+ [BrainLLM: ${tool}() returned ${total.toLocaleString("en-US")} characters, cut at ${cap.toLocaleString("en-US")} to fit the client's limit. ` + `Narrow the read: section= for one heading, block= for one addendum, limit= for fewer entries, or fewer ids=.]`;
28444
+ content.push({ type: "text", text: note });
28445
+ return { ...result, content };
28446
+ }
28447
+ function capToolResults(server, cap = RESULT_CHAR_CAP) {
28448
+ const registry = server._registeredTools;
28449
+ if (!registry)
28450
+ return 0;
28451
+ let wrapped = 0;
28452
+ for (const [name, tool] of Object.entries(registry)) {
28453
+ const inner = tool.handler;
28454
+ if (typeof inner !== "function")
28455
+ continue;
28456
+ tool.handler = async (...args) => capResult(await inner(...args), name, cap);
28457
+ wrapped++;
28458
+ }
28459
+ return wrapped;
28460
+ }
28461
+
28462
+ // src/brand.ts
28463
+ var BRAND2 = {
28464
+ name: "BrainLLM",
28465
+ tagline: "Give Claude a memory that survives the session",
28466
+ description: "Self-hosted memory for LLMs in TriliumNext, structured by the server and editable by you.",
28467
+ website: "https://miisodev.github.io/BrainLLM/",
28468
+ repository: "https://github.com/miisodev/BrainLLM",
28469
+ publisher: "miisodev",
28470
+ colors: { ground: "#0a0a0f", accent: "#f59e0b", nodeOff: "#3f3f46" }
28471
+ };
28472
+ var MARK_GRID = [
28473
+ true,
28474
+ false,
28475
+ true,
28476
+ true,
28477
+ true,
28478
+ false,
28479
+ false,
28480
+ true,
28481
+ true
28482
+ ];
28483
+ function markSvg(size = 22) {
28484
+ const dots = MARK_GRID.map((lit, i) => {
28485
+ const cx = 164 + i % 3 * 92;
28486
+ const cy = 164 + Math.floor(i / 3) * 92;
28487
+ return `<circle cx="${cx}" cy="${cy}" r="38" fill="${lit ? BRAND2.colors.accent : BRAND2.colors.nodeOff}"/>`;
28488
+ }).join("");
28489
+ return `<svg class="brand-mark" width="${size}" height="${size}" viewBox="110 110 292 292" aria-hidden="true">${dots}</svg>`;
28490
+ }
28491
+ var SERVER_INSTRUCTIONS = [
28492
+ `${BRAND2.name} is the user's persistent memory, stored in their own TriliumNext instance. Every note is one the user can open, read and correct.`,
28493
+ "The brain has five areas: Master (about the user), LLM (the assistant's operating notes and daily diary), Memory (threads and session logs), Knowledge (domains of sourced notes) and Insights (daily change logs and the relation graph).",
28494
+ "A session typically opens with start(), which returns the user's stored preferences and protocols and what changed since the last session, and ends with session() and close(), which write the session log. remember() and revise() write; recall(), domain() and the surface reads (master, llm, memory, knowledge, insights) read.",
28495
+ "The server owns placement, labels, deduplication, dates and backups, so writes need content, not bookkeeping."
28496
+ ].join(`
28497
+
28498
+ `);
28499
+ function brandIcons(origin) {
28500
+ const REPO_RAW = "https://raw.githubusercontent.com/miisodev/BrainLLM/main/public";
28501
+ const at = (file) => origin ? `${origin}/${file}` : `${REPO_RAW}/${file}`;
28502
+ return [
28503
+ { src: at("icon-128.png"), mimeType: "image/png", sizes: ["128x128"] },
28504
+ { src: at("icon-512.png"), mimeType: "image/png", sizes: ["512x512"] },
28505
+ { src: origin ? `${origin}/icon.svg` : `${REPO_RAW}/BrainLLM.svg`, mimeType: "image/svg+xml", sizes: ["any"] }
28506
+ ];
28507
+ }
28508
+
27974
28509
  // src/sse.ts
27975
28510
  import { randomUUID } from "crypto";
27976
28511
  var KEEPALIVE_MS = 30000;
@@ -28754,9 +29289,7 @@ var PAGE_CSS = `
28754
29289
  border:1px solid var(--border); border-radius:16px; padding:32px;
28755
29290
  }
28756
29291
  .brand { display:flex; align-items:center; gap:10px; margin-bottom:26px; }
28757
- .brand-mark { display:grid; grid-template-columns:repeat(3,4px); gap:3px; }
28758
- .brand-mark i { width:4px; height:4px; border-radius:50%; background:var(--accent); display:block; }
28759
- .brand-mark i.off { background:#3f3f46; }
29292
+ .brand-mark { display:block; flex:none; }
28760
29293
  .brand-name { font-family:var(--font-display); font-size:19px; font-weight:700; letter-spacing:-.475px; }
28761
29294
  h1 { font-family:var(--font-display); font-size:24px; font-weight:700; letter-spacing:-.02em; line-height:1.2; margin:0 0 8px; }
28762
29295
  p { margin:0 0 20px; color:var(--text-muted); font-size:14.5px; }
@@ -28788,6 +29321,12 @@ var PAGE_CSS = `
28788
29321
  .approve:hover { transform:translateY(-1px); box-shadow:0 0 56px rgba(245,158,11,.42); }
28789
29322
  .deny { background:transparent; color:var(--text); border-color:var(--border-strong); font-weight:500; }
28790
29323
  .deny:hover { background:rgba(255,255,255,.04); }
29324
+ .redirect { margin-top:-8px; }
29325
+ .warn {
29326
+ background:rgba(245,158,11,.08); border:1px solid rgba(245,158,11,.3); color:#fcd34d;
29327
+ padding:11px 14px; border-radius:9px; font-size:14px; margin-bottom:18px;
29328
+ }
29329
+ a { color:var(--accent); }
28791
29330
  .err {
28792
29331
  background:rgba(239,68,68,.1); border:1px solid rgba(239,68,68,.3); color:#fca5a5;
28793
29332
  padding:11px 14px; border-radius:9px; font-size:14px; margin-bottom:18px;
@@ -28796,7 +29335,7 @@ var PAGE_CSS = `
28796
29335
  .foot a { color:inherit; }
28797
29336
  @media (prefers-reduced-motion: reduce) { button { transition:none; } .approve:hover { transform:none; } }
28798
29337
  `;
28799
- var BRAND_MARK = `<span class="brand-mark"><i></i><i class="off"></i><i></i><i></i><i></i><i class="off"></i><i class="off"></i><i></i><i></i></span>`;
29338
+ var BRAND_MARK = markSvg(22);
28800
29339
  var shell = (title, body, head = "") => `<!doctype html>
28801
29340
  <html lang="en"><head><meta charset="utf-8">
28802
29341
  <meta name="viewport" content="width=device-width,initial-scale=1">
@@ -28806,13 +29345,13 @@ var shell = (title, body, head = "") => `<!doctype html>
28806
29345
  <style>${PAGE_CSS}</style></head>
28807
29346
  <body>${body}</body></html>`;
28808
29347
  function landingPage(base, oauthOn, sseOn) {
28809
- return shell("BrainLLM \u2014 a persistent memory server", `<div class="card">
28810
- <div class="brand">${BRAND_MARK}<span class="brand-name">BrainLLM</span></div>
29348
+ return shell(`${BRAND2.name} - ${BRAND2.tagline}`, `<div class="card">
29349
+ <div class="brand">${BRAND_MARK}<span class="brand-name">${BRAND2.name}</span></div>
28811
29350
  <h1>A brain is listening</h1>
28812
- <p>This is a <strong>BrainLLM</strong> MCP memory server \u2014 a persistent, graph-structured second brain served over the Model Context Protocol.</p>
28813
- <p>Point an MCP client at <span class="host">${esc2(base)}/mcp</span>${sseOn ? ` \u2014 or, for clients that only speak the legacy SSE transport, <span class="host">${esc2(base)}/sse</span>` : ""}.</p>
29351
+ <p>This is a <strong>${BRAND2.name}</strong> server: ${esc2(BRAND2.description.charAt(0).toLowerCase() + BRAND2.description.slice(1))}</p>
29352
+ <p>Point an MCP client at <span class="host">${esc2(base)}/mcp</span>${sseOn ? `, or, for clients that only speak the legacy SSE transport, <span class="host">${esc2(base)}/sse</span>` : ""}.</p>
28814
29353
  <p>${oauthOn ? `Authentication: OAuth 2.1 with owner consent, or a static bearer token (<span class="host">MCP_AUTH_TOKEN</span>). Both work against the same brain.` : `Authentication: a static bearer token (<span class="host">MCP_AUTH_TOKEN</span>).`}</p>
28815
- <p class="foot">Health: <span class="host">/health</span> \xB7 Everything else is MCP.</p>
29354
+ <p class="foot">Health: <span class="host">/health</span> \xB7 Everything else is MCP \xB7 About: <span class="host">${esc2(BRAND2.website.replace(/^https:\/\//, "").replace(/\/$/, ""))}</span></p>
28816
29355
  </div>`);
28817
29356
  }
28818
29357
  var PAGE_SECURITY_DIRECTIVES = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; img-src 'self' data:";
@@ -28820,7 +29359,7 @@ function pageContentSecurityPolicy() {
28820
29359
  return `${PAGE_SECURITY_DIRECTIVES}; form-action 'self'; style-src 'unsafe-inline'`;
28821
29360
  }
28822
29361
  function consentResponse(transaction, redirectUri, clientHost, verifiedHost, error, status = 200) {
28823
- return new Response(consentPage({ transaction }, clientHost, error, verifiedHost), {
29362
+ return new Response(consentPage({ transaction }, clientHost, error, verifiedHost, redirectUri), {
28824
29363
  status,
28825
29364
  headers: {
28826
29365
  "Content-Type": "text/html; charset=utf-8",
@@ -28829,13 +29368,28 @@ function consentResponse(transaction, redirectUri, clientHost, verifiedHost, err
28829
29368
  }
28830
29369
  });
28831
29370
  }
28832
- function consentPage(params, clientHost, error, verifiedHost = true) {
29371
+ function redirectTarget(redirectUri) {
29372
+ if (!redirectUri)
29373
+ return null;
29374
+ try {
29375
+ const u = new URL(redirectUri);
29376
+ const host = u.hostname.replace(/^\[|\]$/g, "");
29377
+ const loopback = host === "localhost" || host === "127.0.0.1" || host === "::1";
29378
+ return { host: u.port ? `${u.hostname}:${u.port}` : u.hostname, loopback };
29379
+ } catch {
29380
+ return null;
29381
+ }
29382
+ }
29383
+ function consentPage(params, clientHost, error, verifiedHost = true, redirectUri) {
29384
+ const target = redirectTarget(redirectUri);
28833
29385
  const hidden = Object.entries(params).map(([k, v]) => `<input type="hidden" name="${esc2(k)}" value="${esc2(v)}">`).join("");
28834
29386
  const foot = verifiedHost ? "Only the host above is shown, and it is the one DNS and TLS vouch for." : "The name above is self-reported by the application \u2014 your password is what authorizes access.";
28835
29387
  return shell("Authorize access to your brain", `<form class="card" method="POST" action="/authorize">
28836
- <div class="brand">${BRAND_MARK}<span class="brand-name">BrainLLM</span></div>
29388
+ <div class="brand">${BRAND_MARK}<span class="brand-name">${BRAND2.name}</span></div>
28837
29389
  <h1>Authorize access</h1>
28838
29390
  <p><span class="host">${esc2(clientHost)}</span> is requesting access to your brain.</p>
29391
+ ${target ? `<p class="redirect">Approving sends you back to <span class="host">${esc2(target.host)}</span>.</p>` : ""}
29392
+ ${target?.loopback ? `<div class="warn">That address is this computer. Approve only if you just started this sign-in from an app on this machine, such as Claude Code.</div>` : ""}
28839
29393
  ${error ? `<div class="err">${esc2(error)}</div>` : ""}
28840
29394
  <div class="scope"><span class="dot"></span><span>Read and write everything in your brain \u2014 memories, threads, knowledge and your diary.</span></div>
28841
29395
  <label for="pw">Owner password</label>
@@ -28860,7 +29414,7 @@ function handoff(uri, params, heading, message) {
28860
29414
  u.searchParams.set(k, v);
28861
29415
  const href = esc2(u.href);
28862
29416
  return new Response(shell(heading, `<div class="card">
28863
- <div class="brand">${BRAND_MARK}<span class="brand-name">BrainLLM</span></div>
29417
+ <div class="brand">${BRAND_MARK}<span class="brand-name">${BRAND2.name}</span></div>
28864
29418
  <h1>${esc2(heading)}</h1>
28865
29419
  <p>${esc2(message)}</p>
28866
29420
  <p class="foot"><a href="${href}">Continue</a> if this page does not move on by itself.</p>
@@ -28875,7 +29429,7 @@ function handoff(uri, params, heading, message) {
28875
29429
  });
28876
29430
  }
28877
29431
  var htmlError = (message, status = 400) => new Response(shell("Authorization error", `<div class="card">
28878
- <div class="brand">${BRAND_MARK}<span class="brand-name">BrainLLM</span></div>
29432
+ <div class="brand">${BRAND_MARK}<span class="brand-name">${BRAND2.name}</span></div>
28879
29433
  <h1>Authorization error</h1>
28880
29434
  <div class="err">${esc2(message)}</div>
28881
29435
  <p class="foot">Nothing was authorized. Close this window and start again from your client.</p>
@@ -28982,12 +29536,15 @@ var tokenError = (error, description, status = 400) => new Response(JSON.stringi
28982
29536
  status,
28983
29537
  headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }
28984
29538
  });
29539
+ function refreshKey(token) {
29540
+ return "sha256:" + createHash("sha256").update(token).digest("hex");
29541
+ }
28985
29542
  function issueTokens(base, clientId, resource, scope) {
28986
29543
  const store = loadStore();
28987
29544
  const now = Math.floor(Date.now() / 1000);
28988
29545
  const accessToken = signJwt({ iss: base, aud: resource, sub: "owner", scope, iat: now, exp: now + ACCESS_TOKEN_TTL_S }, signingSecret());
28989
29546
  const refreshToken = randomBytes(32).toString("base64url");
28990
- store.refresh[refreshToken] = { clientId, resource, scope, expiresAt: Date.now() + REFRESH_TOKEN_TTL_MS };
29547
+ store.refresh[refreshKey(refreshToken)] = { clientId, resource, scope, expiresAt: Date.now() + REFRESH_TOKEN_TTL_MS };
28991
29548
  saveStore();
28992
29549
  return {
28993
29550
  access_token: accessToken,
@@ -29035,10 +29592,11 @@ async function handleToken(req, base) {
29035
29592
  }
29036
29593
  if (grantType === "refresh_token") {
29037
29594
  const supplied = get("refresh_token");
29038
- const record = store.refresh[supplied];
29039
- if (!record)
29595
+ const key = supplied && store.refresh[refreshKey(supplied)] ? refreshKey(supplied) : supplied;
29596
+ const record = key ? store.refresh[key] : undefined;
29597
+ if (!record || !key)
29040
29598
  return tokenError("invalid_grant", "Unknown or already-rotated refresh token.");
29041
- delete store.refresh[supplied];
29599
+ delete store.refresh[key];
29042
29600
  saveStore();
29043
29601
  if (record.expiresAt < Date.now())
29044
29602
  return tokenError("invalid_grant", "Refresh token expired.");
@@ -29128,22 +29686,15 @@ if (port && !authToken && !oauthEnabled() && !allowUnauthenticatedHttp) {
29128
29686
  console.error("HTTP mode requires MCP_AUTH_TOKEN or BRAINLLM_OWNER_PASSWORD. Set BRAINLLM_ALLOW_UNAUTHENTICATED_HTTP=true only for a trusted, isolated network.");
29129
29687
  process.exit(1);
29130
29688
  }
29131
- var REPO_RAW = "https://raw.githubusercontent.com/miisodev/BrainLLM/main/public";
29132
- function brandingIcons(origin) {
29133
- const at = (file) => origin ? `${origin}/${file}` : `${REPO_RAW}/${file}`;
29134
- return [
29135
- { src: at("icon-128.png"), mimeType: "image/png", sizes: ["128x128"] },
29136
- { src: at("icon-512.png"), mimeType: "image/png", sizes: ["512x512"] },
29137
- { src: origin ? `${origin}/icon.svg` : `${REPO_RAW}/BrainLLM.svg`, mimeType: "image/svg+xml", sizes: ["any"] }
29138
- ];
29139
- }
29140
29689
  function createServer(origin = null) {
29141
29690
  const s = new McpServer({
29142
- name: "BrainLLM",
29143
- title: "BrainLLM",
29144
- version: "12.7.1",
29145
- icons: brandingIcons(origin)
29146
- });
29691
+ name: BRAND2.name,
29692
+ title: BRAND2.name,
29693
+ version: "12.8.0",
29694
+ description: BRAND2.description,
29695
+ websiteUrl: BRAND2.website,
29696
+ icons: brandIcons(origin)
29697
+ }, { instructions: SERVER_INSTRUCTIONS });
29147
29698
  registerTools(s, trilium, brainRef);
29148
29699
  if (mode === "full")
29149
29700
  registerAdvancedTools(s, trilium, brainRef);
@@ -29155,6 +29706,7 @@ function createServer(origin = null) {
29155
29706
  if (unclassified.length) {
29156
29707
  console.error(`[brainllm] Unclassified tools, treated as writes: ${unclassified.join(", ")}`);
29157
29708
  }
29709
+ capToolResults(s);
29158
29710
  const serialized = serializeWrites(s);
29159
29711
  if (serialized.serialized) {
29160
29712
  console.error(`[brainllm] Write serialization: ${serialized.serialized} write handler(s) queued, ${serialized.readsUntouched} read(s) untouched`);
@@ -29191,7 +29743,8 @@ if (port) {
29191
29743
  authorize: new FixedWindowRateLimiter(30, 15 * 60000),
29192
29744
  token: new FixedWindowRateLimiter(120, 15 * 60000),
29193
29745
  register: new FixedWindowRateLimiter(30, 60 * 60000),
29194
- transport: new FixedWindowRateLimiter(600, 15 * 60000)
29746
+ transport: new FixedWindowRateLimiter(600, 15 * 60000),
29747
+ transportIp: new FixedWindowRateLimiter(3000, 15 * 60000)
29195
29748
  };
29196
29749
  const rateLimited = (limiter, key) => {
29197
29750
  const decision = limiter.allow(key);
@@ -29230,6 +29783,7 @@ if (port) {
29230
29783
  Bun.serve({
29231
29784
  port,
29232
29785
  maxRequestBodySize: 50 * 1024 * 1024,
29786
+ idleTimeout: 240,
29233
29787
  async fetch(req, server) {
29234
29788
  const url = new URL(req.url);
29235
29789
  const requestKey = clientKey(req, server);
@@ -29304,7 +29858,12 @@ if (port) {
29304
29858
  if (!isMcpEndpoint && url.pathname !== "/sse" && url.pathname !== "/messages") {
29305
29859
  return withCors(new Response("Not Found", { status: 404 }));
29306
29860
  }
29307
- const transportLimit = rateLimited(rateLimiters.transport, requestKey);
29861
+ const ipLimit = rateLimited(rateLimiters.transportIp, requestKey);
29862
+ if (ipLimit)
29863
+ return ipLimit;
29864
+ const bearer = req.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
29865
+ const transportKey = bearer ? `token:${new Bun.CryptoHasher("sha256").update(bearer).digest("hex").slice(0, 32)}` : requestKey;
29866
+ const transportLimit = rateLimited(rateLimiters.transport, transportKey);
29308
29867
  if (transportLimit)
29309
29868
  return transportLimit;
29310
29869
  const gate = () => {