brainllm 12.7.1 → 12.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -17351,6 +17351,9 @@ class Protocol {
17351
17351
  this.setRequestHandler(GetTaskPayloadRequestSchema, async (request, extra) => {
17352
17352
  const handleTaskResult = async () => {
17353
17353
  const taskId = request.params.taskId;
17354
+ if (!await this._taskStore.getTask(taskId, extra.sessionId)) {
17355
+ throw new McpError(ErrorCode.InvalidParams, `Task not found: ${taskId}`);
17356
+ }
17354
17357
  if (this._taskMessageQueue) {
17355
17358
  let queuedMessage;
17356
17359
  while (queuedMessage = await this._taskMessageQueue.dequeue(taskId, extra.sessionId)) {
@@ -17381,12 +17384,12 @@ class Protocol {
17381
17384
  throw new McpError(ErrorCode.InvalidParams, `Task not found: ${taskId}`);
17382
17385
  }
17383
17386
  if (!isTerminal(task.status)) {
17384
- await this._waitForTaskUpdate(taskId, extra.signal);
17387
+ await this._waitForTaskUpdate(taskId, extra.signal, extra.sessionId);
17385
17388
  return await handleTaskResult();
17386
17389
  }
17387
17390
  if (isTerminal(task.status)) {
17388
17391
  const result = await this._taskStore.getTaskResult(taskId, extra.sessionId);
17389
- this._clearTaskQueue(taskId);
17392
+ this._clearTaskQueue(taskId, extra.sessionId);
17390
17393
  return {
17391
17394
  ...result,
17392
17395
  _meta: {
@@ -17423,7 +17426,7 @@ class Protocol {
17423
17426
  throw new McpError(ErrorCode.InvalidParams, `Cannot cancel task in terminal status: ${task.status}`);
17424
17427
  }
17425
17428
  await this._taskStore.updateTaskStatus(request.params.taskId, "cancelled", "Client cancelled task execution.", extra.sessionId);
17426
- this._clearTaskQueue(request.params.taskId);
17429
+ this._clearTaskQueue(request.params.taskId, extra.sessionId);
17427
17430
  const cancelledTask = await this._taskStore.getTask(request.params.taskId, extra.sessionId);
17428
17431
  if (!cancelledTask) {
17429
17432
  throw new McpError(ErrorCode.InvalidParams, `Task not found after cancellation: ${request.params.taskId}`);
@@ -17546,6 +17549,19 @@ class Protocol {
17546
17549
  const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler;
17547
17550
  const capturedTransport = this._transport;
17548
17551
  const relatedTaskId = request.params?._meta?.[RELATED_TASK_META_KEY]?.taskId;
17552
+ const sessionId = capturedTransport?.sessionId;
17553
+ const store = this._taskStore;
17554
+ let relatedTaskFound = true;
17555
+ let relatedTaskLookup;
17556
+ if (relatedTaskId && store && this._taskMessageQueue && sessionId !== undefined) {
17557
+ relatedTaskFound = false;
17558
+ relatedTaskLookup = (async () => {
17559
+ if (!await store.getTask(relatedTaskId, sessionId)) {
17560
+ throw new McpError(ErrorCode.InvalidParams, `Task not found: ${relatedTaskId}`);
17561
+ }
17562
+ relatedTaskFound = true;
17563
+ })();
17564
+ }
17549
17565
  if (handler === undefined) {
17550
17566
  const errorResponse = {
17551
17567
  jsonrpc: "2.0",
@@ -17555,7 +17571,10 @@ class Protocol {
17555
17571
  message: "Method not found"
17556
17572
  }
17557
17573
  };
17558
- if (relatedTaskId && this._taskMessageQueue) {
17574
+ if (relatedTaskId && relatedTaskLookup) {
17575
+ const queuedError = { type: "error", message: errorResponse, timestamp: Date.now() };
17576
+ relatedTaskLookup.then(() => this._enqueueTaskMessage(relatedTaskId, queuedError, sessionId), () => capturedTransport?.send(errorResponse)).catch((error) => this._onerror(new Error(`Failed to send an error response: ${error}`)));
17577
+ } else if (relatedTaskId && this._taskMessageQueue) {
17559
17578
  this._enqueueTaskMessage(relatedTaskId, {
17560
17579
  type: "error",
17561
17580
  message: errorResponse,
@@ -17606,7 +17625,10 @@ class Protocol {
17606
17625
  closeSSEStream: extra?.closeSSEStream,
17607
17626
  closeStandaloneSSEStream: extra?.closeStandaloneSSEStream
17608
17627
  };
17609
- Promise.resolve().then(() => {
17628
+ (relatedTaskLookup ?? Promise.resolve()).then(() => {
17629
+ if (relatedTaskLookup && abortController.signal.aborted) {
17630
+ throw new McpError(ErrorCode.ConnectionClosed, "Request was cancelled");
17631
+ }
17610
17632
  if (taskCreationParams) {
17611
17633
  this.assertTaskHandlerCapability(request.method);
17612
17634
  }
@@ -17641,7 +17663,7 @@ class Protocol {
17641
17663
  ...error["data"] !== undefined && { data: error["data"] }
17642
17664
  }
17643
17665
  };
17644
- if (relatedTaskId && this._taskMessageQueue) {
17666
+ if (relatedTaskId && this._taskMessageQueue && relatedTaskFound) {
17645
17667
  await this._enqueueTaskMessage(relatedTaskId, {
17646
17668
  type: "error",
17647
17669
  message: errorResponse,
@@ -18029,7 +18051,7 @@ class Protocol {
18029
18051
  throw new Error("Cannot enqueue task message: taskStore and taskMessageQueue are not configured");
18030
18052
  }
18031
18053
  const maxQueueSize = this._options?.maxTaskQueueSize;
18032
- await this._taskMessageQueue.enqueue(taskId, message, sessionId, maxQueueSize);
18054
+ await this._taskMessageQueue.enqueue(taskId, message, sessionId ?? this._transport?.sessionId, maxQueueSize);
18033
18055
  }
18034
18056
  async _clearTaskQueue(taskId, sessionId) {
18035
18057
  if (this._taskMessageQueue) {
@@ -18048,10 +18070,10 @@ class Protocol {
18048
18070
  }
18049
18071
  }
18050
18072
  }
18051
- async _waitForTaskUpdate(taskId, signal) {
18073
+ async _waitForTaskUpdate(taskId, signal, sessionId) {
18052
18074
  let interval = this._options?.defaultTaskPollInterval ?? 1000;
18053
18075
  try {
18054
- const task = await this._taskStore?.getTask(taskId);
18076
+ const task = await this._taskStore?.getTask(taskId, sessionId);
18055
18077
  if (task?.pollInterval) {
18056
18078
  interval = task.pollInterval;
18057
18079
  }
@@ -18718,6 +18740,43 @@ class ExperimentalMcpServerTasks {
18718
18740
  }
18719
18741
  }
18720
18742
  // node_modules/@modelcontextprotocol/sdk/dist/esm/server/mcp.js
18743
+ function toolInputElementCount(value, max) {
18744
+ let count = 0;
18745
+ const stack = [value];
18746
+ while (stack.length > 0) {
18747
+ const node = stack.pop();
18748
+ if (node === null || typeof node !== "object")
18749
+ continue;
18750
+ if (Array.isArray(node)) {
18751
+ for (const child of node) {
18752
+ if (++count > max)
18753
+ return count;
18754
+ if (child !== null && typeof child === "object")
18755
+ stack.push(child);
18756
+ }
18757
+ } else {
18758
+ for (const key in node) {
18759
+ if (!Object.prototype.hasOwnProperty.call(node, key))
18760
+ continue;
18761
+ if (++count > max)
18762
+ return count;
18763
+ const child = node[key];
18764
+ if (child !== null && typeof child === "object")
18765
+ stack.push(child);
18766
+ }
18767
+ }
18768
+ }
18769
+ return count;
18770
+ }
18771
+ function resolveMaxToolInputElements(value) {
18772
+ if (value === undefined || value === Infinity)
18773
+ return;
18774
+ if (typeof value !== "number" || Number.isNaN(value) || value < 1) {
18775
+ throw new RangeError(`maxToolInputElements must be a number of at least 1, or Infinity, got ${String(value)}`);
18776
+ }
18777
+ return value;
18778
+ }
18779
+
18721
18780
  class McpServer {
18722
18781
  constructor(serverInfo, options) {
18723
18782
  this._registeredResources = {};
@@ -18729,6 +18788,7 @@ class McpServer {
18729
18788
  this._resourceHandlersInitialized = false;
18730
18789
  this._promptHandlersInitialized = false;
18731
18790
  this.server = new Server(serverInfo, options);
18791
+ this._maxToolInputElements = resolveMaxToolInputElements(options?.maxToolInputElements);
18732
18792
  }
18733
18793
  get experimental() {
18734
18794
  if (!this._experimental) {
@@ -18835,12 +18895,15 @@ class McpServer {
18835
18895
  };
18836
18896
  }
18837
18897
  async validateToolInput(tool, args, toolName) {
18898
+ if (this._maxToolInputElements !== undefined && toolInputElementCount(args, this._maxToolInputElements) > this._maxToolInputElements) {
18899
+ throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for tool ${toolName}: arguments contain more than the maximum of ${this._maxToolInputElements} elements`);
18900
+ }
18838
18901
  if (!tool.inputSchema) {
18839
18902
  return;
18840
18903
  }
18841
18904
  const inputObj = normalizeObjectSchema(tool.inputSchema);
18842
18905
  const schemaToParse = inputObj ?? tool.inputSchema;
18843
- const parseResult = await safeParseAsync2(schemaToParse, args);
18906
+ const parseResult = await safeParseAsync2(schemaToParse, args ?? {});
18844
18907
  if (!parseResult.success) {
18845
18908
  const error = "error" in parseResult ? parseResult.error : "Unknown error";
18846
18909
  const errorMessage = getParseErrorMessage(error);
@@ -19065,7 +19128,7 @@ class McpServer {
19065
19128
  }
19066
19129
  if (prompt.argsSchema) {
19067
19130
  const argsObj = normalizeObjectSchema(prompt.argsSchema);
19068
- const parseResult = await safeParseAsync2(argsObj, request.params.arguments);
19131
+ const parseResult = await safeParseAsync2(argsObj, request.params.arguments ?? {});
19069
19132
  if (!parseResult.success) {
19070
19133
  const error = "error" in parseResult ? parseResult.error : "Unknown error";
19071
19134
  const errorMessage = getParseErrorMessage(error);
@@ -21591,6 +21654,16 @@ function looksLikeHtml(body) {
21591
21654
  return true;
21592
21655
  return false;
21593
21656
  }
21657
+ function isStructuralTag(name) {
21658
+ const tag = name.toLowerCase();
21659
+ return SAFE_TAGS.has(tag) || BLOCK_ELEMENTS.has(tag);
21660
+ }
21661
+ function looksLikeStructuralHtml(body) {
21662
+ for (const m of body.matchAll(HTML_TAG))
21663
+ if (isStructuralTag(m[1]))
21664
+ return true;
21665
+ return false;
21666
+ }
21594
21667
  var ENCODED_TAG = /&lt;\/?[a-z][a-z0-9-]*(?:\s[^&<>]*?)?\s*\/?&gt;/i;
21595
21668
  function looksLikeEncodedHtml(body) {
21596
21669
  return !looksLikeHtml(body) && ENCODED_TAG.test(body);
@@ -21658,7 +21731,7 @@ function renderMarkup(raw) {
21658
21731
  const body = raw.replace(/\u0000/g, "");
21659
21732
  if (!body.trim())
21660
21733
  return { html: "<p></p>", convertedRuns: 0 };
21661
- if (looksLikeHtml(body))
21734
+ if (looksLikeStructuralHtml(body))
21662
21735
  return mixedToHtml(body);
21663
21736
  if (looksLikeEncodedHtml(body))
21664
21737
  return { html: decodeEncodedHtml(body), convertedRuns: 0 };
@@ -21747,7 +21820,7 @@ function mixedToHtml(body) {
21747
21820
  }
21748
21821
  function markdownToHtml(body, inlineHtml = false) {
21749
21822
  const kept = [];
21750
- const source = inlineHtml ? body.replace(HTML_TAG, (tag, name) => isHtmlElement(name) ? `\x00${kept.push(tag) - 1}\x00` : tag) : body;
21823
+ const source = inlineHtml ? body.replace(HTML_TAG, (tag, name) => isStructuralTag(name) ? `\x00${kept.push(tag) - 1}\x00` : tag) : body;
21751
21824
  const lines = source.replace(/\r\n/g, `
21752
21825
  `).split(`
21753
21826
  `);
@@ -21881,6 +21954,36 @@ function addendumIndex(html) {
21881
21954
  }
21882
21955
  return out;
21883
21956
  }
21957
+ function addendumBlock(html, block) {
21958
+ const markerRe = /<h2(?:\s[^>]*)?>\s*((?:Addendum|Withdrawn|Recovered|Reopened)[^<]*)<\/h2>/gi;
21959
+ const hits = [...html.matchAll(markerRe)];
21960
+ const markers = hits.map((m) => decodeEntities(m[1]).replace(/\s+/g, " ").trim());
21961
+ const want = block.trim();
21962
+ let i = -1;
21963
+ if (/^-?\d+$/.test(want)) {
21964
+ const n = parseInt(want, 10);
21965
+ i = n < 0 ? hits.length + n : n - 1;
21966
+ } else {
21967
+ i = markers.findIndex((m) => m.endsWith(want) || m.includes(`\u2014 ${want}`) || m === want);
21968
+ }
21969
+ if (i < 0 || i >= hits.length)
21970
+ return { matched: false, available: markers };
21971
+ const from = hits[i].index;
21972
+ const to = i + 1 < hits.length ? hits[i + 1].index : html.length;
21973
+ const content = html.slice(from, to).trim();
21974
+ const idMatch = content.match(/<h3(?:\s[^>]*)?>([\s\S]*?)<\/h3>/i);
21975
+ const identity = idMatch ? decodeEntities(idMatch[1].replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim() : undefined;
21976
+ return { matched: true, marker: markers[i], ...identity ? { identity } : {}, content, position: i + 1, of: hits.length };
21977
+ }
21978
+ function readableText(html) {
21979
+ return decodeEntities(html.replace(/<(style|script)[\s\S]*?<\/\1>/gi, "").replace(/<h([1-6])[^>]*>/gi, (_, n) => `
21980
+ ` + "#".repeat(Number(n)) + " ").replace(/<li[^>]*>/gi, `
21981
+ - `).replace(/<\/t[dh]>\s*<t[dh][^>]*>/gi, " | ").replace(/<(tr|p|div|br|blockquote|figure|table|ul|ol|hr)[^>]*>/gi, `
21982
+ `).replace(/<[^>]+>/g, "")).replace(/[ \t]+/g, " ").replace(/ *\n */g, `
21983
+ `).replace(/\n{3,}/g, `
21984
+
21985
+ `).trim();
21986
+ }
21884
21987
  function nearestContext(source, needle, radius = 40) {
21885
21988
  if (!source || needle.length < 4)
21886
21989
  return null;
@@ -21948,6 +22051,9 @@ function stripTagsWithMap(html) {
21948
22051
  function escapeQueryValue(s) {
21949
22052
  return s.replace(/["\\]/g, " ").replace(/\s+/g, " ").trim();
21950
22053
  }
22054
+ function triliumSearchForm(pattern) {
22055
+ return pattern.normalize("NFD").replace(/\p{Diacritic}/gu, (c) => c.charCodeAt(0) < 128 ? c : "");
22056
+ }
21951
22057
  function escapeQueryRegex(s) {
21952
22058
  return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
21953
22059
  }
@@ -22141,12 +22247,21 @@ function sanitizeHtml(html) {
22141
22247
  n++;
22142
22248
  return "";
22143
22249
  });
22144
- s = s.replace(/<(script|style|noscript|iframe|form|object|applet|select|textarea|button|template|svg|math|canvas)(\s[^>]*)?>[\s\S]*$/gi, () => {
22250
+ let dropped = 0;
22251
+ s = s.replace(/<(script|style|noscript|iframe)(\s[^>]*)?>[\s\S]*$/gi, (m) => {
22145
22252
  n++;
22253
+ dropped += m.length;
22146
22254
  return "";
22147
22255
  });
22148
22256
  if (n)
22149
- warnings.push(`Stripped ${n} forbidden element block(s) \u2014 active/content-bearing tags are not allowed`);
22257
+ warnings.push(`Stripped ${n} forbidden element block(s) \u2014 active/content-bearing tags are not allowed${dropped ? ` (${dropped} characters removed with an unclosed code element)` : ""}`);
22258
+ let opened = 0;
22259
+ s = s.replace(/<(form|object|applet|select|textarea|button|template|svg|math|canvas)(\s[^>]*)?>/gi, (m) => {
22260
+ opened++;
22261
+ return escapeHtml(m, false);
22262
+ });
22263
+ if (opened)
22264
+ warnings.push(`Kept ${opened} unclosed forbidden tag(s) as text \u2014 written as a placeholder, nothing after it was removed`);
22150
22265
  const comments = s.match(/<!--[\s\S]*?-->/g)?.length ?? 0;
22151
22266
  if (comments) {
22152
22267
  s = s.replace(/<!--[\s\S]*?-->/g, "");
@@ -22258,6 +22373,99 @@ function tolerantFindRegex(find) {
22258
22373
  function spansBlockBoundary(find) {
22259
22374
  return /<\/[a-zA-Z][a-zA-Z0-9-]*\s*>\s*<[a-zA-Z]/.test(find);
22260
22375
  }
22376
+ function closedBlockTag(find) {
22377
+ const m = /<\/(tr|td|th|li|p|blockquote|figure|table|ul|ol)\s*>/i.exec(find);
22378
+ return m ? m[1].toLowerCase() : null;
22379
+ }
22380
+ function visibleTextSpans(source, needle, tag) {
22381
+ const norm = (s) => decodeEntities(s).replace(/\\(["'])/g, "$1").replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'").replace(/\s*\|\s*/g, " ").replace(/\s+/g, " ").trim().toLowerCase();
22382
+ const want = norm(needle.replace(/<[^>]+>/g, " "));
22383
+ if (want.length < 2)
22384
+ return [];
22385
+ const out = [];
22386
+ const rx = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, "gi");
22387
+ for (const m of source.matchAll(rx)) {
22388
+ if (norm(m[1].replace(/<[^>]+>/g, " ")).includes(want)) {
22389
+ const open = /^<[^>]*>/.exec(m[0])[0];
22390
+ out.push({ start: m.index + open.length, length: 0 });
22391
+ }
22392
+ }
22393
+ return out;
22394
+ }
22395
+ var INLINE_TAGS = "strong|em|b|i|u|s|del|ins|code|a|span|mark|sub|sup|kbd|small";
22396
+ var INLINE_TAG_RX = new RegExp(`<\\/?(?:${INLINE_TAGS})(?:\\s[^<>]*)?\\/?>`, "gi");
22397
+ var DOUBLE_QUOTE = '(?:"|&quot;|&#34;|\u201C|\u201D|&ldquo;|&rdquo;)';
22398
+ var SINGLE_QUOTE = "(?:'|&#39;|&apos;|\u2018|\u2019|&lsquo;|&rsquo;)";
22399
+ var ENTITY_FORMS = {
22400
+ "&": "&(?:amp;)?",
22401
+ "<": "&lt;",
22402
+ ">": "&gt;",
22403
+ '"': DOUBLE_QUOTE,
22404
+ "\u201C": DOUBLE_QUOTE,
22405
+ "\u201D": DOUBLE_QUOTE,
22406
+ "'": SINGLE_QUOTE,
22407
+ "\u2018": SINGLE_QUOTE,
22408
+ "\u2019": SINGLE_QUOTE
22409
+ };
22410
+ function inlineTolerantSpans(source, needle) {
22411
+ if (/<\/?(?!(?:strong|em|b|i|u|s|del|ins|code|a|span|mark|sub|sup|kbd|small)\b)[a-zA-Z]/.test(needle))
22412
+ return [];
22413
+ const text = decodeEntities(needle.replace(INLINE_TAG_RX, "")).replace(/\\(["'])/g, "$1").trim();
22414
+ if (text.length < 2)
22415
+ return [];
22416
+ const gap = `(?:<\\/?(?:${INLINE_TAGS})(?:\\s[^<>]*)?\\/?>)*`;
22417
+ const esc = (c) => c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22418
+ const parts = [];
22419
+ for (const ch of text.replace(/\s+/g, " ")) {
22420
+ parts.push(ch === " " ? "(?:\\s|&nbsp;|&#160;)+" : ENTITY_FORMS[ch] ?? esc(ch));
22421
+ }
22422
+ let rx;
22423
+ try {
22424
+ rx = new RegExp(parts.join(gap), "g");
22425
+ } catch {
22426
+ return [];
22427
+ }
22428
+ const out = [];
22429
+ for (const m of source.matchAll(rx)) {
22430
+ const span = balanceInline(source, m.index, m.index + m[0].length);
22431
+ if (span)
22432
+ out.push({ start: span.start, length: span.end - span.start });
22433
+ }
22434
+ return out.filter((s, i) => i === 0 || s.start >= out[i - 1].start + out[i - 1].length);
22435
+ }
22436
+ function balanceInline(src, start, end) {
22437
+ for (let guard = 0;guard < 8; guard++) {
22438
+ const stack = [];
22439
+ let strayClose = null;
22440
+ for (const t of src.slice(start, end).matchAll(INLINE_TAG_RX)) {
22441
+ const name = /^<\/?([a-z]+)/i.exec(t[0])[1].toLowerCase();
22442
+ if (t[0].endsWith("/>"))
22443
+ continue;
22444
+ if (t[0].startsWith("</")) {
22445
+ if (stack[stack.length - 1] === name)
22446
+ stack.pop();
22447
+ else if (!strayClose)
22448
+ strayClose = name;
22449
+ } else
22450
+ stack.push(name);
22451
+ }
22452
+ if (!strayClose && !stack.length)
22453
+ return { start, end };
22454
+ if (strayClose) {
22455
+ const before = new RegExp(`<${strayClose}(?:\\s[^<>]*)?>$`, "i").exec(src.slice(0, start));
22456
+ if (!before)
22457
+ return null;
22458
+ start -= before[0].length;
22459
+ continue;
22460
+ }
22461
+ const open = stack[stack.length - 1];
22462
+ const after = new RegExp(`^<\\/${open}\\s*>`, "i").exec(src.slice(end));
22463
+ if (!after)
22464
+ return null;
22465
+ end += after[0].length;
22466
+ }
22467
+ return null;
22468
+ }
22261
22469
  function looksEntityEscaped(find) {
22262
22470
  return !/<[a-zA-Z/]/.test(find) && /&lt;|&gt;/.test(find);
22263
22471
  }
@@ -22281,11 +22489,26 @@ function bumpLastUpdated(html, date) {
22281
22489
  return { html: html.replace(re, `$1${formatted}`), bumped: true };
22282
22490
  }
22283
22491
  function leadingIdentification(html) {
22492
+ return leadingIdentityText(html)?.includes("\xB7") ?? false;
22493
+ }
22494
+ function leadingIdentityText(html) {
22284
22495
  const m = /^(?:\s|<p>(?:\s|&nbsp;)*<\/p>)*<h3(?:\s[^>]*)?>([\s\S]*?)<\/h3>/i.exec(html);
22285
- if (!m)
22286
- return false;
22287
- const text = decodeEntities(m[1].replace(/<[^>]+>/g, "")).trim();
22288
- return text.includes("\xB7");
22496
+ return m ? decodeEntities(m[1].replace(/<[^>]+>/g, "")).trim() : null;
22497
+ }
22498
+ function identityProblem(text) {
22499
+ const line = text.trim();
22500
+ if (/[\r\n]/.test(line))
22501
+ return "it spans several lines";
22502
+ if (line.length > 160)
22503
+ return `it is ${line.length} characters long (160 at most)`;
22504
+ if (/^addendum\b/i.test(line))
22505
+ return "it is an addendum marker, not an identity";
22506
+ if (/\?/.test(line))
22507
+ return "it carries a question mark, which reads as a draft";
22508
+ const parts = line.split("\xB7").map((p) => p.trim());
22509
+ if (parts.length < 3 || parts.some((p) => !p))
22510
+ return `it has ${parts.filter(Boolean).length} "\xB7"-separated part(s); it needs at least three`;
22511
+ return null;
22289
22512
  }
22290
22513
  function duplicateHeadings(html) {
22291
22514
  const MARKER = /<h2(?:\s[^>]*)?>\s*(?:Addendum|Withdrawn|Recovered|Reopened)\s*(?:\u2014|\u2013|-)[^<]*<\/h2>/gi;
@@ -22582,11 +22805,38 @@ function unbalancedTags(html) {
22582
22805
  }
22583
22806
  return [...new Set(stack)];
22584
22807
  }
22808
+ function tableShapeIssues(html) {
22809
+ const issues = [];
22810
+ const cellSpan = (attrs) => Number(/colspan\s*=\s*"?(\d+)/i.exec(attrs ?? "")?.[1] ?? 1);
22811
+ for (const table of html.matchAll(/<table\b[^>]*>([\s\S]*?)<\/table>/gi)) {
22812
+ const rows = [...table[1].matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)].map((r) => [...r[1].matchAll(/<(t[hd])\b([^>]*)>([\s\S]*?)<\/\1>/gi)].map((c) => ({
22813
+ span: cellSpan(c[2]),
22814
+ text: decodeEntities(c[3].replace(/<[^>]+>/g, "")).trim()
22815
+ })));
22816
+ if (rows.length < 2)
22817
+ continue;
22818
+ const width = rows[0].reduce((sum, c) => sum + c.span, 0);
22819
+ for (const cells of rows.slice(1)) {
22820
+ const label = (cells[0]?.text ?? "").slice(0, 60) || "(empty row)";
22821
+ const count = cells.reduce((sum, c) => sum + c.span, 0);
22822
+ if (count !== width) {
22823
+ issues.push(`row "${label}" has ${count} cell(s), its header has ${width}`);
22824
+ continue;
22825
+ }
22826
+ const piped = cells.find((c) => (c.text.match(/\s\|\s/g)?.length ?? 0) >= Math.max(1, width - 2) && width > 1);
22827
+ if (piped && cells.filter((c) => c.text).length < width) {
22828
+ issues.push(`row "${label}" carries pipe-separated values inside one cell \u2014 send each value as its own <td>`);
22829
+ }
22830
+ }
22831
+ }
22832
+ return issues;
22833
+ }
22585
22834
  var LARGE_NOTE_CHARS = 40000;
22586
22835
  function structureReport(html) {
22587
22836
  return {
22588
22837
  duplicateHeadings: duplicateHeadings(html),
22589
22838
  unbalancedTags: unbalancedTags(html),
22839
+ tableShape: tableShapeIssues(html),
22590
22840
  size: html.length
22591
22841
  };
22592
22842
  }
@@ -23136,8 +23386,10 @@ var TIMELESS_KINDS = new Set(["thread", "user", "information", "domain"]);
23136
23386
  var DATED_PROSE_LIMIT = 4;
23137
23387
  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
23388
  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;
23389
+ function datedReferences(html, opts = {}) {
23390
+ const source = opts.register ? html.replace(/<table[\s\S]*?<\/table>/gi, " ") : html;
23391
+ const spaced = source.replace(/<\/?t[dh](?=[\s>])[^>]*>/gi, " ");
23392
+ return (toText(spaced, Number.MAX_SAFE_INTEGER).match(DATED_REF) ?? []).length;
23141
23393
  }
23142
23394
  var STRUCTURE_LINT_LIMIT = 40;
23143
23395
  var SECTION_EDIT_RISK_COUNT = 25;
@@ -23402,9 +23654,10 @@ async function sweep(trilium, cfg, opts = {}) {
23402
23654
  const kindHere = ownedLabel(n, "noteType");
23403
23655
  const isCurrentState = kindHere === "information" && /^current state$/i.test(n.title.trim());
23404
23656
  if (kindHere && TIMELESS_KINDS.has(kindHere) && !isCurrentState) {
23405
- const dated = datedReferences(content);
23657
+ const register = n.attributes.some((a) => a.noteId === n.noteId && a.type === "label" && a.name === "register");
23658
+ const dated = datedReferences(content, { register });
23406
23659
  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.`);
23660
+ 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
23661
  }
23409
23662
  }
23410
23663
  const lintKind = ownedLabel(n, "noteType");
@@ -23417,6 +23670,9 @@ async function sweep(trilium, cfg, opts = {}) {
23417
23670
  if (structure.unbalancedTags.length) {
23418
23671
  report.flagged.push(`unbalanced tags: ${n.title} [${n.noteId}] \u2014 <${structure.unbalancedTags.join(">, <")}> left open; the next write will auto-close them, or fix now with revise(mode=replace)`);
23419
23672
  }
23673
+ if (structure.tableShape.length) {
23674
+ report.flagged.push(`table shape: ${n.title} [${n.noteId}] \u2014 ${structure.tableShape.slice(0, 3).join("; ")}${structure.tableShape.length > 3 ? ` (+${structure.tableShape.length - 3} more)` : ""}. Rewrite the row with one <td> per column: revise(find=<anchor>, within="tr")`);
23675
+ }
23420
23676
  const profile = sectionProfile(content);
23421
23677
  const biggest = profile.largest[0];
23422
23678
  const crowded = profile.count >= SECTION_EDIT_RISK_COUNT;
@@ -23489,13 +23745,15 @@ async function sweep(trilium, cfg, opts = {}) {
23489
23745
  byDomain.get(domSlug).push(n);
23490
23746
  }
23491
23747
  const headingCache = new Map;
23492
- const headingsOf = async (n) => {
23748
+ const shapeOf = async (n) => {
23493
23749
  if (!headingCache.has(n.noteId)) {
23494
23750
  const content = await trilium.getNoteContent(n.noteId).catch(() => "");
23495
- headingCache.set(n.noteId, headingOutline(content).filter((h) => h.level <= 3).map((h) => h.text));
23751
+ const words = new Set(content.replace(/<[^>]+>/g, " ").toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 3));
23752
+ headingCache.set(n.noteId, { headings: headingOutline(content).filter((h) => h.level <= 3).map((h) => h.text), words });
23496
23753
  }
23497
23754
  return headingCache.get(n.noteId);
23498
23755
  };
23756
+ const GENERIC_HEADING = /^(last updated\b.*|overview|context|goal|sources|revision|resolution|notes?|summary)$/i;
23499
23757
  for (const [domSlug, notes] of byDomain) {
23500
23758
  const reported = new Set;
23501
23759
  for (let i = 0;i < notes.length; i++) {
@@ -23512,10 +23770,19 @@ async function sweep(trilium, cfg, opts = {}) {
23512
23770
  reported.add(pairKey);
23513
23771
  if (acknowledged(a) || acknowledged(bn))
23514
23772
  continue;
23515
- const [ha, hb] = await Promise.all([headingsOf(a), headingsOf(bn)]);
23516
- const overlap = ha.filter((h) => hb.some((x) => x.toLowerCase() === h.toLowerCase()));
23773
+ const [sa, sb] = await Promise.all([shapeOf(a), shapeOf(bn)]);
23774
+ const ha = sa.headings, hb = sb.headings;
23775
+ const overlap = ha.filter((h) => !GENERIC_HEADING.test(h) && hb.some((x) => x.toLowerCase() === h.toLowerCase()));
23776
+ let common = 0;
23777
+ for (const w of sa.words)
23778
+ if (sb.words.has(w))
23779
+ common++;
23780
+ const union = sa.words.size + sb.words.size - common;
23781
+ const similarity = union ? common / union : 0;
23782
+ if (!overlap.length && similarity < 0.45)
23783
+ continue;
23517
23784
  const show = (h) => h.length ? h.slice(0, 8).join(" \xB7 ") + (h.length > 8 ? " \xB7 \u2026" : "") : "(no headings)";
23518
- report.flagged.push(`near-duplicate subject: '${a.title}' [${a.noteId}] and '${bn.title}' [${bn.noteId}] in Domain/${domSlug} ` + `share '${shared.join("', '")}'. Sections \u2014 '${a.title}': ${show(ha)} | '${bn.title}': ${show(hb)}. ` + (overlap.length ? `${overlap.length} heading(s) in common (${overlap.slice(0, 5).join(", ")}), which is what a genuine split subject looks like \u2014 read both, merge into the older one, and forget() the other.` : `No headings in common, so these are most likely distinct notes that happen to share a title word \u2014 maintain(ack=["${a.noteId}"]) to silence this once you have confirmed it.`));
23785
+ report.flagged.push(`near-duplicate subject: '${a.title}' [${a.noteId}] and '${bn.title}' [${bn.noteId}] in Domain/${domSlug} ` + `share '${shared.join("', '")}' and ${Math.round(similarity * 100)}% of their vocabulary. Sections \u2014 '${a.title}': ${show(ha)} | '${bn.title}': ${show(hb)}. ` + (overlap.length ? `${overlap.length} heading(s) in common (${overlap.slice(0, 5).join(", ")}), which is what a genuine split subject looks like \u2014 read both, merge into the older one, and forget() the other.` : `No headings in common, but most of their wording is shared \u2014 read both; if they are distinct, maintain(ack=["${a.noteId}"]) silences this.`));
23519
23786
  }
23520
23787
  }
23521
23788
  }
@@ -23915,6 +24182,96 @@ async function catchUpDeletions(trilium, cfg, today) {
23915
24182
  return { windowDays, coverage: "full", deletionsFound: total, regenerated };
23916
24183
  }
23917
24184
 
24185
+ // src/seal.ts
24186
+ import { createHash } from "crypto";
24187
+ var GENESIS = "genesis";
24188
+ function sealText(html) {
24189
+ return decodeEntities(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
24190
+ }
24191
+ function sha(text) {
24192
+ return createHash("sha256").update(text, "utf8").digest("hex");
24193
+ }
24194
+ function sealRecord(text) {
24195
+ return [text.length, sha(text).slice(0, 16)];
24196
+ }
24197
+ function dayDigest(prev, date, records) {
24198
+ const canonical = Object.keys(records).sort().map((id) => `${id}:${records[id][0]}:${records[id][1]}`).join(",");
24199
+ return sha(`${prev}
24200
+ ${date}
24201
+ ${canonical}`);
24202
+ }
24203
+ function recordIntact(current, sealed) {
24204
+ if (current.length < sealed[0])
24205
+ return false;
24206
+ return sha(current.slice(0, sealed[0])).slice(0, 16) === sealed[1];
24207
+ }
24208
+ async function dayRecords(trilium, cfg, date) {
24209
+ const find = (ancestor, kind) => ancestor ? trilium.searchNotes(`#noteType=${kind} #created='${date}'`, { ancestorNoteId: ancestor, fastSearch: true, limit: 200, includeArchivedNotes: true }).then((r) => r.results).catch(() => []) : Promise.resolve([]);
24210
+ const [sessions, diary, entries] = await Promise.all([
24211
+ find(cfg.memory.sessions, "session"),
24212
+ find(cfg.llm.diary, "diary"),
24213
+ find(cfg.memory.threads, "threadEntry")
24214
+ ]);
24215
+ return [...sessions, ...diary, ...entries.filter((n) => n.title.trim() === `[${date}]`)];
24216
+ }
24217
+ async function sealedLogs(trilium, cfg) {
24218
+ if (!cfg.insights.logs)
24219
+ return [];
24220
+ const res = await trilium.searchNotes("#noteType=log #seal", { ancestorNoteId: cfg.insights.logs, fastSearch: true, limit: 1000, includeArchivedNotes: true }).catch(() => ({ results: [] }));
24221
+ return res.results.filter((n) => ownedLabel(n, "seal")).sort((a, b) => (ownedLabel(a, "created") ?? "").localeCompare(ownedLabel(b, "created") ?? ""));
24222
+ }
24223
+ async function sealDay(trilium, cfg, date, logNoteId) {
24224
+ const earlier = (await sealedLogs(trilium, cfg)).filter((n) => (ownedLabel(n, "created") ?? "") < date);
24225
+ const prev = earlier.length ? ownedLabel(earlier[earlier.length - 1], "seal") : GENESIS;
24226
+ const records = {};
24227
+ for (const n of await dayRecords(trilium, cfg, date)) {
24228
+ const content = await trilium.getNoteContent(n.noteId).catch(() => "");
24229
+ if (content)
24230
+ records[n.noteId] = sealRecord(sealText(content));
24231
+ }
24232
+ const digest = dayDigest(prev, date, records);
24233
+ await trilium.updateLabelValue(logNoteId, "sealRecords", JSON.stringify(records));
24234
+ await trilium.updateLabelValue(logNoteId, "sealPrev", prev);
24235
+ await trilium.updateLabelValue(logNoteId, "seal", digest);
24236
+ return { date, digest, records: Object.keys(records).length };
24237
+ }
24238
+ async function verifySeals(trilium, cfg, today, days = 7) {
24239
+ const logs = (await sealedLogs(trilium, cfg)).filter((n) => (ownedLabel(n, "created") ?? "") < today);
24240
+ const findings = [];
24241
+ const window = logs.slice(-days);
24242
+ for (const log of window) {
24243
+ const date = ownedLabel(log, "created") ?? "";
24244
+ const digest = ownedLabel(log, "seal") ?? "";
24245
+ const prev = ownedLabel(log, "sealPrev") ?? "";
24246
+ const i = logs.indexOf(log);
24247
+ const expectedPrev = i > 0 ? ownedLabel(logs[i - 1], "seal") ?? "" : prev;
24248
+ if (prev !== expectedPrev) {
24249
+ findings.push({ date, noteId: log.noteId, problem: `the chain breaks here \u2014 this seal links to ${prev.slice(0, 12)}\u2026, but the previous sealed day's digest is ${expectedPrev.slice(0, 12)}\u2026` });
24250
+ }
24251
+ let records;
24252
+ try {
24253
+ records = JSON.parse(ownedLabel(log, "sealRecords") ?? "{}");
24254
+ } catch {
24255
+ findings.push({ date, noteId: log.noteId, problem: "the sealed record list is unreadable" });
24256
+ continue;
24257
+ }
24258
+ if (dayDigest(prev, date, records) !== digest) {
24259
+ findings.push({ date, noteId: log.noteId, problem: "the sealed record list no longer matches its digest \u2014 the seal labels were edited" });
24260
+ }
24261
+ for (const [noteId, sealed] of Object.entries(records)) {
24262
+ const content = await trilium.getNoteContent(noteId).catch(() => null);
24263
+ if (content === null) {
24264
+ findings.push({ date, noteId, problem: "a sealed record is gone (deleted or unreadable)" });
24265
+ continue;
24266
+ }
24267
+ if (!recordIntact(sealText(content), sealed)) {
24268
+ findings.push({ date, noteId, problem: "a sealed record was changed after its day was sealed \u2014 its sealed text is no longer the start of its body. diff(noteId) and the note's revisions show what changed" });
24269
+ }
24270
+ }
24271
+ }
24272
+ return { checked: window.length, findings };
24273
+ }
24274
+
23918
24275
  // src/diffing.ts
23919
24276
  function revisionsSince(revisions, cutoff) {
23920
24277
  return revisions.filter((r) => (r.dateCreated ?? "").slice(0, 16) >= cutoff);
@@ -23942,8 +24299,10 @@ function blockDiff(before, after, pad = 1, cap = 40) {
23942
24299
  const midA = a.slice(head, a.length - tail);
23943
24300
  const midB = b.slice(head, b.length - tail);
23944
24301
  const lcs = changedLines(midA, midB);
23945
- const removed = lcs ? lcs.removed : midA;
23946
- const added = lcs ? lcs.added : midB;
24302
+ const meaningful = (arr) => arr.filter((l) => !/^<\/[a-zA-Z0-9]+>$/.test(l.trim()));
24303
+ const removed = meaningful(lcs ? lcs.removed : midA);
24304
+ const added = meaningful(lcs ? lcs.added : midB);
24305
+ const tables = tableDiff(before, after);
23947
24306
  const bound = (arr) => arr.length > cap ? [...arr.slice(0, cap), `\u2026 ${arr.length - cap} more line(s)`] : arr;
23948
24307
  const delta = after.length - before.length;
23949
24308
  return {
@@ -23954,6 +24313,7 @@ function blockDiff(before, after, pad = 1, cap = 40) {
23954
24313
  removed: bound(removed),
23955
24314
  added: bound(added),
23956
24315
  contextAfter: bound(a.slice(a.length - tail, a.length - tail + pad)),
24316
+ ...tables ? { tables } : {},
23957
24317
  summary: `${lcs ? `${lcs.hunks} change(s): ` : ""}${removed.length} block(s) removed, ${added.length} added, ${delta >= 0 ? "+" : ""}${delta} characters.`
23958
24318
  };
23959
24319
  }
@@ -23991,6 +24351,57 @@ function changedLines(a, b, budget = 4000000) {
23991
24351
  }
23992
24352
  return { removed, added, hunks };
23993
24353
  }
24354
+ function tableRows2(html) {
24355
+ const rows = [];
24356
+ for (const tr of html.matchAll(/<tr(?=[\s>])[^>]*>([\s\S]*?)<\/tr>/gi)) {
24357
+ 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());
24358
+ rows.push(cells);
24359
+ }
24360
+ return rows;
24361
+ }
24362
+ function tableDiff(before, after, cap = 30) {
24363
+ const key = (r) => r.join("\x00");
24364
+ const a = tableRows2(before);
24365
+ const b = tableRows2(after);
24366
+ const inB = new Map;
24367
+ for (const r of b)
24368
+ inB.set(key(r), (inB.get(key(r)) ?? 0) + 1);
24369
+ const inA = new Map;
24370
+ for (const r of a)
24371
+ inA.set(key(r), (inA.get(key(r)) ?? 0) + 1);
24372
+ const onlyIn = (rows, other) => {
24373
+ const left = new Map(other);
24374
+ return rows.filter((r) => {
24375
+ const n = left.get(key(r)) ?? 0;
24376
+ if (n > 0) {
24377
+ left.set(key(r), n - 1);
24378
+ return false;
24379
+ }
24380
+ return true;
24381
+ });
24382
+ };
24383
+ const gone = onlyIn(a, inB);
24384
+ const fresh = onlyIn(b, inA);
24385
+ if (!gone.length && !fresh.length)
24386
+ return null;
24387
+ const changedCells = [];
24388
+ const rowsRemoved = [];
24389
+ const unpaired = [...fresh];
24390
+ for (const r of gone) {
24391
+ const i = unpaired.findIndex((x) => x[0] === r[0] && x.length === r.length);
24392
+ if (i < 0) {
24393
+ rowsRemoved.push(r.join(" | "));
24394
+ continue;
24395
+ }
24396
+ const [pair] = unpaired.splice(i, 1);
24397
+ r.forEach((cell, col) => {
24398
+ if (cell !== pair[col])
24399
+ changedCells.push({ row: r[0].slice(0, 80), column: col + 1, before: cell, after: pair[col] });
24400
+ });
24401
+ }
24402
+ const clip = (arr) => arr.slice(0, cap);
24403
+ return { changedCells: clip(changedCells), rowsAdded: clip(unpaired.map((r) => r.join(" | "))), rowsRemoved: clip(rowsRemoved) };
24404
+ }
23994
24405
 
23995
24406
  // src/elements.ts
23996
24407
  var WITHIN_TAGS = ["tr", "td", "th", "li", "p", "blockquote", "figure", "table", "ul", "ol"];
@@ -24082,6 +24493,9 @@ async function skim(trilium, ancestorNoteId, opts = {}) {
24082
24493
  };
24083
24494
  }));
24084
24495
  }
24496
+ var blockParam = {
24497
+ 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)')
24498
+ };
24085
24499
  async function readFull(trilium, id, opts = {}) {
24086
24500
  const note = await trilium.getNote(id);
24087
24501
  const contentResult = await trilium.getNoteContentResult(id, note.type).catch(() => "");
@@ -24094,6 +24508,27 @@ async function readFull(trilium, id, opts = {}) {
24094
24508
  return { ...base, content: contentResult.content, contentEncoding: "base64", mime: contentResult.mime };
24095
24509
  }
24096
24510
  const content = contentResult;
24511
+ if (opts.block) {
24512
+ const found = addendumBlock(content, opts.block);
24513
+ if (!found.matched) {
24514
+ return {
24515
+ ...base,
24516
+ content: "",
24517
+ block: opts.block,
24518
+ matched: false,
24519
+ available: found.available,
24520
+ 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=."
24521
+ };
24522
+ }
24523
+ return {
24524
+ ...base,
24525
+ content: found.content,
24526
+ block: found.marker,
24527
+ matched: true,
24528
+ ...found.identity ? { identity: found.identity } : {},
24529
+ position: `${found.position} of ${found.of}`
24530
+ };
24531
+ }
24097
24532
  if (!opts.section) {
24098
24533
  return {
24099
24534
  ...base,
@@ -24165,18 +24600,19 @@ function registerMasterTools(server, trilium, brainRef) {
24165
24600
  // src/tools-llm.ts
24166
24601
  function registerLlmTools(server, trilium, brainRef) {
24167
24602
  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.`, {
24603
+ 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
24604
  which: enumType(["responsibilities", "protocols", "selfcorrection", "diary"]),
24170
24605
  id: stringType().optional().describe("diary only: the entry id from llm_recall (noteId is accepted as an alias)"),
24171
24606
  noteId: stringType().optional().describe("Alias of id"),
24172
24607
  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 }) => {
24608
+ occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)"),
24609
+ ...blockParam
24610
+ }, async ({ which, id, noteId: idAlias, section, occurrence, block }) => {
24175
24611
  if (which === "diary") {
24176
24612
  const entry = id ?? idAlias;
24177
24613
  if (!entry)
24178
24614
  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 }));
24615
+ return txt(await readFull(trilium, entry, { section, occurrence, block }));
24180
24616
  }
24181
24617
  const noteId = b().llm[which];
24182
24618
  if (!noteId) {
@@ -24212,20 +24648,22 @@ index passes ~24k characters, unless limit= says otherwise; with the total count
24212
24648
  date="yyyy-mm-dd" resolves straight to one day. A collection thread returns its
24213
24649
  Context plus its titled entries (alphabetical, with a lead). Read a child with memory(<child id>).
24214
24650
  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".`, {
24651
+ dates and block markers (no book body, no leads): the cheap answer to "what is the newest entry".
24652
+ block="14:05" (or "-1" for the newest) on a session or day entry returns that one addendum block.`, {
24216
24653
  ...idParams,
24217
24654
  date: stringType().optional().describe("Thread books only: resolve directly to this day's child note (yyyy-mm-dd)"),
24218
24655
  limit: numberType().int().positive().optional().describe("Dated threads: how many of the newest day-children to index (default 30)"),
24219
24656
  index: booleanType().optional().describe("Dated threads: ids, dates and block markers only \u2014 no book body, no block leads"),
24220
24657
  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 }) => {
24658
+ occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)"),
24659
+ ...blockParam
24660
+ }, async ({ id: idArg, noteId, date, limit, index, section, occurrence, block }) => {
24223
24661
  const id = idArg ?? noteId;
24224
24662
  if (!id)
24225
24663
  return missingId("memory");
24226
24664
  const note = await trilium.getNote(id).catch(() => null);
24227
24665
  if (!note || labelOf(note, "noteType") !== "thread")
24228
- return txt(await readFull(trilium, id, { section, occurrence }));
24666
+ return txt(await readFull(trilium, id, { section, occurrence, block }));
24229
24667
  if (isCollectionThread(note)) {
24230
24668
  const kids = await trilium.searchNotes("#noteType=threadEntry", { ancestorNoteId: id, fastSearch: true, limit: 500 }).catch(() => ({ results: [] }));
24231
24669
  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 +24678,7 @@ dates and block markers (no book body, no leads): the cheap answer to "what is t
24240
24678
  if (date) {
24241
24679
  const child = await trilium.searchNotes(`#noteType=threadEntry #created='${date}'`, { ancestorNoteId: id, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
24242
24680
  if (child.results[0])
24243
- return txt(await readFull(trilium, child.results[0].noteId, { section, occurrence }));
24681
+ return txt(await readFull(trilium, child.results[0].noteId, { section, occurrence, block }));
24244
24682
  return txt({ id, title: note.title, kind: "thread", note: `No entry for ${date}.` });
24245
24683
  }
24246
24684
  const shown = limit ?? MEMORY_INDEX_DEFAULT;
@@ -24372,6 +24810,35 @@ var txt3 = (obj) => ({
24372
24810
  content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }]
24373
24811
  });
24374
24812
  var today = () => localToday();
24813
+ function applyFindEdit(source, needle, replacement, index) {
24814
+ const spans = (hits, matchMode) => {
24815
+ if (index === undefined) {
24816
+ let out = "";
24817
+ let cursor = 0;
24818
+ for (const h of hits) {
24819
+ out += source.slice(cursor, h.start) + replacement;
24820
+ cursor = h.start + h.length;
24821
+ }
24822
+ return { html: out + source.slice(cursor), count: hits.length, matchMode };
24823
+ }
24824
+ const hit = hits[index - 1];
24825
+ if (!hit)
24826
+ return null;
24827
+ return { html: source.slice(0, hit.start) + replacement + source.slice(hit.start + hit.length), count: 1, matchMode };
24828
+ };
24829
+ const exact = [];
24830
+ for (let at = source.indexOf(needle);at !== -1; at = source.indexOf(needle, at + needle.length)) {
24831
+ exact.push({ start: at, length: needle.length });
24832
+ }
24833
+ if (exact.length)
24834
+ return spans(exact, "exact");
24835
+ const rx = tolerantFindRegex(needle);
24836
+ const tolerant = rx ? [...source.matchAll(rx)].map((m) => ({ start: m.index, length: m[0].length })) : [];
24837
+ if (tolerant.length)
24838
+ return spans(tolerant, "attribute-tolerant");
24839
+ const inline = inlineTolerantSpans(source, needle);
24840
+ return inline.length ? spans(inline, "inline-tolerant") : null;
24841
+ }
24375
24842
  var err = (code, detail, hint) => txt3({ error: code, detail, ...hint ? { hint } : {} });
24376
24843
  var labelOf2 = (n, name) => n.attributes.find((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === name)?.value;
24377
24844
  var hasLabel = (n, name) => n.attributes.some((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === name);
@@ -24406,6 +24873,8 @@ function registerTools(server, trilium, brainRef) {
24406
24873
  const preCloseSteps = new Map;
24407
24874
  let preCloseSeq = 0;
24408
24875
  const REQUIRED_PRECLOSE_STEPS = ["session", "addendum", "maintain", "remarks", "diary"];
24876
+ const LIGHT_PRECLOSE_STEPS = ["session", "remarks", "diary"];
24877
+ let wroteSinceClose = false;
24409
24878
  const gateNote = async (date) => {
24410
24879
  const cfg = b();
24411
24880
  if (!cfg.memory.sessions)
@@ -24464,6 +24933,7 @@ function registerTools(server, trilium, brainRef) {
24464
24933
  await trilium.updateLabelValue(noteId, "gate", "");
24465
24934
  preCloseSteps.clear();
24466
24935
  preCloseSeq = 0;
24936
+ wroteSinceClose = false;
24467
24937
  };
24468
24938
  const RECORD_KINDS = new Set(["session", "diary", "log", "threadEntry"]);
24469
24939
  const isRecordNote = (n) => {
@@ -24473,16 +24943,30 @@ function registerTools(server, trilium, brainRef) {
24473
24943
  return kind === "threadEntry" && /^\[\d{4}-\d{2}-\d{2}\]$/.test(n.title.trim());
24474
24944
  };
24475
24945
  const structuralFindings = (kind, html) => {
24476
- if (!html || kind && RECORD_KINDS.has(kind))
24946
+ if (!html)
24477
24947
  return {};
24948
+ const tableShape = tableShapeIssues(html);
24949
+ const shape = tableShape.length ? { tableShape, tableHint: 'A table row disagrees with its header \u2014 rewrite it with one <td> per column (revise(find=<anchor>, within="tr")).' } : {};
24950
+ if (kind && RECORD_KINDS.has(kind))
24951
+ return shape;
24478
24952
  const dupes = duplicateHeadings(html);
24479
24953
  if (!dupes.length)
24480
- return {};
24954
+ return shape;
24481
24955
  return {
24482
24956
  duplicateHeadings: dupes,
24483
- structureHint: "The note now carries duplicated section headings \u2014 merge them with revise(section=\u2026, mode=replace), or target one specifically with occurrence=."
24957
+ structureHint: "The note now carries duplicated section headings \u2014 merge them with revise(section=\u2026, mode=replace), or target one specifically with occurrence=.",
24958
+ ...shape
24484
24959
  };
24485
24960
  };
24961
+ const identityRefusal = (identity, html) => {
24962
+ const text = leadingIdentification(html) ? leadingIdentityText(html) : identity;
24963
+ if (!text)
24964
+ return null;
24965
+ const problem = identityProblem(text);
24966
+ if (!problem)
24967
+ return null;
24968
+ return err("malformed_identity", `The identification line "${text.slice(0, 100)}" is malformed: ${problem}. Nothing was written.`, 'Pass identity="LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]", e.g. "Claude Opus 5.5 \xB7 Claude Code \xB7 Interactive".');
24969
+ };
24486
24970
  const applyIcon = async (noteId, icon) => {
24487
24971
  const cls = icon ? normalizeIcon(icon) : "";
24488
24972
  if (!cls) {
@@ -24540,7 +25024,7 @@ ${block}` }));
24540
25024
  function collectionAppendRefusal(book) {
24541
25025
  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
25026
  }
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.`, {
25027
+ 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
25028
  depth: enumType(["digest", "full"]).optional().describe('Singleton detail: "digest" (default \u2014 section headings + preview + size) or "full" (every singleton inline; token-heavy)')
24545
25029
  }, async ({ depth }) => {
24546
25030
  const cfg = b();
@@ -24633,9 +25117,9 @@ ${block}` }));
24633
25117
  hygiene: { scanned: hygiene.scanned, fixed: hygiene.fixed.length, transitions: hygiene.transitions, flagged: hygiene.flagged, ...hygiene.suppressed ? { suppressed: hygiene.suppressed } : {} }
24634
25118
  });
24635
25119
  });
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.
25120
+ 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
25121
 
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.`, {
25122
+ 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. A session that wrote no content gets a light close: session() returns only the short next[] (remarks, a one-paragraph diary, close) and close() does not require addendum() or maintain().`, {
24639
25123
  date: stringType().optional().describe("ISO date YYYY-MM-DD (default: today)"),
24640
25124
  full: booleanType().optional().describe("Inline every singleton's content and the diary body (default: stubs only)"),
24641
25125
  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")`),
@@ -24645,6 +25129,19 @@ Protocol: 1. update master singletons with what the session taught about the use
24645
25129
  const cfg = b();
24646
25130
  if (!cfg.master.root || !cfg.llm.root)
24647
25131
  throw new Error("BrainLLM not bootstrapped \u2014 run bootstrap.");
25132
+ if (!(await readGate(d)).has("write")) {
25133
+ await markStep("session", d);
25134
+ return txt3({
25135
+ date: d,
25136
+ closeMode: "light",
25137
+ note: "No content was written this session, so addendum() and maintain() are not required and there is nothing to audit. If you learned something durable about the user or yourself, revise that singleton first \u2014 that makes it a full close, and session() will say so when re-run.",
25138
+ next: [
25139
+ "Call remarks() \u2014 one short cue for a short session.",
25140
+ "Call diary() \u2014 one short paragraph: what the session was and anything worth the team knowing.",
25141
+ "Call close() \u2014 commit the session log."
25142
+ ]
25143
+ });
25144
+ }
24648
25145
  const fetchSingleton = async (id) => {
24649
25146
  if (!full) {
24650
25147
  const note = await trilium.getNote(id);
@@ -24693,6 +25190,12 @@ Protocol: 1. update master singletons with what the session taught about the use
24693
25190
  selfcorrection
24694
25191
  };
24695
25192
  const touchedToday = Object.keys(singletonStubs).filter((k) => singletonStubs[k]?.lastModified === d);
25193
+ const cutoff = `${d} 00:00`;
25194
+ 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);
25195
+ const changedNotes = changedToday ? await Promise.all(changedToday.slice(0, 20).map(async (n) => {
25196
+ const revisions = await trilium.getNoteRevisions(n.noteId).catch(() => []);
25197
+ return { id: n.noteId, title: n.title, kind: ownedLabel(n, "noteType") ?? "", writes: revisionsSince(revisions, cutoff).length };
25198
+ })) : null;
24696
25199
  const pending = {
24697
25200
  addendums: pendingAddendums === null ? "unknown" : pendingAddendums,
24698
25201
  maintenanceFlags: hygiene?.flagged.length ?? 0,
@@ -24710,6 +25213,12 @@ Protocol: 1. update master singletons with what the session taught about the use
24710
25213
  diary: diaryEntry,
24711
25214
  maintenance: hygiene ? { scanned: hygiene.scanned, fixed: hygiene.fixed.length, transitions: hygiene.transitions, flagged: hygiene.flagged, ...hygiene.suppressed ? { suppressed: hygiene.suppressed } : {} } : "skipped",
24712
25215
  pending,
25216
+ changedToday: changedNotes ? {
25217
+ notes: changedToday.length,
25218
+ ...changedToday.length > changedNotes.length ? { shown: changedNotes.length } : {},
25219
+ list: changedNotes,
25220
+ 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."
25221
+ } : "unavailable",
24713
25222
  audit: scoped ? undefined : {
24714
25223
  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
25224
  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,16 +25233,23 @@ Protocol: 1. update master singletons with what the session taught about the use
24724
25233
  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
25234
  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
25235
  "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().",
25236
+ "Call diary() \u2014 write the day's record with the cues in hand; the gate counts it only after remarks().",
24728
25237
  "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
25238
  ]
24730
25239
  });
24731
25240
  });
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 () => {
25241
+ 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
25242
  const cfg = b();
24734
25243
  if (!cfg.root)
24735
25244
  return txt3({ status: "uninitialized", action: "Run bootstrap first." });
24736
25245
  await markStep("remarks");
25246
+ if (!(await readGate(localToday())).has("write")) {
25247
+ return txt3({
25248
+ closeMode: "light",
25249
+ cue: "One short paragraph, first person: what this session was, how it went, and anything about BrainLLM worth the team knowing. Skip what has nothing real to say.",
25250
+ next: ["Write it via diary(body, identity), then call close()."]
25251
+ });
25252
+ }
24737
25253
  return txt3({
24738
25254
  cues: {
24739
25255
  experience: [
@@ -24754,7 +25270,7 @@ Protocol: 1. update master singletons with what the session taught about the use
24754
25270
  ]
24755
25271
  });
24756
25272
  });
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.`, {
25273
+ 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; a session that wrote no content (light close) needs only session(), remarks() and diary() (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
25274
  summary: stringType().describe("What happened this session \u2014 factual, concise prose"),
24759
25275
  title: stringType().optional().describe("Short session title \u2014 appears as an <h2> heading above Summary"),
24760
25276
  identity: stringType().describe(`Identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" \u2014 rendered as the block's h3 (required)`),
@@ -24777,9 +25293,11 @@ Protocol: 1. update master singletons with what the session taught about the use
24777
25293
  }
24778
25294
  }
24779
25295
  const gate = await readGate(gateDate);
24780
- const missing = continued ? [] : REQUIRED_PRECLOSE_STEPS.filter((step) => !gate.has(step));
25296
+ const light = !gate.has("write");
25297
+ const required = light ? LIGHT_PRECLOSE_STEPS : REQUIRED_PRECLOSE_STEPS;
25298
+ const missing = continued ? [] : required.filter((step) => !gate.has(step));
24781
25299
  if (missing.length && !force) {
24782
- return err("preclose_incomplete", `close() refused \u2014 these pre-close steps haven't run yet this session: ${missing.join(", ")}.`, `Call ${missing.map((s) => `${s}()`).join(", ")} first, or pass force=true if one of them genuinely has nothing to log.`);
25300
+ return err("preclose_incomplete", `close() refused \u2014 these pre-close steps haven't run yet this session: ${missing.join(", ")}.${light ? " (No content was written this session, so addendum() and maintain() are not required.)" : ""}`, `Call ${missing.map((s) => `${s}()`).join(", ")} first, or pass force=true if one of them genuinely has nothing to log.`);
24783
25301
  }
24784
25302
  const seq = (step) => gate.get(step) ?? 0;
24785
25303
  const orderOk = continued || missing.length > 0 || seq("session") < seq("remarks") && seq("remarks") < seq("diary");
@@ -24795,6 +25313,9 @@ Protocol: 1. update master singletons with what the session taught about the use
24795
25313
  if (!identity && !leadingIdentification(summaryHtml)) {
24796
25314
  return err("missing_identity", 'Session addendums open with the canonical identification line (h3): "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]".', `Pass identity="Claude \u2026 \xB7 <environment> \xB7 <agent/mode>" on close() \u2014 the server renders it as the block's h3.`);
24797
25315
  }
25316
+ const badIdentity = identityRefusal(identity, summaryHtml);
25317
+ if (badIdentity)
25318
+ return badIdentity;
24798
25319
  const identityBlock = identity && !leadingIdentification(summaryHtml) ? `<h3>${escapeHtml(identity)}</h3>
24799
25320
  ` : "";
24800
25321
  const titleBlock = title ? `<h2>${escapeHtml(title)}</h2>
@@ -24849,6 +25370,7 @@ ${contentBlock}` }));
24849
25370
  if (!hasEdge(logNote, noteId))
24850
25371
  await trilium.addRelation(logReport.noteId, "references", noteId).catch(() => null);
24851
25372
  }
25373
+ const sealReport = logReport?.noteId ? await sealDay(trilium, cfg, d, logReport.noteId).catch(() => null) : null;
24852
25374
  let backupStatus = "disabled";
24853
25375
  let backupName = rollingBackupName(d);
24854
25376
  if (backup !== false) {
@@ -24870,6 +25392,8 @@ ${contentBlock}` }));
24870
25392
  ...caughtUp?.coverage === "unknown" ? { deletionCatchUp: "unknown \u2014 Trilium's deletion history could not be read; the configured catch-up window is not fully verified" } : caughtUp && caughtUp.deletionsFound ? { deletionCatchUp: `${caughtUp.deletionsFound} deletion(s) caught up \u2014 logs regenerated for: ${caughtUp.regenerated.join(", ")}` } : {},
24871
25393
  ...iconSet ? { icon: iconSet } : {},
24872
25394
  ...continued ? { continuing: true } : {},
25395
+ ...sealReport ? { seal: `${sealReport.records} record(s) of ${d} sealed (${sealReport.digest.slice(0, 12)}\u2026), chained to the previous sealed day` } : { seal: "not written \u2014 the day's log note was unavailable" },
25396
+ ...light && !continued ? { closeMode: "light \u2014 no content was written this session, so addendum() and maintain() were not required" } : {},
24873
25397
  ...missing.length || !orderOk ? { bypassed: [...missing, ...!orderOk ? ["ordering(session\u2192remarks\u2192diary)"] : []] } : {},
24874
25398
  ...warnings.length ? { sanitized: warnings } : {}
24875
25399
  });
@@ -24881,13 +25405,78 @@ ${contentBlock}` }));
24881
25405
  const backupName = name ?? rollingBackupName(d);
24882
25406
  try {
24883
25407
  await trilium.createBackup(backupName);
24884
- return txt3({ ok: true, backup: backupName, backupStatus: "completed", date: d });
25408
+ const named = name && name !== rollingBackupName(d);
25409
+ if (named) {
25410
+ const cfg = b();
25411
+ (cfg.backups ??= {})[backupName] = d;
25412
+ try {
25413
+ saveConfig(cfg);
25414
+ } catch {}
25415
+ }
25416
+ const kept = Object.keys(b().backups ?? {}).length;
25417
+ return txt3({
25418
+ ok: true,
25419
+ backup: backupName,
25420
+ backupStatus: "completed",
25421
+ date: d,
25422
+ ...named ? { namedBackups: kept, note: `A named backup stays on Trilium's volume until deleted there. ${kept} named backup(s) recorded; health() reports them.` } : {}
25423
+ });
24885
25424
  } catch (error) {
24886
25425
  return txt3({ ok: false, backup: backupName, backupStatus: "failed", error: error instanceof Error ? error.message : String(error), date: d });
24887
25426
  }
24888
25427
  });
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"),
25428
+ const SIZE_BANDS = [
25429
+ { min: 1e7, max: 50000000 },
25430
+ { min: 1e6, max: 1e7 },
25431
+ { min: 1e5, max: 1e6 },
25432
+ { min: 1e4, max: 1e5 }
25433
+ ];
25434
+ const NAMED_BACKUP_WARN = 6;
25435
+ 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 () => {
25436
+ const cfg = b();
25437
+ const info = await trilium.getAppInfo().catch(() => null);
25438
+ const count = (q) => trilium.searchNotes(q, { limit: 1e5, fastSearch: true, includeArchivedNotes: true }).then((r) => r.results.length).catch(() => null);
25439
+ const bandCounts = await Promise.all(SIZE_BANDS.map((band) => count(`note.contentAndAttachmentsAndRevisionsSize >= ${band.min} AND note.contentAndAttachmentsAndRevisionsSize < ${band.max}`)));
25440
+ const typed = await count("#noteType");
25441
+ let estimate = 0;
25442
+ SIZE_BANDS.forEach((band, i) => {
25443
+ estimate += (bandCounts[i] ?? 0) * Math.sqrt(band.min * band.max);
25444
+ });
25445
+ const heavy = await trilium.searchNotes("note.contentAndAttachmentsAndRevisionsSize >= 1000000", {
25446
+ limit: 5,
25447
+ orderBy: "contentAndAttachmentsAndRevisionsSize",
25448
+ orderDirection: "desc",
25449
+ includeArchivedNotes: true
25450
+ }).then((r) => r.results).catch(() => []);
25451
+ const heaviest = await Promise.all(heavy.map(async (n) => {
25452
+ const revisions = await trilium.getNoteRevisions(n.noteId).catch(() => []);
25453
+ const revisionChars = revisions.reduce((sum, r) => sum + (r.contentLength ?? 0), 0);
25454
+ return { id: n.noteId, title: n.title, revisions: revisions.length, revisionChars };
25455
+ }));
25456
+ const named = Object.entries(cfg.backups ?? {}).sort((x, y) => x[1].localeCompare(y[1]));
25457
+ const mb = (n) => `${(n / 1e6).toFixed(1)} MB`;
25458
+ const flags = [];
25459
+ if (named.length >= NAMED_BACKUP_WARN)
25460
+ 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.`);
25461
+ for (const h of heaviest) {
25462
+ if (h.revisions >= 200)
25463
+ 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.`);
25464
+ }
25465
+ return txt3({
25466
+ trilium: info ? { appVersion: info.appVersion, dbVersion: info.dbVersion, buildDate: info.buildDate } : { error: "app-info unreachable" },
25467
+ notes: { typed: typed ?? undefined, bySize: SIZE_BANDS.map((band, i) => ({ atLeast: mb(band.min), below: mb(band.max), notes: bandCounts[i] })) },
25468
+ estimatedSize: `${mb(estimate)} (content, attachments and revisions of notes over 10 kB; an estimate from size bands, not a measurement)`,
25469
+ heaviest,
25470
+ backups: {
25471
+ rollingSlots: "7 (brainllm-mon \u2026 brainllm-sun), overwritten weekly by close()",
25472
+ named: named.map(([name, date]) => ({ name, date })),
25473
+ note: "Only backups taken through this server are listed. Each is roughly the size of the database."
25474
+ },
25475
+ ...flags.length ? { flags } : { flags: [], status: "No growth findings." }
25476
+ });
25477
+ });
25478
+ 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().`, {
25479
+ body: stringType().describe("The diary text, in the first person"),
24891
25480
  identity: stringType().describe(`Identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" \u2014 rendered as the block's h3 (required)`),
24892
25481
  icon: stringType().optional().describe("Display icon for the day's entry \u2014 a boxicons class or bare name; normalized server-side"),
24893
25482
  date: stringType().optional().describe("ISO date YYYY-MM-DD (default: today)")
@@ -24902,6 +25491,9 @@ ${contentBlock}` }));
24902
25491
  if (!identity && !leadingIdentification(sanitized.html)) {
24903
25492
  return err("missing_identity", 'Diary addendums open with the canonical identification line (h3): "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]".', `Pass identity="Claude \u2026 \xB7 <environment> \xB7 <agent/mode>" on diary() \u2014 the server renders it as the block's h3.`);
24904
25493
  }
25494
+ const badIdentity = identityRefusal(identity, sanitized.html);
25495
+ if (badIdentity)
25496
+ return badIdentity;
24905
25497
  const html = identity && !leadingIdentification(sanitized.html) ? `<h3>${escapeHtml(identity)}</h3>
24906
25498
  ${sanitized.html}` : sanitized.html;
24907
25499
  const found = await trilium.searchNotes(`#noteType=diary #created='${d}'`, { ancestorNoteId: parentId, fastSearch: true, limit: 1 });
@@ -25084,7 +25676,8 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25084
25676
  ...createdDomain ? { createdDomain: domainTitle } : {},
25085
25677
  ...revisionChanges.length ? { revision: revisionChanges } : {},
25086
25678
  ...sourceMerge,
25087
- ...revisionKeys.length ? { revisionRows: revisionKeys } : {},
25679
+ ...revisionKeys.length ? { revisionRows: revisionKeys.length } : {},
25680
+ ...revisionChanges.some((c) => c.startsWith("added:")) && revisionKeys.length ? { 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." } : {},
25088
25681
  ...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." } : {},
25089
25682
  ...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." } : {},
25090
25683
  ...connected.length ? { connected } : {},
@@ -25182,6 +25775,9 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25182
25775
  if (kind === "thread" && !identity && !leadingIdentification(html)) {
25183
25776
  return err("missing_identity", 'Thread addendums open with the canonical identification line (h3): "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]".', `Pass identity="Claude \u2026 \xB7 <environment> \xB7 <agent/mode>" \u2014 the server renders it as the addendum's h3.`);
25184
25777
  }
25778
+ const badIdentity = identityRefusal(identity, html);
25779
+ if (badIdentity)
25780
+ return badIdentity;
25185
25781
  const block = identity && !leadingIdentification(html) ? `<h3>${escapeHtml(identity)}</h3>
25186
25782
  ${html}` : html;
25187
25783
  if (kind === "thread") {
@@ -25201,7 +25797,7 @@ ${html}` : html;
25201
25797
  const connected = await wireRequested(existing.noteId);
25202
25798
  const iconSet = await applyIcon(existing.noteId, icon);
25203
25799
  const relations = relationSnippet(existing);
25204
- return txt3({ action: "updated", noteId: existing.noteId, entryId: entry.noteId, entryAction: entry.action, kind, title: existing.title, ...connected.length ? { connected } : {}, ...iconSet ? { icon: iconSet } : {}, ...relations ? { relations } : {}, ...sanitizeWarnings.length ? { sanitized: sanitizeWarnings } : {} });
25800
+ return txt3({ action: "updated", noteId: existing.noteId, entryId: entry.noteId, entryAction: entry.action, 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 } : {} });
25205
25801
  }
25206
25802
  const current = await trilium.getNoteContent(existing.noteId);
25207
25803
  if (isDuplicateAppend(current, block))
@@ -25265,7 +25861,7 @@ ${block}`), d);
25265
25861
  ...sanitizeWarnings.length ? { sanitized: sanitizeWarnings } : {}
25266
25862
  });
25267
25863
  });
25268
- server.tool("recall", `Ranked search across the whole brain \u2014 label, title and full-text strategies merged, with kind and status. Pass domain= whenever you know the area. orderBy/orderDirection sort by date; fastSearch= scans titles and labels only; regex= matches note bodies with a real regular expression; a fuzzy pass runs automatically when exact results are thin (hits marked fuzzy: true are leads, not answers). includeArchived= includes archived notes. A thin result is evidence about the query, not the brain.`, {
25864
+ server.tool("recall", `Ranked search across the whole brain \u2014 label, title and full-text strategies merged, with kind and status. Pass domain= whenever you know the area. orderBy/orderDirection sort by date; fastSearch= scans titles and labels only; regex= matches note bodies with a real regular expression; a fuzzy pass runs automatically when exact results are thin (hits marked fuzzy: true are leads, not answers). Recently changed notes rank slightly higher at equal relevance. Regex hits carry evidence: the matched passage; records are included unless kinds= narrows. includeArchived= includes archived notes. A thin result is evidence about the query, not the brain.`, {
25269
25865
  query: stringType().describe("What to find \u2014 natural phrasing is fine"),
25270
25866
  kinds: arrayType(enumType(Kinds)).optional().describe("Restrict to these kinds"),
25271
25867
  domain: stringType().optional().describe("Restrict to a knowledge domain"),
@@ -25328,10 +25924,21 @@ ${block}`), d);
25328
25924
  } catch (e) {
25329
25925
  return err("invalid_pattern", `Not a valid regular expression: ${e.message}`, "Escape backslashes \u2014 a JSON string needs \\\\d for \\d.");
25330
25926
  }
25331
- const candidates = (await run(`note.content %= '${escapeQueryRegex(regex)}'`)).filter(filterNote);
25927
+ const backendPattern = triliumSearchForm(regex);
25928
+ const widened = backendPattern !== regex;
25929
+ const candidates = (await run(`note.content %= '${escapeQueryRegex(backendPattern)}'`)).filter(filterNote);
25332
25930
  const confirmed = [];
25333
25931
  let rejected = 0;
25334
25932
  let tagSpanning = 0;
25933
+ let recordHits = 0;
25934
+ const evidenceIn = (text) => {
25935
+ const m = new RegExp(re.source, "i").exec(text);
25936
+ if (!m)
25937
+ return "";
25938
+ const from = Math.max(0, m.index - 80);
25939
+ const to = Math.min(text.length, m.index + m[0].length + 80);
25940
+ return `${from > 0 ? "\u2026" : ""}${text.slice(from, to).replace(/\s+/g, " ").trim()}${to < text.length ? "\u2026" : ""}`;
25941
+ };
25335
25942
  for (const n of candidates) {
25336
25943
  if (confirmed.length >= max)
25337
25944
  break;
@@ -25341,18 +25948,28 @@ ${block}`), d);
25341
25948
  continue;
25342
25949
  }
25343
25950
  const rawHit = re.test(content);
25344
- const projectedHit = !rawHit && re.test(stripTagsWithMap(content).text);
25951
+ const projected = stripTagsWithMap(content).text;
25952
+ const projectedHit = re.test(projected);
25345
25953
  if (rawHit || projectedHit) {
25346
- if (projectedHit)
25954
+ if (projectedHit && !rawHit)
25347
25955
  tagSpanning++;
25348
- confirmed.push(n);
25956
+ const kind = labelOf2(n, "noteType");
25957
+ if (kind === "session" || kind === "diary" || kind === "log" || kind === "threadEntry" && /^\[\d{4}-\d{2}-\d{2}\]$/.test(n.title.trim()))
25958
+ recordHits++;
25959
+ confirmed.push({ note: n, evidence: decodeEntities(projectedHit ? evidenceIn(projected) : evidenceIn(toText(content, 1e5))) });
25349
25960
  } else
25350
25961
  rejected++;
25351
25962
  }
25352
- const results = await Promise.all(confirmed.map(buildResult));
25963
+ const results = await Promise.all(confirmed.map(async ({ note, evidence }, i) => {
25964
+ const { snippet: _snippet, ...base } = await buildResult(note, i);
25965
+ return { ...base, ...evidence ? { evidence } : {} };
25966
+ }));
25353
25967
  const notes = [
25354
25968
  rejected ? `${rejected} backend candidate(s) did not actually match the pattern and were dropped \u2014 results are verified against the real regex, not just the search index.` : null,
25355
25969
  tagSpanning ? `${tagSpanning} match(es) were found only after stripping markup \u2014 the phrase is split by an inline tag there.` : null,
25970
+ widened ? "Diacritic characters (\xB7, accents) were normalised away for Trilium's pre-filter, which strips them from note text before matching; every result was then verified against your exact pattern." : null,
25971
+ recordHits ? `${recordHits} result(s) are records (sessions, diary, dated thread entries) \u2014 included by default; kinds= narrows to maintained notes.` : null,
25972
+ candidates.length >= 30 && confirmed.length < max ? "The backend returned its 30-candidate cap, so further matches may exist \u2014 narrow with domain= or kinds=, or use consistency() for an exhaustive scan." : null,
25356
25973
  results.length === 0 ? "No bodies matched that pattern, searched both as stored HTML and tag-stripped. Note that Trilium's %= pre-filter reads a striptags'd copy, so a pattern anchored ON tags may never reach verification \u2014 consistency() scans exhaustively if you need certainty." : null,
25357
25974
  results.length === 0 && regex.includes("\\") ? `The pattern reached the server as /${regex}/i. In it, \\d is a digit and \\\\ is one literal backslash (a Windows path separator); a backslash doubled once too often matches a literal backslash instead of escaping. If that is not the pattern you meant, adjust the escaping and retry.` : null
25358
25975
  ].filter(Boolean);
@@ -25413,7 +26030,12 @@ ${block}`), d);
25413
26030
  fuzzyIds.add(n.noteId);
25414
26031
  add(byFuzzy, 0.5);
25415
26032
  }
25416
- const ranked = [...scores.values()].filter(({ note }) => filterNote(note)).sort((a, b2) => b2.score - a.score || (a.note.dateModified < b2.note.dateModified ? 1 : -1)).slice(0, max);
26033
+ const now = Date.now();
26034
+ const recency = (n) => {
26035
+ const age = Math.max(0, (now - new Date(n.dateModified.replace(" ", "T")).getTime()) / 86400000);
26036
+ return Number.isFinite(age) ? 0.9 * Math.pow(0.5, age / 14) : 0;
26037
+ };
26038
+ const ranked = [...scores.values()].filter(({ note }) => filterNote(note)).map((s) => ({ ...s, rank: s.score + recency(s.note) })).sort((a, b2) => b2.rank - a.rank || (a.note.dateModified < b2.note.dateModified ? 1 : -1)).slice(0, max);
25417
26039
  const results = await Promise.all(ranked.map(async ({ note }, i) => {
25418
26040
  const row = await buildResult(note, i);
25419
26041
  return fuzzyIds.has(note.noteId) ? { ...row, fuzzy: true } : row;
@@ -25425,10 +26047,11 @@ ${block}`), d);
25425
26047
  ...results.length === 0 ? noMatch : {}
25426
26048
  });
25427
26049
  });
25428
- server.tool("domain", `Everything for a domain, topic or project: its knowledge book (if any) plus every note carrying a matching #domain or #topic slug, grouped by kind, each with idle days and a stale flag. The reliable retrieval path for an area; use recall() for keyword search.`, {
26050
+ server.tool("domain", `Everything for a domain, topic or project: its knowledge book (if any) plus every note carrying a matching #domain or #topic slug, grouped by kind, each with idle days and a stale flag; outline=true adds every maintained note's headings and size. The reliable retrieval path for an area; use recall() for keyword search.`, {
25429
26051
  name: stringType().describe("Domain, topic, or project name"),
25430
- includeArchived: booleanType().optional().describe("Include archived/resolved items (default: false)")
25431
- }, async ({ name, includeArchived }) => {
26052
+ includeArchived: booleanType().optional().describe("Include archived/resolved items (default: false)"),
26053
+ outline: booleanType().optional().describe("Also return each maintained note's headings (h2\u2013h4) and size \u2014 plan a multi-note rewrite in one call instead of one outline() per note")
26054
+ }, async ({ name, includeArchived, outline }) => {
25432
26055
  const cfg = b();
25433
26056
  const slug = slugify(name);
25434
26057
  const CAP = 500;
@@ -25455,6 +26078,19 @@ ${block}`), d);
25455
26078
  const staleAfter = cfg.policy.staleAfterDays;
25456
26079
  const idleSince = (iso) => Math.max(0, Math.floor((Date.now() - new Date(iso.replace(" ", "T")).getTime()) / 86400000));
25457
26080
  const RECORD_ROWS = new Set(["session", "diary", "log", "threadEntry"]);
26081
+ const outlines = new Map;
26082
+ if (outline) {
26083
+ await Promise.all(all.filter((n) => {
26084
+ const k = ownedLabel(n, "noteType");
26085
+ return k && k !== "domain" && !RECORD_ROWS.has(k);
26086
+ }).map(async (n) => {
26087
+ const content = await trilium.getNoteContent(n.noteId).catch(() => "");
26088
+ outlines.set(n.noteId, {
26089
+ headings: headingOutline(content).filter((h) => !/^Last updated\b/i.test(h.text)).map((h) => `${"#".repeat(h.level - 1)} ${h.text}`),
26090
+ size: content.length
26091
+ });
26092
+ }));
26093
+ }
25458
26094
  const groups = {};
25459
26095
  let staleCount = 0;
25460
26096
  for (const n of all) {
@@ -25478,7 +26114,8 @@ ${block}`), d);
25478
26114
  ...stale ? { stale: true } : {},
25479
26115
  ...hasLabel(n, "mandate") ? { mandate: true } : {},
25480
26116
  ...hasLabel(n, "archived") ? { archived: true } : {},
25481
- ...relations ? { relations } : {}
26117
+ ...relations ? { relations } : {},
26118
+ ...outlines.has(n.noteId) ? outlines.get(n.noteId) : {}
25482
26119
  });
25483
26120
  }
25484
26121
  const total = all.filter((n) => {
@@ -25508,19 +26145,23 @@ ${block}`), d);
25508
26145
  ...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
26146
  });
25510
26147
  });
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) => {
26148
+ const READ_BUDGET = 80000;
26149
+ 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.`, {
26150
+ ids: arrayType(stringType()).min(1).max(10).describe("Note ids to read \u2014 one body per id, one round trip (cap 10)"),
26151
+ text: booleanType().optional().describe("Return readable plain text instead of stored HTML (default false)")
26152
+ }, async ({ ids, text }) => {
26153
+ const fetched = await Promise.all(ids.map(async (id) => {
25515
26154
  try {
25516
26155
  const note = await trilium.getNote(id);
25517
26156
  const contentResult = await trilium.getNoteContentResult(id, note.type);
25518
26157
  const relations = relationSnippet(note);
26158
+ const raw = typeof contentResult === "string" ? contentResult : contentResult.content;
26159
+ const content = text && typeof contentResult === "string" ? readableText(raw) : raw;
25519
26160
  return {
25520
26161
  id,
25521
26162
  title: note.title,
25522
26163
  kind: ownedLabel(note, "noteType") ?? undefined,
25523
- content: typeof contentResult === "string" ? contentResult : contentResult.content,
26164
+ content,
25524
26165
  ...typeof contentResult === "string" ? {} : { contentEncoding: contentResult.encoding, mime: contentResult.mime },
25525
26166
  ...relations ? { relations } : {}
25526
26167
  };
@@ -25528,14 +26169,102 @@ ${block}`), d);
25528
26169
  return { id, missing: true };
25529
26170
  }
25530
26171
  }));
25531
- return txt3({ count: notes.length, notes });
26172
+ const notes = [];
26173
+ const deferred = [];
26174
+ let used = 0;
26175
+ for (const n of fetched) {
26176
+ const size = "content" in n ? n.content.length : 0;
26177
+ if ("content" in n && notes.length && used + size > READ_BUDGET) {
26178
+ deferred.push({ id: n.id, title: n.title, size });
26179
+ continue;
26180
+ }
26181
+ notes.push(n);
26182
+ used += size;
26183
+ }
26184
+ return txt3({
26185
+ count: notes.length,
26186
+ notes,
26187
+ ...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"}.` } : {}
26188
+ });
25532
26189
  });
26190
+ const reviseDomain = async (name, plan, dry, d) => {
26191
+ if (plan.some((e) => !e.find))
26192
+ return err("missing_param", "Every find must be non-empty.");
26193
+ const cfg = b();
26194
+ const slug = slugify(name);
26195
+ const CAP = 300;
26196
+ const search = (q) => trilium.searchNotes(q, { ancestorNoteId: cfg.root, limit: CAP, fastSearch: true }).then((r) => r.results).catch(() => []);
26197
+ const [byDomain, byTopic] = await Promise.all([search(`#domain='${slug}'`), search(`#topic='${slug}'`)]);
26198
+ const RECORDS = new Set(["session", "diary", "log", "threadEntry", "domain"]);
26199
+ const seen = new Set;
26200
+ const targets = [...byDomain, ...byTopic].filter((n) => {
26201
+ if (seen.has(n.noteId))
26202
+ return false;
26203
+ seen.add(n.noteId);
26204
+ const kind = ownedLabel(n, "noteType");
26205
+ return !!kind && !RECORDS.has(kind) && !isContainer(cfg, n.noteId) && !hasLabel(n, "archived");
26206
+ });
26207
+ if (!targets.length)
26208
+ return txt3({ ok: true, domain: name, slug, scanned: 0, note: `No maintained notes carry #domain or #topic "${slug}".` });
26209
+ const changes = [];
26210
+ let total = 0;
26211
+ for (const n of targets) {
26212
+ let content;
26213
+ try {
26214
+ content = await trilium.getNoteContent(n.noteId);
26215
+ } catch {
26216
+ continue;
26217
+ }
26218
+ let working = content;
26219
+ let count = 0;
26220
+ const previews = [];
26221
+ for (const e of plan) {
26222
+ const applied = applyFindEdit(working, e.find, e.body, e.nth);
26223
+ if (!applied)
26224
+ continue;
26225
+ if (previews.length < 3) {
26226
+ const plain = toText(working, Number.MAX_SAFE_INTEGER);
26227
+ const at = plain.indexOf(toText(e.find, Number.MAX_SAFE_INTEGER).slice(0, 40));
26228
+ previews.push(at >= 0 ? `\u2026${plain.slice(Math.max(0, at - 50), at + 90)}\u2026` : toText(e.find, 120));
26229
+ }
26230
+ working = applied.html;
26231
+ count += applied.count;
26232
+ }
26233
+ if (!count || working === content)
26234
+ continue;
26235
+ total += count;
26236
+ const row = { id: n.noteId, title: n.title, replaced: count, previews };
26237
+ if (!dry) {
26238
+ await trilium.createRevision(n.noteId).catch(() => null);
26239
+ const clean = sanitizeHtml(working);
26240
+ await trilium.updateNoteContent(n.noteId, bumpLastUpdated(clean.html, d).html);
26241
+ await trilium.updateLabelValue(n.noteId, "updated", d);
26242
+ row.written = true;
26243
+ }
26244
+ changes.push(row);
26245
+ }
26246
+ return txt3({
26247
+ ok: true,
26248
+ domain: name,
26249
+ slug,
26250
+ dryRun: dry,
26251
+ scanned: targets.length,
26252
+ notesMatched: changes.length,
26253
+ replaced: total,
26254
+ date: d,
26255
+ ...targets.length >= CAP ? { truncated: `A query hit its ${CAP}-note cap; some notes in the domain were not scanned.` } : {},
26256
+ changes,
26257
+ 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."
26258
+ });
26259
+ };
25533
26260
  server.tool("revise", `Edit a note by id. A revision is taken first; "Last updated" lines are bumped on content writes.
25534
26261
 
25535
- 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.
26262
+ 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. 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. 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). closure={thread, body} with within="tr" and mode="remove" closes a register row: the row is removed and the closure appended to that thread's entry for today, in one call. "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.
25536
26263
 
25537
26264
  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"),
26265
+ noteId: stringType().optional().describe("Note to update (omit only with domain=)"),
26266
+ 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"),
26267
+ dryRun: booleanType().optional().describe("domain=: preview the matches without writing (default true). Re-run with dryRun=false to apply"),
25539
26268
  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
26269
  title: stringType().optional().describe("New title (normalized server-side)"),
25541
26270
  section: stringType().optional().describe("Target a section by heading text (h2/h3/h4, in that order); omit for whole-note append/replace"),
@@ -25545,6 +26274,10 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25545
26274
  find: stringType().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."),
25546
26275
  nth: numberType().int().positive().optional().describe("find=: replace only the Nth occurrence, 1-based (default: all of them). With within=: the Nth containing element"),
25547
26276
  within: enumType(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'),
26277
+ closure: objectType({
26278
+ thread: stringType().describe('The dated thread to record the closure in: its id or title (e.g. "Escalations")'),
26279
+ body: stringType().describe("The closure: what settled it, who ruled, and where its durable substance went")
26280
+ }).optional().describe(`With within= and mode="remove": close a register row in one call. The row is removed and body= is appended to the thread's entry for today, quoting the removed row. Needs identity=. Run consistency() afterwards on any fact the row asserted elsewhere`),
25548
26281
  edits: arrayType(objectType({
25549
26282
  find: stringType().describe("Exact raw string to replace"),
25550
26283
  body: stringType().describe("Raw replacement string"),
@@ -25553,7 +26286,23 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25553
26286
  identity: stringType().optional().describe('Appending to a dated thread: the identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" (required)'),
25554
26287
  icon: stringType().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name; normalized server-side'),
25555
26288
  date: stringType().optional().describe("ISO date (default: today)")
25556
- }, async ({ noteId, body, title, section, occurrence, mode, find, nth, within, edits, identity, icon, date, strict }) => {
26289
+ }, async ({ noteId: noteIdArg, domain, dryRun, body, title, section, occurrence, mode, find, nth, within, closure, edits, identity, icon, date, strict }) => {
26290
+ if (closure && !(within && mode === "remove"))
26291
+ return err("conflicting_params", 'closure= closes a register row and needs within= with mode="remove"; nothing was written.', 'e.g. revise(noteId, find="<row anchor>", within="tr", mode="remove", closure={thread: "Escalations", body: "Closed on \u2026"}, identity="\u2026")');
26292
+ if (domain !== undefined) {
26293
+ if (noteIdArg)
26294
+ return err("conflicting_params", "Pass noteId= or domain=, not both.", "domain= edits every note in the domain; noteId= edits one.");
26295
+ if (find === undefined && edits === undefined)
26296
+ return err("missing_param", "domain= needs find= (with body=) or edits=.", 'e.g. revise(domain="myClerkBook", find="Code Priority Queue", body="Backlog")');
26297
+ if (within || section || title || mode || identity || icon)
26298
+ return err("conflicting_params", "domain= takes only find=/body=, edits=, nth= and dryRun=.", "Use noteId= for element, section, title or icon edits.");
26299
+ return reviseDomain(domain, edits ?? [{ find, body: body ?? "", nth }], dryRun ?? true, checkedDate(date));
26300
+ }
26301
+ if (dryRun !== undefined)
26302
+ 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.");
26303
+ if (!noteIdArg)
26304
+ return err("missing_param", "revise needs noteId= (or domain= for a domain-wide find/replace).", "Pass the id of the note to edit.");
26305
+ const noteId = noteIdArg;
25557
26306
  if (isContainer(b(), noteId))
25558
26307
  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
26308
  if (within && find === undefined)
@@ -25600,46 +26349,19 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25600
26349
  if (e.body === undefined && !within)
25601
26350
  return err("missing_param", `edits[${i}] requires body as the replacement string.`, 'Call revise(noteId, find="<exact text>", body="<replacement>").');
25602
26351
  }
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
26352
  const missHint = (needle, source, alreadyConsumed) => {
25636
26353
  if (alreadyConsumed)
25637
26354
  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.`;
26355
+ if (/Last updated/i.test(needle))
26356
+ return `Not found \u2014 "Last updated" lines are server-owned and every content write bumps them to today, so this one most likely already moved. Leave it out of find=; the server keeps it current.`;
25638
26357
  if (looksEntityEscaped(needle))
25639
26358
  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
26359
  if (spansBlockBoundary(needle))
25641
26360
  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.`;
26361
+ const closed = closedBlockTag(needle);
26362
+ if (closed && !within)
26363
+ return `Not found \u2014 the string ends inside a closing </${closed}>, and where the editor stored that tag decides whether it matches. Use within="${closed}" with a short plain-text anchor from inside the element instead: revise(noteId, find="<a few words>", within="${closed}", body="<${closed}>\u2026</${closed}>").`;
26364
+ return `Not found in the note body (exact, attribute-tolerant, ignoring inline tags${within ? `, or by the visible text of a <${within}>` : ""}) \u2014 already replaced on a retry, or the text genuinely differs.${within ? "" : ' To act on a whole table row by its visible text, use within="tr".'}`;
25643
26365
  };
25644
26366
  const missContext = (needle, source) => {
25645
26367
  const near = nearestContext(source, needle);
@@ -25658,7 +26380,10 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25658
26380
  for (let at = current.indexOf(find);at !== -1; at = current.indexOf(find, at + find.length))
25659
26381
  exact.push({ start: at, length: find.length });
25660
26382
  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 })) : [];
26383
+ const tolerantHits = rx ? [...current.matchAll(rx)].map((m) => ({ start: m.index, length: m[0].length })) : [];
26384
+ const inlineHits = exact.length || tolerantHits.length ? [] : inlineTolerantSpans(current, find);
26385
+ const visibleHits = exact.length || tolerantHits.length || inlineHits.length ? [] : visibleTextSpans(current, find, within);
26386
+ const hits = exact.length ? exact : tolerantHits.length ? tolerantHits : inlineHits.length ? inlineHits : visibleHits;
25662
26387
  const res = editWithin(current, hits, within, action, body ?? "", nth);
25663
26388
  if (!res.ok) {
25664
26389
  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.`;
@@ -25676,6 +26401,26 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25676
26401
  }
25677
26402
  if (res.html === current)
25678
26403
  return txt3({ ok: true, noteId, mode: `within:${within}:${action}`, unchanged: true, date: d, note: "The body equals the element it would replace, so nothing was written and no revision was taken." });
26404
+ let closureThread = null;
26405
+ let closureBlock = "";
26406
+ if (closure) {
26407
+ closureThread = /^[A-Za-z0-9]{12}$/.test(closure.thread) ? await trilium.getNote(closure.thread).catch(() => null) : await findExisting("thread", closure.thread);
26408
+ if (!closureThread || ownedLabel(closureThread, "noteType") !== "thread")
26409
+ return err("not_found", `closure.thread "${closure.thread}" is not a thread; nothing was written.`, `Pass the thread's id or exact title, e.g. "Escalations".`);
26410
+ if (isCollectionThread(closureThread))
26411
+ return collectionAppendRefusal(closureThread);
26412
+ const rendered = renderBody(closure.body).html;
26413
+ const removedText = toText(res.element, 400);
26414
+ const closureHtml = `<p><strong>Closed:</strong> ${escapeHtml(removedText)}</p>
26415
+ ${rendered}`;
26416
+ if (!identity)
26417
+ return err("missing_identity", "closure= appends to a dated thread, which needs identity=; nothing was written.", 'Pass identity="LLM \xB7 environment \xB7 agent/mode".');
26418
+ const badIdentity = identityRefusal(identity, closureHtml);
26419
+ if (badIdentity)
26420
+ return badIdentity;
26421
+ closureBlock = `<h3>${escapeHtml(identity)}</h3>
26422
+ ${closureHtml}`;
26423
+ }
25679
26424
  await trilium.createRevision(noteId).catch(() => null);
25680
26425
  const withinResult = sanitizeHtml(res.html);
25681
26426
  const stampedWithin = bumpLastUpdated(withinResult.html, d);
@@ -25686,16 +26431,32 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25686
26431
  if (labelOf2(note, "status") === "dormant")
25687
26432
  await trilium.updateLabelValue(noteId, "status", "active");
25688
26433
  const relsWithin = relationSnippet(note);
26434
+ let closureReceipt = null;
26435
+ if (closureThread) {
26436
+ const entry = await appendThreadEntry(closureThread.noteId, closureBlock, d);
26437
+ await trilium.updateLabelValue(closureThread.noteId, "updated", d);
26438
+ if (labelOf2(closureThread, "status") === "dormant")
26439
+ await trilium.updateLabelValue(closureThread.noteId, "status", "active");
26440
+ closureReceipt = {
26441
+ threadId: closureThread.noteId,
26442
+ thread: closureThread.title,
26443
+ entryId: entry.noteId,
26444
+ action: entry.action,
26445
+ next: "Run consistency() on any fact the closed row asserted elsewhere, so no stale sentence survives it."
26446
+ };
26447
+ }
25689
26448
  return txt3({
25690
26449
  ok: true,
25691
26450
  noteId,
25692
26451
  mode: `within:${within}:${action}`,
25693
26452
  date: d,
25694
26453
  element: toText(res.element, 200),
26454
+ ...closureReceipt ? { closure: closureReceipt } : {},
25695
26455
  ...titledWithin.retitled ? { retitled: titledWithin.retitled } : {},
25696
26456
  ...iconWithin ? { icon: iconWithin } : {},
25697
26457
  ...relsWithin ? { relations: relsWithin } : {},
25698
26458
  ...structuralFindings(noteKind, stampedWithin.html),
26459
+ ...stampedWithin.bumped ? { lastUpdated: "bumped to today \u2014 the line is server-owned" } : {},
25699
26460
  ...withinResult.warnings.length ? { sanitized: withinResult.warnings } : {}
25700
26461
  });
25701
26462
  }
@@ -25704,7 +26465,7 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25704
26465
  let total = 0;
25705
26466
  const consumed = new Set;
25706
26467
  for (const e of plan) {
25707
- const applied = applyEdit(working, e.find, e.body, e.nth);
26468
+ const applied = applyFindEdit(working, e.find, e.body, e.nth);
25708
26469
  if (!applied) {
25709
26470
  const alreadyConsumed = consumed.has(e.find);
25710
26471
  results.push({
@@ -25775,6 +26536,7 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25775
26536
  ...iconApplied ? { icon: iconApplied } : {},
25776
26537
  ...rels ? { relations: rels } : {},
25777
26538
  ...structure,
26539
+ ...stamped.bumped ? { lastUpdated: "bumped to today \u2014 the line is server-owned" } : {},
25778
26540
  ...replacedResult.warnings.length ? { sanitized: replacedResult.warnings } : {}
25779
26541
  });
25780
26542
  }
@@ -25867,6 +26629,9 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25867
26629
  if (!identity && !leadingIdentification(html)) {
25868
26630
  return err("missing_identity", 'Thread addendums open with the canonical identification line (h3): "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]".', `Pass identity="Claude \u2026 \xB7 <environment> \xB7 <agent/mode>" \u2014 the server renders it as the addendum's h3.`);
25869
26631
  }
26632
+ const badIdentity = identityRefusal(identity, html);
26633
+ if (badIdentity)
26634
+ return badIdentity;
25870
26635
  const block = identity && !leadingIdentification(html) ? `<h3>${escapeHtml(identity)}</h3>
25871
26636
  ${html}` : html;
25872
26637
  threadEntryResult = await appendThreadEntry(noteId, block, d);
@@ -25905,7 +26670,7 @@ ${block}`), d).html;
25905
26670
  } : {},
25906
26671
  ...sectionMiss,
25907
26672
  ...sectionHint ? { hint: sectionHint } : {},
25908
- ...threadEntryResult ? { entryId: threadEntryResult.noteId, entryAction: threadEntryResult.action } : {},
26673
+ ...threadEntryResult ? { entryId: threadEntryResult.noteId, entryAction: threadEntryResult.action, entry: `today's [${d}] entry in this thread; revise(entryId) edits it` } : {},
25909
26674
  ...titled.retitled ? { retitled: titled.retitled } : {},
25910
26675
  ...titled.cascaded ? { domainCascade: `#domain updated on ${titled.cascaded} descendant note(s)` } : {},
25911
26676
  ...structuralFindings(noteKind, finalContent),
@@ -26045,7 +26810,7 @@ ${result.html}`);
26045
26810
  ...warnings.length ? { sanitized: warnings } : {}
26046
26811
  });
26047
26812
  });
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.`, {
26813
+ 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
26814
  noteId: stringType().describe("Note to edit"),
26050
26815
  name: stringType().describe("Label name, no # prefix (e.g. status, domain, topic, created)"),
26051
26816
  value: stringType().optional().describe("New value \u2014 required unless remove=true"),
@@ -26177,8 +26942,10 @@ calling twice is safe. Use remove=true to delete an edge.`, {
26177
26942
  }
26178
26943
  }
26179
26944
  });
26180
- server.tool("consistency", `Does the brain agree with itself? pattern= is a regex with ONE capture group naming the value that should match across notes, e.g. "(\\\\d+) mailboxes"; the result groups every asserting note by value with agreement unanimous or DISAGREEMENT. subject="<fact in prose>" finds notes asserting about a subject however phrased. staleAfterDays=N also reports values held in exactly one note untouched N+ days. Matches stored HTML and tag-stripped text; escape backslashes; scope with domain=/kinds=. Records (sessions, diary, logs, dated thread entries) are skipped by default because they are never rewritten; includeRecords=true or a kinds= naming them brings them back. Scans every in-scope note (fast=true uses Trilium's lossy pre-filter). Run it after correcting any fact recorded in more than one place.`, {
26181
- pattern: stringType().optional().describe("Regex over note bodies. One capture group = the value that should agree across notes. Omit when using subject= (prose mode)."),
26945
+ server.tool("consistency", `Does the brain agree with itself? pattern= is a regex with ONE capture group naming the value that should match across notes, e.g. "(\\\\d+) mailboxes"; the result groups every asserting note by value with agreement unanimous or DISAGREEMENT. subject="<fact in prose>" finds notes asserting about a subject however phrased. staleAfterDays=N also reports values held in exactly one note untouched N+ days. Matches stored HTML and tag-stripped text; escape backslashes; scope with domain=/kinds=. Records (sessions, diary, logs, dated thread entries) are skipped by default because they are never rewritten; includeRecords=true or a kinds= naming them brings them back. Scans every in-scope note (fast=true uses Trilium's lossy pre-filter). Matching ignores case unless caseSensitive=true (a leading (?i) is accepted). Without a capture group the whole match is the value compared. patterns=[\u2026] checks several facts in one call over one read of each note. Run it after correcting any fact recorded in more than one place.`, {
26946
+ pattern: stringType().optional().describe("Regex over note bodies. One capture group = the value that should agree across notes; with none, the whole match is the value. Omit when using subject= (prose mode)."),
26947
+ patterns: arrayType(stringType()).max(10).optional().describe("Several patterns checked in one call (each reported as its own result, in order); every note is read once"),
26948
+ caseSensitive: booleanType().optional().describe('Match case exactly (default false: "Blueprints" and "blueprints" both match)'),
26182
26949
  subject: stringType().optional().describe("A fact in prose \u2014 returns notes asserting about it however phrased (instead of pattern=)"),
26183
26950
  staleAfterDays: numberType().optional().describe("With pattern: also report values held in exactly one note untouched N+ days (staleSingles)"),
26184
26951
  domain: stringType().optional().describe("Restrict to one knowledge domain"),
@@ -26187,146 +26954,175 @@ calling twice is safe. Use remove=true to delete an edge.`, {
26187
26954
  includeArchived: booleanType().optional().describe("Include archived notes (default false)"),
26188
26955
  limit: numberType().optional().describe("Max notes to examine (default 60)"),
26189
26956
  fast: booleanType().optional().describe("Use Trilium's faster but lossy %= pre-filter (default: scan every in-scope note)")
26190
- }, async ({ pattern, subject, staleAfterDays, domain, kinds, includeRecords, includeArchived, limit, fast }) => {
26191
- const wantsRecords = includeRecords === true || (kinds ?? []).some((k) => RECORD_KINDS.has(k));
26192
- let recordsSkipped = 0;
26193
- const inScope = (n) => {
26194
- const kind = ownedLabel(n, "noteType");
26195
- if (!kind)
26196
- return false;
26197
- if (kinds?.length && !kinds.includes(kind))
26198
- return false;
26199
- if (!wantsRecords && isRecordNote(n)) {
26200
- recordsSkipped++;
26201
- return false;
26202
- }
26203
- return true;
26957
+ }, async (args) => {
26958
+ const contentCache = new Map;
26959
+ const contentOf = (id) => {
26960
+ let p = contentCache.get(id);
26961
+ if (!p) {
26962
+ p = trilium.getNoteContent(id).catch(() => "");
26963
+ contentCache.set(id, p);
26964
+ }
26965
+ return p;
26204
26966
  };
26205
- const recordsNote = () => recordsSkipped ? { recordsSkipped, recordsNote: `${recordsSkipped} record(s) (sessions, diary, logs, dated thread entries) were skipped: they are never rewritten. includeRecords=true matches them too.` } : {};
26206
- if (!pattern && !subject)
26207
- return err("missing_param", "consistency() needs either a regex pattern or a prose subject.", 'Pass pattern="(\\\\d+) users" to compare a captured value, or subject="<a fact in prose>" to find every note asserting about that subject however phrased.');
26208
- if (subject && !pattern) {
26967
+ if (args.patterns?.length) {
26968
+ if (args.pattern || args.subject)
26969
+ return err("conflicting_params", "Pass patterns=[\u2026] alone, not with pattern= or subject=.", "Put every pattern in the patterns array.");
26970
+ const results = [];
26971
+ for (const p of args.patterns) {
26972
+ const r = await checkOne({ ...args, pattern: p, patterns: undefined });
26973
+ results.push(JSON.parse(r.content[0].text));
26974
+ }
26975
+ const disagreeing = results.filter((r) => r.agreement === "DISAGREEMENT").length;
26976
+ return txt3({ mode: "multi", patterns: args.patterns.length, disagreeing, results });
26977
+ }
26978
+ return checkOne(args);
26979
+ async function checkOne({ pattern, subject, staleAfterDays, domain, kinds, includeRecords, includeArchived, limit, fast, caseSensitive }) {
26980
+ const wantsRecords = includeRecords === true || (kinds ?? []).some((k) => RECORD_KINDS.has(k));
26981
+ let recordsSkipped = 0;
26982
+ const inScope = (n) => {
26983
+ const kind = ownedLabel(n, "noteType");
26984
+ if (!kind)
26985
+ return false;
26986
+ if (kinds?.length && !kinds.includes(kind))
26987
+ return false;
26988
+ if (!wantsRecords && isRecordNote(n)) {
26989
+ recordsSkipped++;
26990
+ return false;
26991
+ }
26992
+ return true;
26993
+ };
26994
+ const recordsNote = () => recordsSkipped ? { recordsSkipped, recordsNote: `${recordsSkipped} record(s) (sessions, diary, logs, dated thread entries) were skipped: they are never rewritten. includeRecords=true matches them too.` } : {};
26995
+ if (!pattern && !subject)
26996
+ return err("missing_param", "consistency() needs either a regex pattern or a prose subject.", 'Pass pattern="(\\\\d+) users" to compare a captured value, or subject="<a fact in prose>" to find every note asserting about that subject however phrased.');
26997
+ if (subject && !pattern) {
26998
+ const max = limit ?? 60;
26999
+ const tokens = queryTokens(subject, 8);
27000
+ if (!tokens.length)
27001
+ return err("no_tokens", "The subject produced no significant content words.", 'A subject of all stop-words ("what was it") cannot find anything \u2014 name the thing you re-measured.');
27002
+ const clauses = ["#noteType"];
27003
+ if (domain)
27004
+ clauses.push(`#domain='${slugify(domain)}'`);
27005
+ const notes = await trilium.searchNotes(clauses.join(" AND "), { ancestorNoteId: b().root, limit: wantsRecords ? max : max * 4, includeArchivedNotes: includeArchived ?? false }).then((r) => r.results).catch(() => []);
27006
+ const scoped = notes.filter(inScope).slice(0, max);
27007
+ const threshold = Math.max(1, Math.ceil(tokens.length / 2));
27008
+ const hits = [];
27009
+ for (const n of scoped) {
27010
+ const content = await contentOf(n.noteId);
27011
+ if (!content)
27012
+ continue;
27013
+ const text = stripTagsWithMap(content).text.toLowerCase();
27014
+ const matchedTokens = tokens.filter((t) => text.includes(t));
27015
+ if (matchedTokens.length < threshold)
27016
+ continue;
27017
+ hits.push({ id: n.noteId, title: n.title, kind: ownedLabel(n, "noteType") ?? "", matchedTokens, snippet: toText(content, 200) });
27018
+ }
27019
+ return txt3({
27020
+ mode: "subject",
27021
+ subject,
27022
+ tokens,
27023
+ threshold: `${threshold} of ${tokens.length} token(s)`,
27024
+ notesExamined: scoped.length,
27025
+ notes: hits,
27026
+ total: hits.length,
27027
+ ...domain ? { domain: slugify(domain) } : {},
27028
+ ...recordsNote(),
27029
+ note: hits.length ? `${hits.length} note(s) assert something about "${subject}". Open each to read the claim \u2014 or narrow with domain= / kinds=.` : `No note in scope matched ${threshold}+ of the subject's tokens. That is evidence about the phrasing, not the brain \u2014 this mode is deliberately phrase-agnostic, so if you expected a hit, the fact may not be recorded here at all.`
27030
+ });
27031
+ }
27032
+ if (!pattern)
27033
+ return err("missing_param", "No regex pattern given for consistency.", 'Pass pattern="(\\\\d+) users" \u2014 or subject="<a fact in prose>" for the prose-subject mode.');
27034
+ let re;
27035
+ const inlineInsensitive = /^\(\?i\)/.test(pattern);
27036
+ const source = inlineInsensitive ? pattern.slice(4) : pattern;
27037
+ const flags = caseSensitive && !inlineInsensitive ? "g" : "gi";
27038
+ try {
27039
+ re = new RegExp(source, flags);
27040
+ } catch (e) {
27041
+ return err("invalid_pattern", `Not a valid regular expression: ${e.message}`, "Escape backslashes \u2014 a JSON string needs \\\\d for \\d.");
27042
+ }
27043
+ const groupCount = new RegExp(`${source}|`).exec("").length - 1;
26209
27044
  const max = limit ?? 60;
26210
- const tokens = queryTokens(subject, 8);
26211
- if (!tokens.length)
26212
- return err("no_tokens", "The subject produced no significant content words.", 'A subject of all stop-words ("what was it") cannot find anything \u2014 name the thing you re-measured.');
26213
- const clauses = ["#noteType"];
27045
+ const clauses = fast ? [`note.content %= '${escapeQueryRegex(pattern)}'`] : ["#noteType"];
26214
27046
  if (domain)
26215
27047
  clauses.push(`#domain='${slugify(domain)}'`);
26216
27048
  const notes = await trilium.searchNotes(clauses.join(" AND "), { ancestorNoteId: b().root, limit: wantsRecords ? max : max * 4, includeArchivedNotes: includeArchived ?? false }).then((r) => r.results).catch(() => []);
26217
27049
  const scoped = notes.filter(inScope).slice(0, max);
26218
- const threshold = Math.max(1, Math.ceil(tokens.length / 2));
26219
- const hits = [];
27050
+ const byValue = new Map;
27051
+ const noCapture = [];
27052
+ let hasCaptureGroup = false;
27053
+ const scan = (haystack) => {
27054
+ const values = [];
27055
+ let matched = false;
27056
+ re.lastIndex = 0;
27057
+ let m;
27058
+ while ((m = re.exec(haystack)) !== null) {
27059
+ matched = true;
27060
+ const captured = groupCount === 0 ? m[0] : m.slice(1).find((g) => g !== undefined);
27061
+ if (captured !== undefined && captured !== "")
27062
+ values.push(toText(captured, 120).trim() || captured.trim());
27063
+ if (m[0] === "")
27064
+ re.lastIndex++;
27065
+ }
27066
+ return { values, matched };
27067
+ };
27068
+ let tagSpanning = 0;
27069
+ const idleSince = (iso) => Math.max(0, Math.floor((Date.now() - new Date(iso.replace(" ", "T")).getTime()) / 86400000));
26220
27070
  for (const n of scoped) {
26221
- const content = await trilium.getNoteContent(n.noteId).catch(() => "");
27071
+ const content = await contentOf(n.noteId);
26222
27072
  if (!content)
26223
27073
  continue;
26224
- const text = stripTagsWithMap(content).text.toLowerCase();
26225
- const matchedTokens = tokens.filter((t) => text.includes(t));
26226
- if (matchedTokens.length < threshold)
26227
- continue;
26228
- hits.push({ id: n.noteId, title: n.title, kind: ownedLabel(n, "noteType") ?? "", matchedTokens, snippet: toText(content, 200) });
27074
+ const stub = { id: n.noteId, title: n.title, kind: ownedLabel(n, "noteType") ?? "", idleDays: idleSince(n.dateModified) };
27075
+ const raw = scan(content);
27076
+ const projected = scan(stripTagsWithMap(content).text);
27077
+ if (projected.matched && !raw.matched)
27078
+ tagSpanning++;
27079
+ const seen = new Set;
27080
+ for (const value of [...raw.values, ...projected.values]) {
27081
+ hasCaptureGroup = true;
27082
+ if (seen.has(value))
27083
+ continue;
27084
+ seen.add(value);
27085
+ if (!byValue.has(value))
27086
+ byValue.set(value, []);
27087
+ byValue.get(value).push(stub);
27088
+ }
27089
+ if ((raw.matched || projected.matched) && !seen.size)
27090
+ noCapture.push(stub);
26229
27091
  }
26230
- return txt3({
26231
- mode: "subject",
26232
- subject,
26233
- tokens,
26234
- threshold: `${threshold} of ${tokens.length} token(s)`,
26235
- notesExamined: scoped.length,
26236
- notes: hits,
26237
- total: hits.length,
26238
- ...domain ? { domain: slugify(domain) } : {},
26239
- ...recordsNote(),
26240
- note: hits.length ? `${hits.length} note(s) assert something about "${subject}". Open each to read the claim \u2014 or narrow with domain= / kinds=.` : `No note in scope matched ${threshold}+ of the subject's tokens. That is evidence about the phrasing, not the brain \u2014 this mode is deliberately phrase-agnostic, so if you expected a hit, the fact may not be recorded here at all.`
26241
- });
26242
- }
26243
- if (!pattern)
26244
- return err("missing_param", "No regex pattern given for consistency.", 'Pass pattern="(\\\\d+) users" \u2014 or subject="<a fact in prose>" for the prose-subject mode.');
26245
- let re;
26246
- try {
26247
- re = new RegExp(pattern, "gi");
26248
- } catch (e) {
26249
- return err("invalid_pattern", `Not a valid regular expression: ${e.message}`, "Escape backslashes \u2014 a JSON string needs \\\\d for \\d.");
26250
- }
26251
- const max = limit ?? 60;
26252
- const clauses = fast ? [`note.content %= '${escapeQueryRegex(pattern)}'`] : ["#noteType"];
26253
- if (domain)
26254
- clauses.push(`#domain='${slugify(domain)}'`);
26255
- const notes = await trilium.searchNotes(clauses.join(" AND "), { ancestorNoteId: b().root, limit: wantsRecords ? max : max * 4, includeArchivedNotes: includeArchived ?? false }).then((r) => r.results).catch(() => []);
26256
- const scoped = notes.filter(inScope).slice(0, max);
26257
- const byValue = new Map;
26258
- const noCapture = [];
26259
- let hasCaptureGroup = false;
26260
- const scan = (haystack) => {
26261
- const values = [];
26262
- let matched = false;
26263
- re.lastIndex = 0;
26264
- let m;
26265
- while ((m = re.exec(haystack)) !== null) {
26266
- matched = true;
26267
- const captured = m.slice(1).find((g) => g !== undefined);
26268
- if (captured !== undefined)
26269
- values.push(toText(captured, 120).trim() || captured.trim());
26270
- if (m[0] === "")
26271
- re.lastIndex++;
26272
- }
26273
- return { values, matched };
26274
- };
26275
- let tagSpanning = 0;
26276
- const idleSince = (iso) => Math.max(0, Math.floor((Date.now() - new Date(iso.replace(" ", "T")).getTime()) / 86400000));
26277
- for (const n of scoped) {
26278
- const content = await trilium.getNoteContent(n.noteId).catch(() => "");
26279
- if (!content)
26280
- continue;
26281
- const stub = { id: n.noteId, title: n.title, kind: ownedLabel(n, "noteType") ?? "", idleDays: idleSince(n.dateModified) };
26282
- const raw = scan(content);
26283
- const projected = scan(stripTagsWithMap(content).text);
26284
- if (projected.matched && !raw.matched)
26285
- tagSpanning++;
26286
- const seen = new Set;
26287
- for (const value of [...raw.values, ...projected.values]) {
26288
- hasCaptureGroup = true;
26289
- if (seen.has(value))
26290
- continue;
26291
- seen.add(value);
26292
- if (!byValue.has(value))
26293
- byValue.set(value, []);
26294
- byValue.get(value).push(stub);
27092
+ if (!hasCaptureGroup) {
27093
+ return txt3({
27094
+ mode: "presence",
27095
+ pattern,
27096
+ scan: fast ? "fast (%= pre-filter)" : "exhaustive",
27097
+ notesExamined: scoped.length,
27098
+ ...recordsNote(),
27099
+ notes: noCapture,
27100
+ total: noCapture.length,
27101
+ note: noCapture.length === 0 ? `No note body matched that pattern across ${scoped.length} note(s), searched both as stored HTML and tag-stripped.${fast ? " fast=true used Trilium's %= pre-filter, which reads a striptags'd copy and can drop notes \u2014 re-run without it before concluding anything." : " That is evidence about the pattern, not the brain."}` : 'The pattern has no capture group, so this is a presence check only. Add one \u2014 consistency("(\\\\d+) users") \u2014 to compare the values these notes actually assert.'
27102
+ });
26295
27103
  }
26296
- if ((raw.matched || projected.matched) && !seen.size)
26297
- noCapture.push(stub);
26298
- }
26299
- if (!hasCaptureGroup) {
27104
+ const groups = [...byValue.entries()].map(([value, notes]) => ({ value, count: notes.length, notes })).sort((a, b) => b.count - a.count);
27105
+ const staleSingles = staleAfterDays ? groups.filter((g) => g.count === 1 && g.notes[0].idleDays >= staleAfterDays).map((g) => ({ value: g.value, note: { id: g.notes[0].id, title: g.notes[0].title, kind: g.notes[0].kind }, idleDays: g.notes[0].idleDays })) : [];
27106
+ const agrees = groups.length <= 1;
26300
27107
  return txt3({
26301
- mode: "presence",
27108
+ mode: "consistency",
26302
27109
  pattern,
27110
+ ...groupCount === 0 ? { valueFrom: "the whole match (the pattern has no capture group)" } : {},
27111
+ ...caseSensitive && !inlineInsensitive ? { caseSensitive: true } : {},
26303
27112
  scan: fast ? "fast (%= pre-filter)" : "exhaustive",
27113
+ ...domain ? { domain: slugify(domain) } : {},
26304
27114
  notesExamined: scoped.length,
26305
27115
  ...recordsNote(),
26306
- notes: noCapture,
26307
- total: noCapture.length,
26308
- note: noCapture.length === 0 ? `No note body matched that pattern across ${scoped.length} note(s), searched both as stored HTML and tag-stripped.${fast ? " fast=true used Trilium's %= pre-filter, which reads a striptags'd copy and can drop notes \u2014 re-run without it before concluding anything." : " That is evidence about the pattern, not the brain."}` : 'The pattern has no capture group, so this is a presence check only. Add one \u2014 consistency("(\\\\d+) users") \u2014 to compare the values these notes actually assert.'
27116
+ ...tagSpanning ? { tagSpanning, tagSpanningNote: `${tagSpanning} note(s) matched only once markup was stripped \u2014 the phrase is split by an inline tag there. Before v10.3 those were invisible.` } : {},
27117
+ distinctValues: groups.length,
27118
+ agreement: groups.length === 0 ? "no-data" : agrees ? "unanimous" : "DISAGREEMENT",
27119
+ groups,
27120
+ ...staleAfterDays ? { staleAfterDays } : {},
27121
+ ...staleSingles.length ? { staleSingles, staleSinglesNote: `${staleSingles.length} figure(s) appear in exactly one note, untouched ${staleAfterDays}d+ \u2014 a single copy cannot disagree with itself, so re-measure these against the world rather than against other notes.` } : {},
27122
+ ...noCapture.length ? { matchedWithoutValue: noCapture } : {},
27123
+ note: agrees ? groups.length === 0 ? "No note asserted a value for that pattern. That is evidence about the pattern, not about the brain \u2014 check it against a note you know contains the fact." : `All ${groups[0].count} note(s) agree on "${groups[0].value}".` : `${groups.length} DIFFERENT values are asserted across ${scoped.length} notes. Establish which is true from evidence, correct every note that disagrees, and wire ~corrects from the note that overturns the old claim \u2014 revising in place leaves no trace the wrong value was ever believed.`
26309
27124
  });
26310
27125
  }
26311
- const groups = [...byValue.entries()].map(([value, notes]) => ({ value, count: notes.length, notes })).sort((a, b) => b.count - a.count);
26312
- const staleSingles = staleAfterDays ? groups.filter((g) => g.count === 1 && g.notes[0].idleDays >= staleAfterDays).map((g) => ({ value: g.value, note: { id: g.notes[0].id, title: g.notes[0].title, kind: g.notes[0].kind }, idleDays: g.notes[0].idleDays })) : [];
26313
- const agrees = groups.length <= 1;
26314
- return txt3({
26315
- mode: "consistency",
26316
- pattern,
26317
- scan: fast ? "fast (%= pre-filter)" : "exhaustive",
26318
- ...domain ? { domain: slugify(domain) } : {},
26319
- notesExamined: scoped.length,
26320
- ...recordsNote(),
26321
- ...tagSpanning ? { tagSpanning, tagSpanningNote: `${tagSpanning} note(s) matched only once markup was stripped \u2014 the phrase is split by an inline tag there. Before v10.3 those were invisible.` } : {},
26322
- distinctValues: groups.length,
26323
- agreement: groups.length === 0 ? "no-data" : agrees ? "unanimous" : "DISAGREEMENT",
26324
- groups,
26325
- ...staleAfterDays ? { staleAfterDays } : {},
26326
- ...staleSingles.length ? { staleSingles, staleSinglesNote: `${staleSingles.length} figure(s) appear in exactly one note, untouched ${staleAfterDays}d+ \u2014 a single copy cannot disagree with itself, so re-measure these against the world rather than against other notes.` } : {},
26327
- ...noCapture.length ? { matchedWithoutValue: noCapture } : {},
26328
- note: agrees ? groups.length === 0 ? "No note asserted a value for that pattern. That is evidence about the pattern, not about the brain \u2014 check it against a note you know contains the fact." : `All ${groups[0].count} note(s) agree on "${groups[0].value}".` : `${groups.length} DIFFERENT values are asserted across ${scoped.length} notes. Establish which is true from evidence, correct every note that disagrees, and wire ~corrects from the note that overturns the old claim \u2014 revising in place leaves no trace the wrong value was ever believed.`
26329
- });
26330
27126
  });
26331
27127
  server.tool("outline", `A note's heading tree without its body: each h2\u2013h4 with level and occurrence index (and raw stored text where inline markup differs), table key columns, size, and structural findings. Read it before a section= edit you're not sure of. Notes over 15k also get a first-block preview per section.`, {
26332
27128
  noteId: stringType().describe("Note to outline")
@@ -26375,7 +27171,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
26375
27171
  ...headings.length === 0 ? { note: "No h2\u2013h4 headings \u2014 section= would append a new section rather than match anything." } : {}
26376
27172
  });
26377
27173
  });
26378
- server.tool("inspect", `Everything about one note: every label, relation and attachment, type/mime, parent and child ids, dates. content=true adds the raw body (section= narrows it). find="<literal>" counts occurrences (total, per addendum block, per section) and on a miss shows the nearest stored text. Read-only, safe on any note.`, {
27174
+ server.tool("inspect", `Everything about one note: every label, relation and attachment, type/mime, parent and child ids, dates. content=true adds the raw body (section= narrows it). find="<literal>" counts occurrences (total, per addendum block, per section), returns the stored element around each one (up to 10: its row, list item or paragraph, ready to anchor a revise), and on a miss shows the nearest stored text. Read-only, safe on any note.`, {
26379
27175
  noteId: stringType().describe("Note to inspect"),
26380
27176
  content: booleanType().optional().describe("Include the note's raw body content (default: false)"),
26381
27177
  section: stringType().optional().describe("With content=true, return only this heading's section rather than the whole body"),
@@ -26448,11 +27244,39 @@ calling twice is safe. Use remove=true to delete an edge.`, {
26448
27244
  }
26449
27245
  const total = countIn(rawBody);
26450
27246
  const near = total === 0 ? nearestContext(rawBody, find) : null;
27247
+ const matches = [];
27248
+ const seenSpans = new Set;
27249
+ for (let at = rawBody.indexOf(find);at !== -1 && matches.length < 10; at = rawBody.indexOf(find, at + Math.max(1, find.length))) {
27250
+ let el = null;
27251
+ let tag = "";
27252
+ for (const t of ["tr", "li", "p", "blockquote", "td"]) {
27253
+ el = containingElement(rawBody, t, at, at + find.length);
27254
+ if (el) {
27255
+ tag = t;
27256
+ break;
27257
+ }
27258
+ }
27259
+ const start = el?.start ?? Math.max(0, at - 120);
27260
+ const end = el?.end ?? Math.min(rawBody.length, at + find.length + 120);
27261
+ if (seenSpans.has(start))
27262
+ continue;
27263
+ seenSpans.add(start);
27264
+ let sectionName = "(head)";
27265
+ for (const h of headings) {
27266
+ if (h.index < at)
27267
+ sectionName = h.text;
27268
+ else
27269
+ break;
27270
+ }
27271
+ const stored = rawBody.slice(start, end);
27272
+ matches.push({ section: sectionName, element: tag || "text", stored: stored.length > 600 ? `${stored.slice(0, 600)}\u2026` : stored });
27273
+ }
26451
27274
  findReport = {
26452
27275
  find,
26453
27276
  total,
26454
27277
  blocks,
26455
27278
  ...sections.length ? { sections } : {},
27279
+ ...matches.length ? { matches } : {},
26456
27280
  ...near ? { matchedUpTo: near.fragment, storedNearby: near.context } : {},
26457
27281
  ...total === 0 && !near ? { hint: "Not present, and no fragment of it is either \u2014 the string is unrelated to this note's content." } : {}
26458
27282
  };
@@ -26803,7 +27627,7 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
26803
27627
  ...found.length === 0 ? { note: "No notes with pending addendums." } : { hint: "Fold each addendum into its note: read the block, then revise(noteId, section='<heading>', body='<merged content>', mode='replace') to absorb it into the right section. These notes must be left clean \u2014 no addendum markers. Addendum-style history belongs only in sessions, diary, and logs. Scoped/autonomous agents: fold only what's in your lane \u2014 leaving personal or out-of-scope addendums for the next interactive session is correct, and calling this tool is what satisfies the pre-close gate." }
26804
27628
  });
26805
27629
  });
26806
- server.tool("maintain", `Brain hygiene. Lite (automatic in start/close): ages threads active \u2192 dormant \u2192 archived and checks labels. deep=true adds stale notes, orphans and sinks, structural lint (duplicate headings, unbalanced tags, missing required sections, dated prose in timeless notes, oversized notes), duplicate titles, lapsed or broken claims and hygiene passes. dryRun previews. ack=[ids] silences a note you reviewed until its content changes. domain= narrows deep passes to one lane. repair=[ids] unwinds entity double-escaping in place. coverage names any capped pass: structural lint reads at most 40 notes per run and rotates through the rest day by day, so a whole-brain lint spans several runs.`, {
27630
+ server.tool("maintain", `Brain hygiene. Lite (automatic in start/close): ages threads active \u2192 dormant \u2192 archived and checks labels. deep=true adds stale notes, orphans and sinks, structural lint (duplicate headings, unbalanced tags, missing required sections, dated prose in timeless notes, oversized notes), duplicate titles, lapsed or broken claims, table rows that disagree with their header, record seals (whether any record of the last seven sealed days was rewritten after close() sealed it) and hygiene passes. dryRun previews. ack=[ids] silences a note you reviewed until its content changes. domain= narrows deep passes to one lane. repair=[ids] unwinds entity double-escaping in place. coverage names any capped pass: structural lint reads at most 40 notes per run and rotates through the rest day by day, so a whole-brain lint spans several runs.`, {
26807
27631
  deep: booleanType().optional().describe("Deep pass: stale-review + orphan/sink + structural lint + duplicate titles across Memory/Threads and Knowledge (default: false)"),
26808
27632
  dryRun: booleanType().optional().describe("Report what would change without changing it"),
26809
27633
  domain: stringType().optional().describe("Narrow the deep passes to one domain's notes (slugged server-side) \u2014 for scoped agents working a single lane"),
@@ -26817,6 +27641,15 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
26817
27641
  ...ack?.length ? { ack } : {},
26818
27642
  ...repair?.length ? { repair } : {}
26819
27643
  });
27644
+ if (deep && !domain) {
27645
+ const seals = await verifySeals(trilium, b(), localToday()).catch(() => null);
27646
+ if (seals) {
27647
+ for (const f of seals.findings) {
27648
+ report.flagged.push(`record seal: ${f.date}${f.noteId ? ` [${f.noteId}]` : ""} \u2014 ${f.problem}. Records are append-only; read the revisions to see whether the change was intended. The finding ages out with the seven-day window.`);
27649
+ }
27650
+ report.seals = seals.checked ? `${seals.checked} sealed day(s) verified, ${seals.findings.length} finding(s)` : "no sealed days yet \u2014 the first close of 12.9.0 seals its day";
27651
+ }
27652
+ }
26820
27653
  if (deep && !dryRun && b().sizes && Object.keys(b().sizes).length) {
26821
27654
  saveConfig(b());
26822
27655
  }
@@ -27380,6 +28213,37 @@ and every thread day-child, in time order with its identification line.`, {
27380
28213
  registerMemoryTools(server, trilium, brainRef);
27381
28214
  registerKnowledgeTools(server, trilium, brainRef);
27382
28215
  registerInsightsTools(server, trilium, brainRef);
28216
+ const registry = server._registeredTools ?? {};
28217
+ for (const name of CONTENT_WRITE_TOOLS) {
28218
+ const tool = registry[name];
28219
+ const inner = tool?.handler;
28220
+ if (!tool || typeof inner !== "function")
28221
+ continue;
28222
+ tool.handler = async (...args) => {
28223
+ const result = await inner(...args);
28224
+ if (!wroteSinceClose && wroteContent(result)) {
28225
+ await markStep("write").catch(() => null);
28226
+ wroteSinceClose = true;
28227
+ }
28228
+ return result;
28229
+ };
28230
+ }
28231
+ }
28232
+ var CONTENT_WRITE_TOOLS = ["remember", "revise", "split", "resolve", "withdraw", "recover", "forget", "label", "connect", "attach", "detach", "claim"];
28233
+ function wroteContent(result) {
28234
+ const text = result?.content?.[0]?.text;
28235
+ if (typeof text !== "string")
28236
+ return true;
28237
+ try {
28238
+ const r = JSON.parse(text);
28239
+ if (r.error || r.ok === false || r.unchanged === true || r.action === "already_written" || r.mode === "already_written")
28240
+ return false;
28241
+ if (r.replaced === 0 && !r.closure)
28242
+ return false;
28243
+ return true;
28244
+ } catch {
28245
+ return true;
28246
+ }
27383
28247
  }
27384
28248
 
27385
28249
  // src/annotations.ts
@@ -27401,6 +28265,7 @@ var TOOL_ANNOTATIONS = {
27401
28265
  consistency: READ,
27402
28266
  assembly: READ,
27403
28267
  diff: READ,
28268
+ health: READ,
27404
28269
  master: READ,
27405
28270
  master_recall: READ,
27406
28271
  llm: READ,
@@ -27419,11 +28284,11 @@ var TOOL_ANNOTATIONS = {
27419
28284
  claim: APPEND,
27420
28285
  remember: WRITE,
27421
28286
  diary: APPEND,
27422
- revise: WRITE,
28287
+ revise: DESTRUCTIVE,
27423
28288
  split: WRITE,
27424
28289
  close: APPEND,
27425
- connect: WRITE,
27426
- label: WRITE,
28290
+ connect: DESTRUCTIVE,
28291
+ label: DESTRUCTIVE,
27427
28292
  attach: WRITE,
27428
28293
  backup: APPEND,
27429
28294
  bootstrap: WRITE,
@@ -27449,16 +28314,16 @@ var TOOL_ANNOTATIONS = {
27449
28314
  get_year_note: WRITE,
27450
28315
  get_inbox_note: WRITE,
27451
28316
  create_note: APPEND,
27452
- patch_note: WRITE,
27453
- update_note_content: WRITE,
28317
+ patch_note: DESTRUCTIVE,
28318
+ update_note_content: DESTRUCTIVE,
27454
28319
  clone_note: APPEND,
27455
28320
  move_note: WRITE,
27456
28321
  undelete_note: WRITE,
27457
28322
  add_label: WRITE,
27458
28323
  add_relation: WRITE,
27459
- update_attribute: WRITE,
28324
+ update_attribute: DESTRUCTIVE,
27460
28325
  create_attachment: APPEND,
27461
- update_attachment: WRITE,
28326
+ update_attachment: DESTRUCTIVE,
27462
28327
  create_revision: APPEND,
27463
28328
  create_backup: APPEND,
27464
28329
  delete_note: DESTRUCTIVE,
@@ -27466,6 +28331,86 @@ var TOOL_ANNOTATIONS = {
27466
28331
  delete_branch: DESTRUCTIVE,
27467
28332
  delete_attachment: DESTRUCTIVE
27468
28333
  };
28334
+ var TOOL_TITLES = {
28335
+ start: "Open the brain for this session",
28336
+ session: "Review the session before closing",
28337
+ remarks: "Get diary prompts",
28338
+ close: "Close the session and write its log",
28339
+ backup: "Back up the brain",
28340
+ health: "Check storage and runtime health",
28341
+ diary: "Write the daily diary",
28342
+ remember: "Remember something",
28343
+ recall: "Search the brain",
28344
+ domain: "Read everything about an area",
28345
+ read: "Read several notes",
28346
+ revise: "Edit a note",
28347
+ resolve: "Resolve a thread",
28348
+ split: "Split a note into two",
28349
+ withdraw: "Reopen a thread",
28350
+ label: "Set or remove a label",
28351
+ connect: "Link two notes",
28352
+ explore: "Explore a note's links",
28353
+ consistency: "Check the brain agrees with itself",
28354
+ outline: "Show a note's headings",
28355
+ inspect: "Inspect a note's raw form",
28356
+ claim: "Register or verify a claim",
28357
+ diff: "Show what changed",
28358
+ attach: "Attach a file to a note",
28359
+ detach: "Remove an attachment",
28360
+ addendum: "Find notes with pending addenda",
28361
+ maintain: "Run brain maintenance",
28362
+ forget: "Archive or delete a note",
28363
+ recover: "Restore an archived note",
28364
+ template: "Show a note kind's template",
28365
+ graph: "Render the relation graph",
28366
+ day: "Summarise the day",
28367
+ brain: "List the brain's inventory",
28368
+ assembly: "List what the brain holds",
28369
+ bootstrap: "Create or refresh the brain's structure",
28370
+ master: "Read a note about the user",
28371
+ master_recall: "Skim notes about the user",
28372
+ llm: "Read the model's operating notes",
28373
+ llm_recall: "Skim the model's operating notes",
28374
+ memory: "Read a thread or session",
28375
+ memory_recall: "Skim threads and sessions",
28376
+ knowledge: "Read a knowledge note",
28377
+ knowledge_recall: "Skim knowledge",
28378
+ insights: "Read a day's change log",
28379
+ insights_recall: "Skim insights",
28380
+ get_note: "Get note metadata (raw)",
28381
+ get_note_content: "Get note content (raw)",
28382
+ get_attachments: "List attachments (raw)",
28383
+ get_attachment_content: "Get attachment content (raw)",
28384
+ get_attribute: "Get an attribute (raw)",
28385
+ get_branch: "Get a branch (raw)",
28386
+ get_revisions: "List revisions (raw)",
28387
+ get_revision_content: "Get revision content (raw)",
28388
+ search_notes: "Search notes (raw query)",
28389
+ note_history: "List recent changes (raw)",
28390
+ get_app_info: "Get Trilium version (raw)",
28391
+ get_day_note: "Get or create a day note (raw)",
28392
+ get_week_note: "Get or create a week note (raw)",
28393
+ get_month_note: "Get or create a month note (raw)",
28394
+ get_year_note: "Get or create a year note (raw)",
28395
+ get_inbox_note: "Get or create the inbox note (raw)",
28396
+ create_note: "Create a note (raw)",
28397
+ patch_note: "Change note properties (raw)",
28398
+ update_note_content: "Replace note content (raw)",
28399
+ clone_note: "Clone a note (raw)",
28400
+ move_note: "Move a note (raw)",
28401
+ undelete_note: "Undelete a note (raw)",
28402
+ add_label: "Add a label (raw)",
28403
+ add_relation: "Add a relation (raw)",
28404
+ update_attribute: "Update an attribute (raw)",
28405
+ create_attachment: "Create an attachment (raw)",
28406
+ update_attachment: "Update an attachment (raw)",
28407
+ create_revision: "Snapshot a revision (raw)",
28408
+ create_backup: "Create a backup (raw)",
28409
+ delete_note: "Delete a note (raw)",
28410
+ delete_attribute: "Delete an attribute (raw)",
28411
+ delete_branch: "Delete a branch (raw)",
28412
+ delete_attachment: "Delete an attachment (raw)"
28413
+ };
27469
28414
  function applyToolAnnotations(server) {
27470
28415
  const registry = server._registeredTools;
27471
28416
  if (!registry)
@@ -27475,11 +28420,15 @@ function applyToolAnnotations(server) {
27475
28420
  for (const [name, tool] of Object.entries(registry)) {
27476
28421
  const hints = TOOL_ANNOTATIONS[name];
27477
28422
  if (!hints) {
27478
- tool.annotations = { ...tool.annotations, ...APPEND, title: name };
28423
+ const fallback = TOOL_TITLES[name] ?? name;
28424
+ tool.annotations = { ...tool.annotations, ...APPEND, title: fallback };
28425
+ tool.title = fallback;
27479
28426
  unclassified.push(name);
27480
28427
  continue;
27481
28428
  }
27482
- tool.annotations = { ...tool.annotations, ...hints, title: name };
28429
+ const title = TOOL_TITLES[name] ?? name;
28430
+ tool.annotations = { ...tool.annotations, ...hints, title };
28431
+ tool.title = title;
27483
28432
  annotated++;
27484
28433
  }
27485
28434
  return { annotated, unclassified };
@@ -27635,10 +28584,24 @@ var asArray = (v) => {
27635
28584
  return v;
27636
28585
  }
27637
28586
  };
28587
+ var asObject = (v) => {
28588
+ if (typeof v !== "string")
28589
+ return v;
28590
+ const t = v.trim();
28591
+ if (!t.startsWith("{"))
28592
+ return v;
28593
+ try {
28594
+ const parsed = JSON.parse(t);
28595
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : v;
28596
+ } catch {
28597
+ return v;
28598
+ }
28599
+ };
27638
28600
  var COERCERS = {
27639
28601
  ZodBoolean: asBoolean,
27640
28602
  ZodNumber: asNumber,
27641
- ZodArray: asArray
28603
+ ZodArray: asArray,
28604
+ ZodObject: asObject
27642
28605
  };
27643
28606
  function coerceToolArgs(server) {
27644
28607
  const registry = server._registeredTools;
@@ -27971,6 +28934,93 @@ note.dateModified >= 'YYYY-MM-DD', AND/OR. Unscoped unless ancestorNoteId is giv
27971
28934
  });
27972
28935
  }
27973
28936
 
28937
+ // src/cap.ts
28938
+ var RESULT_CHAR_CAP = 140000;
28939
+ function capResult(result, tool, cap = RESULT_CHAR_CAP) {
28940
+ const blocks = result?.content;
28941
+ if (!Array.isArray(blocks))
28942
+ return result;
28943
+ const total = blocks.reduce((n, b) => n + (b.type === "text" ? b.text?.length ?? 0 : 0), 0);
28944
+ if (total <= cap)
28945
+ return result;
28946
+ let budget = cap;
28947
+ const content = blocks.map((b) => {
28948
+ if (b.type !== "text" || !b.text)
28949
+ return b;
28950
+ if (budget <= 0)
28951
+ return { ...b, text: "" };
28952
+ const text = b.text.length > budget ? b.text.slice(0, budget) : b.text;
28953
+ budget -= text.length;
28954
+ return { ...b, text };
28955
+ });
28956
+ const note = `
28957
+
28958
+ [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=.]`;
28959
+ content.push({ type: "text", text: note });
28960
+ return { ...result, content };
28961
+ }
28962
+ function capToolResults(server, cap = RESULT_CHAR_CAP) {
28963
+ const registry = server._registeredTools;
28964
+ if (!registry)
28965
+ return 0;
28966
+ let wrapped = 0;
28967
+ for (const [name, tool] of Object.entries(registry)) {
28968
+ const inner = tool.handler;
28969
+ if (typeof inner !== "function")
28970
+ continue;
28971
+ tool.handler = async (...args) => capResult(await inner(...args), name, cap);
28972
+ wrapped++;
28973
+ }
28974
+ return wrapped;
28975
+ }
28976
+
28977
+ // src/brand.ts
28978
+ var BRAND2 = {
28979
+ name: "BrainLLM",
28980
+ tagline: "Give Claude a memory that survives the session",
28981
+ description: "Self-hosted memory for LLMs in TriliumNext, structured by the server and editable by you.",
28982
+ website: "https://miisodev.github.io/BrainLLM/",
28983
+ repository: "https://github.com/miisodev/BrainLLM",
28984
+ publisher: "miisodev",
28985
+ colors: { ground: "#0a0a0f", accent: "#f59e0b", nodeOff: "#3f3f46" }
28986
+ };
28987
+ var MARK_GRID = [
28988
+ true,
28989
+ false,
28990
+ true,
28991
+ true,
28992
+ true,
28993
+ false,
28994
+ false,
28995
+ true,
28996
+ true
28997
+ ];
28998
+ function markSvg(size = 22) {
28999
+ const dots = MARK_GRID.map((lit, i) => {
29000
+ const cx = 164 + i % 3 * 92;
29001
+ const cy = 164 + Math.floor(i / 3) * 92;
29002
+ return `<circle cx="${cx}" cy="${cy}" r="38" fill="${lit ? BRAND2.colors.accent : BRAND2.colors.nodeOff}"/>`;
29003
+ }).join("");
29004
+ return `<svg class="brand-mark" width="${size}" height="${size}" viewBox="110 110 292 292" aria-hidden="true">${dots}</svg>`;
29005
+ }
29006
+ var SERVER_INSTRUCTIONS = [
29007
+ `${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.`,
29008
+ "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).",
29009
+ "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.",
29010
+ "The server owns placement, labels, deduplication, dates and backups, so writes need content, not bookkeeping."
29011
+ ].join(`
29012
+
29013
+ `);
29014
+ function brandIcons(origin) {
29015
+ const REPO_RAW = "https://raw.githubusercontent.com/miisodev/BrainLLM/main/public";
29016
+ const at = (file) => origin ? `${origin}/${file}` : `${REPO_RAW}/${file}`;
29017
+ return [
29018
+ { src: at("icon-128.png"), mimeType: "image/png", sizes: ["128x128"] },
29019
+ { src: at("icon-512.png"), mimeType: "image/png", sizes: ["512x512"] },
29020
+ { src: origin ? `${origin}/icon.svg` : `${REPO_RAW}/BrainLLM.svg`, mimeType: "image/svg+xml", sizes: ["any"] }
29021
+ ];
29022
+ }
29023
+
27974
29024
  // src/sse.ts
27975
29025
  import { randomUUID } from "crypto";
27976
29026
  var KEEPALIVE_MS = 30000;
@@ -28078,7 +29128,7 @@ data: ${JSON.stringify(message)}
28078
29128
  }
28079
29129
 
28080
29130
  // src/oauth.ts
28081
- import { createHmac, randomBytes, createHash, timingSafeEqual } from "crypto";
29131
+ import { createHmac, randomBytes, createHash as createHash2, timingSafeEqual } from "crypto";
28082
29132
  import { existsSync as existsSync2, readFileSync as readFileSync2, statSync, writeFileSync as writeFileSync2 } from "fs";
28083
29133
 
28084
29134
  // src/network-security.ts
@@ -28754,9 +29804,7 @@ var PAGE_CSS = `
28754
29804
  border:1px solid var(--border); border-radius:16px; padding:32px;
28755
29805
  }
28756
29806
  .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; }
29807
+ .brand-mark { display:block; flex:none; }
28760
29808
  .brand-name { font-family:var(--font-display); font-size:19px; font-weight:700; letter-spacing:-.475px; }
28761
29809
  h1 { font-family:var(--font-display); font-size:24px; font-weight:700; letter-spacing:-.02em; line-height:1.2; margin:0 0 8px; }
28762
29810
  p { margin:0 0 20px; color:var(--text-muted); font-size:14.5px; }
@@ -28788,6 +29836,12 @@ var PAGE_CSS = `
28788
29836
  .approve:hover { transform:translateY(-1px); box-shadow:0 0 56px rgba(245,158,11,.42); }
28789
29837
  .deny { background:transparent; color:var(--text); border-color:var(--border-strong); font-weight:500; }
28790
29838
  .deny:hover { background:rgba(255,255,255,.04); }
29839
+ .redirect { margin-top:-8px; }
29840
+ .warn {
29841
+ background:rgba(245,158,11,.08); border:1px solid rgba(245,158,11,.3); color:#fcd34d;
29842
+ padding:11px 14px; border-radius:9px; font-size:14px; margin-bottom:18px;
29843
+ }
29844
+ a { color:var(--accent); }
28791
29845
  .err {
28792
29846
  background:rgba(239,68,68,.1); border:1px solid rgba(239,68,68,.3); color:#fca5a5;
28793
29847
  padding:11px 14px; border-radius:9px; font-size:14px; margin-bottom:18px;
@@ -28796,7 +29850,7 @@ var PAGE_CSS = `
28796
29850
  .foot a { color:inherit; }
28797
29851
  @media (prefers-reduced-motion: reduce) { button { transition:none; } .approve:hover { transform:none; } }
28798
29852
  `;
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>`;
29853
+ var BRAND_MARK = markSvg(22);
28800
29854
  var shell = (title, body, head = "") => `<!doctype html>
28801
29855
  <html lang="en"><head><meta charset="utf-8">
28802
29856
  <meta name="viewport" content="width=device-width,initial-scale=1">
@@ -28806,13 +29860,13 @@ var shell = (title, body, head = "") => `<!doctype html>
28806
29860
  <style>${PAGE_CSS}</style></head>
28807
29861
  <body>${body}</body></html>`;
28808
29862
  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>
29863
+ return shell(`${BRAND2.name} - ${BRAND2.tagline}`, `<div class="card">
29864
+ <div class="brand">${BRAND_MARK}<span class="brand-name">${BRAND2.name}</span></div>
28811
29865
  <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>
29866
+ <p>This is a <strong>${BRAND2.name}</strong> server: ${esc2(BRAND2.description.charAt(0).toLowerCase() + BRAND2.description.slice(1))}</p>
29867
+ <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
29868
  <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>
29869
+ <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
29870
  </div>`);
28817
29871
  }
28818
29872
  var PAGE_SECURITY_DIRECTIVES = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; img-src 'self' data:";
@@ -28820,7 +29874,7 @@ function pageContentSecurityPolicy() {
28820
29874
  return `${PAGE_SECURITY_DIRECTIVES}; form-action 'self'; style-src 'unsafe-inline'`;
28821
29875
  }
28822
29876
  function consentResponse(transaction, redirectUri, clientHost, verifiedHost, error, status = 200) {
28823
- return new Response(consentPage({ transaction }, clientHost, error, verifiedHost), {
29877
+ return new Response(consentPage({ transaction }, clientHost, error, verifiedHost, redirectUri), {
28824
29878
  status,
28825
29879
  headers: {
28826
29880
  "Content-Type": "text/html; charset=utf-8",
@@ -28829,13 +29883,28 @@ function consentResponse(transaction, redirectUri, clientHost, verifiedHost, err
28829
29883
  }
28830
29884
  });
28831
29885
  }
28832
- function consentPage(params, clientHost, error, verifiedHost = true) {
29886
+ function redirectTarget(redirectUri) {
29887
+ if (!redirectUri)
29888
+ return null;
29889
+ try {
29890
+ const u = new URL(redirectUri);
29891
+ const host = u.hostname.replace(/^\[|\]$/g, "");
29892
+ const loopback = host === "localhost" || host === "127.0.0.1" || host === "::1";
29893
+ return { host: u.port ? `${u.hostname}:${u.port}` : u.hostname, loopback };
29894
+ } catch {
29895
+ return null;
29896
+ }
29897
+ }
29898
+ function consentPage(params, clientHost, error, verifiedHost = true, redirectUri) {
29899
+ const target = redirectTarget(redirectUri);
28833
29900
  const hidden = Object.entries(params).map(([k, v]) => `<input type="hidden" name="${esc2(k)}" value="${esc2(v)}">`).join("");
28834
29901
  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
29902
  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>
29903
+ <div class="brand">${BRAND_MARK}<span class="brand-name">${BRAND2.name}</span></div>
28837
29904
  <h1>Authorize access</h1>
28838
29905
  <p><span class="host">${esc2(clientHost)}</span> is requesting access to your brain.</p>
29906
+ ${target ? `<p class="redirect">Approving sends you back to <span class="host">${esc2(target.host)}</span>.</p>` : ""}
29907
+ ${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
29908
  ${error ? `<div class="err">${esc2(error)}</div>` : ""}
28840
29909
  <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
29910
  <label for="pw">Owner password</label>
@@ -28860,7 +29929,7 @@ function handoff(uri, params, heading, message) {
28860
29929
  u.searchParams.set(k, v);
28861
29930
  const href = esc2(u.href);
28862
29931
  return new Response(shell(heading, `<div class="card">
28863
- <div class="brand">${BRAND_MARK}<span class="brand-name">BrainLLM</span></div>
29932
+ <div class="brand">${BRAND_MARK}<span class="brand-name">${BRAND2.name}</span></div>
28864
29933
  <h1>${esc2(heading)}</h1>
28865
29934
  <p>${esc2(message)}</p>
28866
29935
  <p class="foot"><a href="${href}">Continue</a> if this page does not move on by itself.</p>
@@ -28875,7 +29944,7 @@ function handoff(uri, params, heading, message) {
28875
29944
  });
28876
29945
  }
28877
29946
  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>
29947
+ <div class="brand">${BRAND_MARK}<span class="brand-name">${BRAND2.name}</span></div>
28879
29948
  <h1>Authorization error</h1>
28880
29949
  <div class="err">${esc2(message)}</div>
28881
29950
  <p class="foot">Nothing was authorized. Close this window and start again from your client.</p>
@@ -28982,12 +30051,15 @@ var tokenError = (error, description, status = 400) => new Response(JSON.stringi
28982
30051
  status,
28983
30052
  headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }
28984
30053
  });
30054
+ function refreshKey(token) {
30055
+ return "sha256:" + createHash2("sha256").update(token).digest("hex");
30056
+ }
28985
30057
  function issueTokens(base, clientId, resource, scope) {
28986
30058
  const store = loadStore();
28987
30059
  const now = Math.floor(Date.now() / 1000);
28988
30060
  const accessToken = signJwt({ iss: base, aud: resource, sub: "owner", scope, iat: now, exp: now + ACCESS_TOKEN_TTL_S }, signingSecret());
28989
30061
  const refreshToken = randomBytes(32).toString("base64url");
28990
- store.refresh[refreshToken] = { clientId, resource, scope, expiresAt: Date.now() + REFRESH_TOKEN_TTL_MS };
30062
+ store.refresh[refreshKey(refreshToken)] = { clientId, resource, scope, expiresAt: Date.now() + REFRESH_TOKEN_TTL_MS };
28991
30063
  saveStore();
28992
30064
  return {
28993
30065
  access_token: accessToken,
@@ -29026,7 +30098,7 @@ async function handleToken(req, base) {
29026
30098
  return tokenError("invalid_grant", "redirect_uri mismatch.");
29027
30099
  if (!verifier)
29028
30100
  return tokenError("invalid_request", "code_verifier is required.");
29029
- const computed = createHash("sha256").update(verifier).digest("base64url");
30101
+ const computed = createHash2("sha256").update(verifier).digest("base64url");
29030
30102
  if (computed !== record.codeChallenge)
29031
30103
  return tokenError("invalid_grant", "PKCE verification failed.");
29032
30104
  return new Response(JSON.stringify(issueTokens(base, record.clientId, record.resource, record.scope)), {
@@ -29035,10 +30107,11 @@ async function handleToken(req, base) {
29035
30107
  }
29036
30108
  if (grantType === "refresh_token") {
29037
30109
  const supplied = get("refresh_token");
29038
- const record = store.refresh[supplied];
29039
- if (!record)
30110
+ const key = supplied && store.refresh[refreshKey(supplied)] ? refreshKey(supplied) : supplied;
30111
+ const record = key ? store.refresh[key] : undefined;
30112
+ if (!record || !key)
29040
30113
  return tokenError("invalid_grant", "Unknown or already-rotated refresh token.");
29041
- delete store.refresh[supplied];
30114
+ delete store.refresh[key];
29042
30115
  saveStore();
29043
30116
  if (record.expiresAt < Date.now())
29044
30117
  return tokenError("invalid_grant", "Refresh token expired.");
@@ -29128,22 +30201,15 @@ if (port && !authToken && !oauthEnabled() && !allowUnauthenticatedHttp) {
29128
30201
  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
30202
  process.exit(1);
29130
30203
  }
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
30204
  function createServer(origin = null) {
29141
30205
  const s = new McpServer({
29142
- name: "BrainLLM",
29143
- title: "BrainLLM",
29144
- version: "12.7.1",
29145
- icons: brandingIcons(origin)
29146
- });
30206
+ name: BRAND2.name,
30207
+ title: BRAND2.name,
30208
+ version: "12.9.0",
30209
+ description: BRAND2.description,
30210
+ websiteUrl: BRAND2.website,
30211
+ icons: brandIcons(origin)
30212
+ }, { instructions: SERVER_INSTRUCTIONS });
29147
30213
  registerTools(s, trilium, brainRef);
29148
30214
  if (mode === "full")
29149
30215
  registerAdvancedTools(s, trilium, brainRef);
@@ -29155,6 +30221,7 @@ function createServer(origin = null) {
29155
30221
  if (unclassified.length) {
29156
30222
  console.error(`[brainllm] Unclassified tools, treated as writes: ${unclassified.join(", ")}`);
29157
30223
  }
30224
+ capToolResults(s);
29158
30225
  const serialized = serializeWrites(s);
29159
30226
  if (serialized.serialized) {
29160
30227
  console.error(`[brainllm] Write serialization: ${serialized.serialized} write handler(s) queued, ${serialized.readsUntouched} read(s) untouched`);
@@ -29191,7 +30258,8 @@ if (port) {
29191
30258
  authorize: new FixedWindowRateLimiter(30, 15 * 60000),
29192
30259
  token: new FixedWindowRateLimiter(120, 15 * 60000),
29193
30260
  register: new FixedWindowRateLimiter(30, 60 * 60000),
29194
- transport: new FixedWindowRateLimiter(600, 15 * 60000)
30261
+ transport: new FixedWindowRateLimiter(600, 15 * 60000),
30262
+ transportIp: new FixedWindowRateLimiter(3000, 15 * 60000)
29195
30263
  };
29196
30264
  const rateLimited = (limiter, key) => {
29197
30265
  const decision = limiter.allow(key);
@@ -29230,6 +30298,7 @@ if (port) {
29230
30298
  Bun.serve({
29231
30299
  port,
29232
30300
  maxRequestBodySize: 50 * 1024 * 1024,
30301
+ idleTimeout: 240,
29233
30302
  async fetch(req, server) {
29234
30303
  const url = new URL(req.url);
29235
30304
  const requestKey = clientKey(req, server);
@@ -29304,7 +30373,12 @@ if (port) {
29304
30373
  if (!isMcpEndpoint && url.pathname !== "/sse" && url.pathname !== "/messages") {
29305
30374
  return withCors(new Response("Not Found", { status: 404 }));
29306
30375
  }
29307
- const transportLimit = rateLimited(rateLimiters.transport, requestKey);
30376
+ const ipLimit = rateLimited(rateLimiters.transportIp, requestKey);
30377
+ if (ipLimit)
30378
+ return ipLimit;
30379
+ const bearer = req.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
30380
+ const transportKey = bearer ? `token:${new Bun.CryptoHasher("sha256").update(bearer).digest("hex").slice(0, 32)}` : requestKey;
30381
+ const transportLimit = rateLimited(rateLimiters.transport, transportKey);
29308
30382
  if (transportLimit)
29309
30383
  return transportLimit;
29310
30384
  const gate = () => {