brainllm 12.8.0 → 12.9.1

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
  `);
@@ -21978,6 +22051,9 @@ function stripTagsWithMap(html) {
21978
22051
  function escapeQueryValue(s) {
21979
22052
  return s.replace(/["\\]/g, " ").replace(/\s+/g, " ").trim();
21980
22053
  }
22054
+ function triliumSearchForm(pattern) {
22055
+ return pattern.normalize("NFD").replace(/\p{Diacritic}/gu, (c) => c.charCodeAt(0) < 128 ? c : "");
22056
+ }
21981
22057
  function escapeQueryRegex(s) {
21982
22058
  return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
21983
22059
  }
@@ -22171,12 +22247,21 @@ function sanitizeHtml(html) {
22171
22247
  n++;
22172
22248
  return "";
22173
22249
  });
22174
- 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) => {
22175
22252
  n++;
22253
+ dropped += m.length;
22176
22254
  return "";
22177
22255
  });
22178
22256
  if (n)
22179
- 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`);
22180
22265
  const comments = s.match(/<!--[\s\S]*?-->/g)?.length ?? 0;
22181
22266
  if (comments) {
22182
22267
  s = s.replace(/<!--[\s\S]*?-->/g, "");
@@ -22288,19 +22373,44 @@ function tolerantFindRegex(find) {
22288
22373
  function spansBlockBoundary(find) {
22289
22374
  return /<\/[a-zA-Z][a-zA-Z0-9-]*\s*>\s*<[a-zA-Z]/.test(find);
22290
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
+ }
22291
22395
  var INLINE_TAGS = "strong|em|b|i|u|s|del|ins|code|a|span|mark|sub|sup|kbd|small";
22292
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;)";
22293
22399
  var ENTITY_FORMS = {
22294
22400
  "&": "&(?:amp;)?",
22295
22401
  "<": "&lt;",
22296
22402
  ">": "&gt;",
22297
- '"': '(?:"|&quot;)',
22298
- "'": "(?:'|&#39;|&apos;)"
22403
+ '"': DOUBLE_QUOTE,
22404
+ "\u201C": DOUBLE_QUOTE,
22405
+ "\u201D": DOUBLE_QUOTE,
22406
+ "'": SINGLE_QUOTE,
22407
+ "\u2018": SINGLE_QUOTE,
22408
+ "\u2019": SINGLE_QUOTE
22299
22409
  };
22300
22410
  function inlineTolerantSpans(source, needle) {
22301
22411
  if (/<\/?(?!(?:strong|em|b|i|u|s|del|ins|code|a|span|mark|sub|sup|kbd|small)\b)[a-zA-Z]/.test(needle))
22302
22412
  return [];
22303
- const text = decodeEntities(needle.replace(INLINE_TAG_RX, "")).trim();
22413
+ const text = decodeEntities(needle.replace(INLINE_TAG_RX, "")).replace(/\\(["'])/g, "$1").trim();
22304
22414
  if (text.length < 2)
22305
22415
  return [];
22306
22416
  const gap = `(?:<\\/?(?:${INLINE_TAGS})(?:\\s[^<>]*)?\\/?>)*`;
@@ -22379,11 +22489,26 @@ function bumpLastUpdated(html, date) {
22379
22489
  return { html: html.replace(re, `$1${formatted}`), bumped: true };
22380
22490
  }
22381
22491
  function leadingIdentification(html) {
22492
+ return leadingIdentityText(html)?.includes("\xB7") ?? false;
22493
+ }
22494
+ function leadingIdentityText(html) {
22382
22495
  const m = /^(?:\s|<p>(?:\s|&nbsp;)*<\/p>)*<h3(?:\s[^>]*)?>([\s\S]*?)<\/h3>/i.exec(html);
22383
- if (!m)
22384
- return false;
22385
- const text = decodeEntities(m[1].replace(/<[^>]+>/g, "")).trim();
22386
- 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;
22387
22512
  }
22388
22513
  function duplicateHeadings(html) {
22389
22514
  const MARKER = /<h2(?:\s[^>]*)?>\s*(?:Addendum|Withdrawn|Recovered|Reopened)\s*(?:\u2014|\u2013|-)[^<]*<\/h2>/gi;
@@ -22680,11 +22805,47 @@ function unbalancedTags(html) {
22680
22805
  }
22681
22806
  return [...new Set(stack)];
22682
22807
  }
22808
+ function tableShapeIssues(html) {
22809
+ const issues = [];
22810
+ const spanOf = (attrs, name) => Number(new RegExp(`${name}\\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: spanOf(c[2], "colspan"),
22814
+ rows: spanOf(c[2], "rowspan"),
22815
+ text: decodeEntities(c[3].replace(/<[^>]+>/g, "")).trim()
22816
+ })));
22817
+ if (rows.length < 2)
22818
+ continue;
22819
+ const width = rows[0].reduce((sum, c) => sum + c.span, 0);
22820
+ let spanning = [];
22821
+ for (const [i, cells] of rows.entries()) {
22822
+ const inherited = spanning.reduce((sum, s) => sum + s.columns, 0);
22823
+ if (i > 0) {
22824
+ const label = (cells[0]?.text ?? "").slice(0, 60) || "(empty row)";
22825
+ const count = cells.reduce((sum, c) => sum + c.span, 0) + inherited;
22826
+ if (count !== width) {
22827
+ issues.push(`row "${label}" has ${count} cell(s), its header has ${width}`);
22828
+ } else {
22829
+ const piped = cells.find((c) => (c.text.match(/\s\|\s/g)?.length ?? 0) >= Math.max(1, width - 2) && width > 1);
22830
+ if (piped && cells.filter((c) => c.text).length < width) {
22831
+ issues.push(`row "${label}" carries pipe-separated values inside one cell \u2014 send each value as its own <td>`);
22832
+ }
22833
+ }
22834
+ }
22835
+ spanning = [
22836
+ ...spanning.map((s) => ({ ...s, rowsLeft: s.rowsLeft - 1 })).filter((s) => s.rowsLeft > 0),
22837
+ ...cells.filter((c) => c.rows > 1).map((c) => ({ columns: c.span, rowsLeft: c.rows - 1 }))
22838
+ ];
22839
+ }
22840
+ }
22841
+ return issues;
22842
+ }
22683
22843
  var LARGE_NOTE_CHARS = 40000;
22684
22844
  function structureReport(html) {
22685
22845
  return {
22686
22846
  duplicateHeadings: duplicateHeadings(html),
22687
22847
  unbalancedTags: unbalancedTags(html),
22848
+ tableShape: tableShapeIssues(html),
22688
22849
  size: html.length
22689
22850
  };
22690
22851
  }
@@ -23518,6 +23679,9 @@ async function sweep(trilium, cfg, opts = {}) {
23518
23679
  if (structure.unbalancedTags.length) {
23519
23680
  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)`);
23520
23681
  }
23682
+ if (structure.tableShape.length) {
23683
+ 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")`);
23684
+ }
23521
23685
  const profile = sectionProfile(content);
23522
23686
  const biggest = profile.largest[0];
23523
23687
  const crowded = profile.count >= SECTION_EDIT_RISK_COUNT;
@@ -23590,13 +23754,15 @@ async function sweep(trilium, cfg, opts = {}) {
23590
23754
  byDomain.get(domSlug).push(n);
23591
23755
  }
23592
23756
  const headingCache = new Map;
23593
- const headingsOf = async (n) => {
23757
+ const shapeOf = async (n) => {
23594
23758
  if (!headingCache.has(n.noteId)) {
23595
23759
  const content = await trilium.getNoteContent(n.noteId).catch(() => "");
23596
- headingCache.set(n.noteId, headingOutline(content).filter((h) => h.level <= 3).map((h) => h.text));
23760
+ const words = new Set(content.replace(/<[^>]+>/g, " ").toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 3));
23761
+ headingCache.set(n.noteId, { headings: headingOutline(content).filter((h) => h.level <= 3).map((h) => h.text), words });
23597
23762
  }
23598
23763
  return headingCache.get(n.noteId);
23599
23764
  };
23765
+ const GENERIC_HEADING = /^(last updated\b.*|overview|context|goal|sources|revision|resolution|notes?|summary)$/i;
23600
23766
  for (const [domSlug, notes] of byDomain) {
23601
23767
  const reported = new Set;
23602
23768
  for (let i = 0;i < notes.length; i++) {
@@ -23613,10 +23779,19 @@ async function sweep(trilium, cfg, opts = {}) {
23613
23779
  reported.add(pairKey);
23614
23780
  if (acknowledged(a) || acknowledged(bn))
23615
23781
  continue;
23616
- const [ha, hb] = await Promise.all([headingsOf(a), headingsOf(bn)]);
23617
- const overlap = ha.filter((h) => hb.some((x) => x.toLowerCase() === h.toLowerCase()));
23782
+ const [sa, sb] = await Promise.all([shapeOf(a), shapeOf(bn)]);
23783
+ const ha = sa.headings, hb = sb.headings;
23784
+ const overlap = ha.filter((h) => !GENERIC_HEADING.test(h) && hb.some((x) => x.toLowerCase() === h.toLowerCase()));
23785
+ let common = 0;
23786
+ for (const w of sa.words)
23787
+ if (sb.words.has(w))
23788
+ common++;
23789
+ const union = sa.words.size + sb.words.size - common;
23790
+ const similarity = union ? common / union : 0;
23791
+ if (!overlap.length && similarity < 0.45)
23792
+ continue;
23618
23793
  const show = (h) => h.length ? h.slice(0, 8).join(" \xB7 ") + (h.length > 8 ? " \xB7 \u2026" : "") : "(no headings)";
23619
- 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.`));
23794
+ 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.`));
23620
23795
  }
23621
23796
  }
23622
23797
  }
@@ -24016,6 +24191,96 @@ async function catchUpDeletions(trilium, cfg, today) {
24016
24191
  return { windowDays, coverage: "full", deletionsFound: total, regenerated };
24017
24192
  }
24018
24193
 
24194
+ // src/seal.ts
24195
+ import { createHash } from "crypto";
24196
+ var GENESIS = "genesis";
24197
+ function sealText(html) {
24198
+ return decodeEntities(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
24199
+ }
24200
+ function sha(text) {
24201
+ return createHash("sha256").update(text, "utf8").digest("hex");
24202
+ }
24203
+ function sealRecord(text) {
24204
+ return [text.length, sha(text).slice(0, 16)];
24205
+ }
24206
+ function dayDigest(prev, date, records) {
24207
+ const canonical = Object.keys(records).sort().map((id) => `${id}:${records[id][0]}:${records[id][1]}`).join(",");
24208
+ return sha(`${prev}
24209
+ ${date}
24210
+ ${canonical}`);
24211
+ }
24212
+ function recordIntact(current, sealed) {
24213
+ if (current.length < sealed[0])
24214
+ return false;
24215
+ return sha(current.slice(0, sealed[0])).slice(0, 16) === sealed[1];
24216
+ }
24217
+ async function dayRecords(trilium, cfg, date) {
24218
+ 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([]);
24219
+ const [sessions, diary, entries] = await Promise.all([
24220
+ find(cfg.memory.sessions, "session"),
24221
+ find(cfg.llm.diary, "diary"),
24222
+ find(cfg.memory.threads, "threadEntry")
24223
+ ]);
24224
+ return [...sessions, ...diary, ...entries.filter((n) => n.title.trim() === `[${date}]`)];
24225
+ }
24226
+ async function sealedLogs(trilium, cfg) {
24227
+ if (!cfg.insights.logs)
24228
+ return [];
24229
+ const res = await trilium.searchNotes("#noteType=log #seal", { ancestorNoteId: cfg.insights.logs, fastSearch: true, limit: 1000, includeArchivedNotes: true }).catch(() => ({ results: [] }));
24230
+ return res.results.filter((n) => ownedLabel(n, "seal")).sort((a, b) => (ownedLabel(a, "created") ?? "").localeCompare(ownedLabel(b, "created") ?? ""));
24231
+ }
24232
+ async function sealDay(trilium, cfg, date, logNoteId) {
24233
+ const earlier = (await sealedLogs(trilium, cfg)).filter((n) => (ownedLabel(n, "created") ?? "") < date);
24234
+ const prev = earlier.length ? ownedLabel(earlier[earlier.length - 1], "seal") : GENESIS;
24235
+ const records = {};
24236
+ for (const n of await dayRecords(trilium, cfg, date)) {
24237
+ const content = await trilium.getNoteContent(n.noteId).catch(() => "");
24238
+ if (content)
24239
+ records[n.noteId] = sealRecord(sealText(content));
24240
+ }
24241
+ const digest = dayDigest(prev, date, records);
24242
+ await trilium.updateLabelValue(logNoteId, "sealRecords", JSON.stringify(records));
24243
+ await trilium.updateLabelValue(logNoteId, "sealPrev", prev);
24244
+ await trilium.updateLabelValue(logNoteId, "seal", digest);
24245
+ return { date, digest, records: Object.keys(records).length };
24246
+ }
24247
+ async function verifySeals(trilium, cfg, today, days = 7) {
24248
+ const logs = (await sealedLogs(trilium, cfg)).filter((n) => (ownedLabel(n, "created") ?? "") < today);
24249
+ const findings = [];
24250
+ const window = logs.slice(-days);
24251
+ for (const log of window) {
24252
+ const date = ownedLabel(log, "created") ?? "";
24253
+ const digest = ownedLabel(log, "seal") ?? "";
24254
+ const prev = ownedLabel(log, "sealPrev") ?? "";
24255
+ const i = logs.indexOf(log);
24256
+ const expectedPrev = i > 0 ? ownedLabel(logs[i - 1], "seal") ?? "" : prev;
24257
+ if (prev !== expectedPrev) {
24258
+ 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` });
24259
+ }
24260
+ let records;
24261
+ try {
24262
+ records = JSON.parse(ownedLabel(log, "sealRecords") ?? "{}");
24263
+ } catch {
24264
+ findings.push({ date, noteId: log.noteId, problem: "the sealed record list is unreadable" });
24265
+ continue;
24266
+ }
24267
+ if (dayDigest(prev, date, records) !== digest) {
24268
+ findings.push({ date, noteId: log.noteId, problem: "the sealed record list no longer matches its digest \u2014 the seal labels were edited" });
24269
+ }
24270
+ for (const [noteId, sealed] of Object.entries(records)) {
24271
+ const content = await trilium.getNoteContent(noteId).catch(() => null);
24272
+ if (content === null) {
24273
+ findings.push({ date, noteId, problem: "a sealed record is gone (deleted or unreadable)" });
24274
+ continue;
24275
+ }
24276
+ if (!recordIntact(sealText(content), sealed)) {
24277
+ 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" });
24278
+ }
24279
+ }
24280
+ }
24281
+ return { checked: window.length, findings };
24282
+ }
24283
+
24019
24284
  // src/diffing.ts
24020
24285
  function revisionsSince(revisions, cutoff) {
24021
24286
  return revisions.filter((r) => (r.dateCreated ?? "").slice(0, 16) >= cutoff);
@@ -24617,6 +24882,8 @@ function registerTools(server, trilium, brainRef) {
24617
24882
  const preCloseSteps = new Map;
24618
24883
  let preCloseSeq = 0;
24619
24884
  const REQUIRED_PRECLOSE_STEPS = ["session", "addendum", "maintain", "remarks", "diary"];
24885
+ const LIGHT_PRECLOSE_STEPS = ["session", "remarks", "diary"];
24886
+ let wroteSinceClose = false;
24620
24887
  const gateNote = async (date) => {
24621
24888
  const cfg = b();
24622
24889
  if (!cfg.memory.sessions)
@@ -24675,6 +24942,7 @@ function registerTools(server, trilium, brainRef) {
24675
24942
  await trilium.updateLabelValue(noteId, "gate", "");
24676
24943
  preCloseSteps.clear();
24677
24944
  preCloseSeq = 0;
24945
+ wroteSinceClose = false;
24678
24946
  };
24679
24947
  const RECORD_KINDS = new Set(["session", "diary", "log", "threadEntry"]);
24680
24948
  const isRecordNote = (n) => {
@@ -24684,16 +24952,30 @@ function registerTools(server, trilium, brainRef) {
24684
24952
  return kind === "threadEntry" && /^\[\d{4}-\d{2}-\d{2}\]$/.test(n.title.trim());
24685
24953
  };
24686
24954
  const structuralFindings = (kind, html) => {
24687
- if (!html || kind && RECORD_KINDS.has(kind))
24955
+ if (!html)
24688
24956
  return {};
24957
+ const tableShape = tableShapeIssues(html);
24958
+ 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")).' } : {};
24959
+ if (kind && RECORD_KINDS.has(kind))
24960
+ return shape;
24689
24961
  const dupes = duplicateHeadings(html);
24690
24962
  if (!dupes.length)
24691
- return {};
24963
+ return shape;
24692
24964
  return {
24693
24965
  duplicateHeadings: dupes,
24694
- structureHint: "The note now carries duplicated section headings \u2014 merge them with revise(section=\u2026, mode=replace), or target one specifically with occurrence=."
24966
+ structureHint: "The note now carries duplicated section headings \u2014 merge them with revise(section=\u2026, mode=replace), or target one specifically with occurrence=.",
24967
+ ...shape
24695
24968
  };
24696
24969
  };
24970
+ const identityRefusal = (identity, html) => {
24971
+ const text = leadingIdentification(html) ? leadingIdentityText(html) : identity;
24972
+ if (!text)
24973
+ return null;
24974
+ const problem = identityProblem(text);
24975
+ if (!problem)
24976
+ return null;
24977
+ 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".');
24978
+ };
24697
24979
  const applyIcon = async (noteId, icon) => {
24698
24980
  const cls = icon ? normalizeIcon(icon) : "";
24699
24981
  if (!cls) {
@@ -24846,7 +25128,7 @@ ${block}` }));
24846
25128
  });
24847
25129
  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.
24848
25130
 
24849
- The close sequence next[] lays out: singleton updates from the session, then addendum(), maintain(), remarks(), diary() and close(). close() checks that addendum, maintain, remarks and diary ran, in the order session \u2192 remarks \u2192 diary. scope="agent" omits the singleton updates for scoped or autonomous runs.`, {
25131
+ 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().`, {
24850
25132
  date: stringType().optional().describe("ISO date YYYY-MM-DD (default: today)"),
24851
25133
  full: booleanType().optional().describe("Inline every singleton's content and the diary body (default: stubs only)"),
24852
25134
  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")`),
@@ -24856,6 +25138,19 @@ The close sequence next[] lays out: singleton updates from the session, then add
24856
25138
  const cfg = b();
24857
25139
  if (!cfg.master.root || !cfg.llm.root)
24858
25140
  throw new Error("BrainLLM not bootstrapped \u2014 run bootstrap.");
25141
+ if (!(await readGate(d)).has("write")) {
25142
+ await markStep("session", d);
25143
+ return txt3({
25144
+ date: d,
25145
+ closeMode: "light",
25146
+ 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.",
25147
+ next: [
25148
+ "Call remarks() \u2014 one short cue for a short session.",
25149
+ "Call diary() \u2014 one short paragraph: what the session was and anything worth the team knowing.",
25150
+ "Call close() \u2014 commit the session log."
25151
+ ]
25152
+ });
25153
+ }
24859
25154
  const fetchSingleton = async (id) => {
24860
25155
  if (!full) {
24861
25156
  const note = await trilium.getNote(id);
@@ -24957,6 +25252,13 @@ The close sequence next[] lays out: singleton updates from the session, then add
24957
25252
  if (!cfg.root)
24958
25253
  return txt3({ status: "uninitialized", action: "Run bootstrap first." });
24959
25254
  await markStep("remarks");
25255
+ if (!(await readGate(localToday())).has("write")) {
25256
+ return txt3({
25257
+ closeMode: "light",
25258
+ 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.",
25259
+ next: ["Write it via diary(body, identity), then call close()."]
25260
+ });
25261
+ }
24960
25262
  return txt3({
24961
25263
  cues: {
24962
25264
  experience: [
@@ -24977,7 +25279,7 @@ The close sequence next[] lays out: singleton updates from the session, then add
24977
25279
  ]
24978
25280
  });
24979
25281
  });
24980
- server.tool("close", `Writes the session log, normally the last call of a session. Refuses unless session(), addendum(), maintain(), remarks() and diary() ran and session \u2192 remarks \u2192 diary held (force=true bypasses a step with genuinely nothing to do; bypassed steps are reported). identity= is required. Writes a timestamped block to today's [yyyy-mm-dd] session note (title= becomes its heading), regenerates the daily log, backs up the database and resets the gate. continuing=true is a second close the same day.`, {
25282
+ 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.`, {
24981
25283
  summary: stringType().describe("What happened this session \u2014 factual, concise prose"),
24982
25284
  title: stringType().optional().describe("Short session title \u2014 appears as an <h2> heading above Summary"),
24983
25285
  identity: stringType().describe(`Identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" \u2014 rendered as the block's h3 (required)`),
@@ -25000,9 +25302,11 @@ The close sequence next[] lays out: singleton updates from the session, then add
25000
25302
  }
25001
25303
  }
25002
25304
  const gate = await readGate(gateDate);
25003
- const missing = continued ? [] : REQUIRED_PRECLOSE_STEPS.filter((step) => !gate.has(step));
25305
+ const light = !gate.has("write");
25306
+ const required = light ? LIGHT_PRECLOSE_STEPS : REQUIRED_PRECLOSE_STEPS;
25307
+ const missing = continued ? [] : required.filter((step) => !gate.has(step));
25004
25308
  if (missing.length && !force) {
25005
- 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.`);
25309
+ 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.`);
25006
25310
  }
25007
25311
  const seq = (step) => gate.get(step) ?? 0;
25008
25312
  const orderOk = continued || missing.length > 0 || seq("session") < seq("remarks") && seq("remarks") < seq("diary");
@@ -25018,6 +25322,9 @@ The close sequence next[] lays out: singleton updates from the session, then add
25018
25322
  if (!identity && !leadingIdentification(summaryHtml)) {
25019
25323
  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.`);
25020
25324
  }
25325
+ const badIdentity = identityRefusal(identity, summaryHtml);
25326
+ if (badIdentity)
25327
+ return badIdentity;
25021
25328
  const identityBlock = identity && !leadingIdentification(summaryHtml) ? `<h3>${escapeHtml(identity)}</h3>
25022
25329
  ` : "";
25023
25330
  const titleBlock = title ? `<h2>${escapeHtml(title)}</h2>
@@ -25072,6 +25379,7 @@ ${contentBlock}` }));
25072
25379
  if (!hasEdge(logNote, noteId))
25073
25380
  await trilium.addRelation(logReport.noteId, "references", noteId).catch(() => null);
25074
25381
  }
25382
+ const sealReport = logReport?.noteId ? await sealDay(trilium, cfg, d, logReport.noteId).catch(() => null) : null;
25075
25383
  let backupStatus = "disabled";
25076
25384
  let backupName = rollingBackupName(d);
25077
25385
  if (backup !== false) {
@@ -25093,6 +25401,8 @@ ${contentBlock}` }));
25093
25401
  ...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(", ")}` } : {},
25094
25402
  ...iconSet ? { icon: iconSet } : {},
25095
25403
  ...continued ? { continuing: true } : {},
25404
+ ...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" },
25405
+ ...light && !continued ? { closeMode: "light \u2014 no content was written this session, so addendum() and maintain() were not required" } : {},
25096
25406
  ...missing.length || !orderOk ? { bypassed: [...missing, ...!orderOk ? ["ordering(session\u2192remarks\u2192diary)"] : []] } : {},
25097
25407
  ...warnings.length ? { sanitized: warnings } : {}
25098
25408
  });
@@ -25190,6 +25500,9 @@ ${contentBlock}` }));
25190
25500
  if (!identity && !leadingIdentification(sanitized.html)) {
25191
25501
  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.`);
25192
25502
  }
25503
+ const badIdentity = identityRefusal(identity, sanitized.html);
25504
+ if (badIdentity)
25505
+ return badIdentity;
25193
25506
  const html = identity && !leadingIdentification(sanitized.html) ? `<h3>${escapeHtml(identity)}</h3>
25194
25507
  ${sanitized.html}` : sanitized.html;
25195
25508
  const found = await trilium.searchNotes(`#noteType=diary #created='${d}'`, { ancestorNoteId: parentId, fastSearch: true, limit: 1 });
@@ -25372,7 +25685,8 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25372
25685
  ...createdDomain ? { createdDomain: domainTitle } : {},
25373
25686
  ...revisionChanges.length ? { revision: revisionChanges } : {},
25374
25687
  ...sourceMerge,
25375
- ...revisionKeys.length ? { revisionRows: revisionKeys } : {},
25688
+ ...revisionKeys.length ? { revisionRows: revisionKeys.length } : {},
25689
+ ...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." } : {},
25376
25690
  ...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." } : {},
25377
25691
  ...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." } : {},
25378
25692
  ...connected.length ? { connected } : {},
@@ -25470,6 +25784,9 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25470
25784
  if (kind === "thread" && !identity && !leadingIdentification(html)) {
25471
25785
  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.`);
25472
25786
  }
25787
+ const badIdentity = identityRefusal(identity, html);
25788
+ if (badIdentity)
25789
+ return badIdentity;
25473
25790
  const block = identity && !leadingIdentification(html) ? `<h3>${escapeHtml(identity)}</h3>
25474
25791
  ${html}` : html;
25475
25792
  if (kind === "thread") {
@@ -25489,7 +25806,7 @@ ${html}` : html;
25489
25806
  const connected = await wireRequested(existing.noteId);
25490
25807
  const iconSet = await applyIcon(existing.noteId, icon);
25491
25808
  const relations = relationSnippet(existing);
25492
- 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 } : {} });
25809
+ 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 } : {} });
25493
25810
  }
25494
25811
  const current = await trilium.getNoteContent(existing.noteId);
25495
25812
  if (isDuplicateAppend(current, block))
@@ -25553,7 +25870,7 @@ ${block}`), d);
25553
25870
  ...sanitizeWarnings.length ? { sanitized: sanitizeWarnings } : {}
25554
25871
  });
25555
25872
  });
25556
- 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.`, {
25873
+ 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.`, {
25557
25874
  query: stringType().describe("What to find \u2014 natural phrasing is fine"),
25558
25875
  kinds: arrayType(enumType(Kinds)).optional().describe("Restrict to these kinds"),
25559
25876
  domain: stringType().optional().describe("Restrict to a knowledge domain"),
@@ -25616,10 +25933,21 @@ ${block}`), d);
25616
25933
  } catch (e) {
25617
25934
  return err("invalid_pattern", `Not a valid regular expression: ${e.message}`, "Escape backslashes \u2014 a JSON string needs \\\\d for \\d.");
25618
25935
  }
25619
- const candidates = (await run(`note.content %= '${escapeQueryRegex(regex)}'`)).filter(filterNote);
25936
+ const backendPattern = triliumSearchForm(regex);
25937
+ const widened = backendPattern !== regex;
25938
+ const candidates = (await run(`note.content %= '${escapeQueryRegex(backendPattern)}'`)).filter(filterNote);
25620
25939
  const confirmed = [];
25621
25940
  let rejected = 0;
25622
25941
  let tagSpanning = 0;
25942
+ let recordHits = 0;
25943
+ const evidenceIn = (text) => {
25944
+ const m = new RegExp(re.source, "i").exec(text);
25945
+ if (!m)
25946
+ return "";
25947
+ const from = Math.max(0, m.index - 80);
25948
+ const to = Math.min(text.length, m.index + m[0].length + 80);
25949
+ return `${from > 0 ? "\u2026" : ""}${text.slice(from, to).replace(/\s+/g, " ").trim()}${to < text.length ? "\u2026" : ""}`;
25950
+ };
25623
25951
  for (const n of candidates) {
25624
25952
  if (confirmed.length >= max)
25625
25953
  break;
@@ -25629,18 +25957,28 @@ ${block}`), d);
25629
25957
  continue;
25630
25958
  }
25631
25959
  const rawHit = re.test(content);
25632
- const projectedHit = !rawHit && re.test(stripTagsWithMap(content).text);
25960
+ const projected = stripTagsWithMap(content).text;
25961
+ const projectedHit = re.test(projected);
25633
25962
  if (rawHit || projectedHit) {
25634
- if (projectedHit)
25963
+ if (projectedHit && !rawHit)
25635
25964
  tagSpanning++;
25636
- confirmed.push(n);
25965
+ const kind = labelOf2(n, "noteType");
25966
+ if (kind === "session" || kind === "diary" || kind === "log" || kind === "threadEntry" && /^\[\d{4}-\d{2}-\d{2}\]$/.test(n.title.trim()))
25967
+ recordHits++;
25968
+ confirmed.push({ note: n, evidence: decodeEntities(projectedHit ? evidenceIn(projected) : evidenceIn(toText(content, 1e5))) });
25637
25969
  } else
25638
25970
  rejected++;
25639
25971
  }
25640
- const results = await Promise.all(confirmed.map(buildResult));
25972
+ const results = await Promise.all(confirmed.map(async ({ note, evidence }, i) => {
25973
+ const { snippet: _snippet, ...base } = await buildResult(note, i);
25974
+ return { ...base, ...evidence ? { evidence } : {} };
25975
+ }));
25641
25976
  const notes = [
25642
25977
  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,
25643
25978
  tagSpanning ? `${tagSpanning} match(es) were found only after stripping markup \u2014 the phrase is split by an inline tag there.` : null,
25979
+ 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,
25980
+ recordHits ? `${recordHits} result(s) are records (sessions, diary, dated thread entries) \u2014 included by default; kinds= narrows to maintained notes.` : null,
25981
+ 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,
25644
25982
  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,
25645
25983
  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
25646
25984
  ].filter(Boolean);
@@ -25701,7 +26039,12 @@ ${block}`), d);
25701
26039
  fuzzyIds.add(n.noteId);
25702
26040
  add(byFuzzy, 0.5);
25703
26041
  }
25704
- 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);
26042
+ const now = Date.now();
26043
+ const recency = (n) => {
26044
+ const age = Math.max(0, (now - new Date(n.dateModified.replace(" ", "T")).getTime()) / 86400000);
26045
+ return Number.isFinite(age) ? 0.9 * Math.pow(0.5, age / 14) : 0;
26046
+ };
26047
+ 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);
25705
26048
  const results = await Promise.all(ranked.map(async ({ note }, i) => {
25706
26049
  const row = await buildResult(note, i);
25707
26050
  return fuzzyIds.has(note.noteId) ? { ...row, fuzzy: true } : row;
@@ -25713,10 +26056,11 @@ ${block}`), d);
25713
26056
  ...results.length === 0 ? noMatch : {}
25714
26057
  });
25715
26058
  });
25716
- 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.`, {
26059
+ 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.`, {
25717
26060
  name: stringType().describe("Domain, topic, or project name"),
25718
- includeArchived: booleanType().optional().describe("Include archived/resolved items (default: false)")
25719
- }, async ({ name, includeArchived }) => {
26061
+ includeArchived: booleanType().optional().describe("Include archived/resolved items (default: false)"),
26062
+ 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")
26063
+ }, async ({ name, includeArchived, outline }) => {
25720
26064
  const cfg = b();
25721
26065
  const slug = slugify(name);
25722
26066
  const CAP = 500;
@@ -25743,6 +26087,19 @@ ${block}`), d);
25743
26087
  const staleAfter = cfg.policy.staleAfterDays;
25744
26088
  const idleSince = (iso) => Math.max(0, Math.floor((Date.now() - new Date(iso.replace(" ", "T")).getTime()) / 86400000));
25745
26089
  const RECORD_ROWS = new Set(["session", "diary", "log", "threadEntry"]);
26090
+ const outlines = new Map;
26091
+ if (outline) {
26092
+ await Promise.all(all.filter((n) => {
26093
+ const k = ownedLabel(n, "noteType");
26094
+ return k && k !== "domain" && !RECORD_ROWS.has(k);
26095
+ }).map(async (n) => {
26096
+ const content = await trilium.getNoteContent(n.noteId).catch(() => "");
26097
+ outlines.set(n.noteId, {
26098
+ headings: headingOutline(content).filter((h) => !/^Last updated\b/i.test(h.text)).map((h) => `${"#".repeat(h.level - 1)} ${h.text}`),
26099
+ size: content.length
26100
+ });
26101
+ }));
26102
+ }
25746
26103
  const groups = {};
25747
26104
  let staleCount = 0;
25748
26105
  for (const n of all) {
@@ -25766,7 +26123,8 @@ ${block}`), d);
25766
26123
  ...stale ? { stale: true } : {},
25767
26124
  ...hasLabel(n, "mandate") ? { mandate: true } : {},
25768
26125
  ...hasLabel(n, "archived") ? { archived: true } : {},
25769
- ...relations ? { relations } : {}
26126
+ ...relations ? { relations } : {},
26127
+ ...outlines.has(n.noteId) ? outlines.get(n.noteId) : {}
25770
26128
  });
25771
26129
  }
25772
26130
  const total = all.filter((n) => {
@@ -25910,7 +26268,7 @@ ${block}`), d);
25910
26268
  };
25911
26269
  server.tool("revise", `Edit a note by id. A revision is taken first; "Last updated" lines are bumped on content writes.
25912
26270
 
25913
- Modes: default append (a dated addendum \u2014 right only for records; a dated thread's append lands in today's child), mode=replace (whole body), section="<heading>" (replace that section's whole body, h2\u2192h3\u2192h4, occurrence= for repeats), section= + mode=before|after (insert a sibling block around the whole section), mode=prepend (top of the section body), mode=remove (delete the section). find="<exact stored text>" replaces every occurrence (nth= for one); edits=[{find, body}] applies several in one write. find= + within="tr" (or li, p, td, \u2026) acts on the element containing a short anchor: replace it, insert a row before/after it, or remove it, so a table row never needs its full stored markup. A replacement equal to its match writes nothing and reports unchanged. title= composes with every mode; retitling a domain book cascades its #domain slug.
26271
+ 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.
25914
26272
 
25915
26273
  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.`, {
25916
26274
  noteId: stringType().optional().describe("Note to update (omit only with domain=)"),
@@ -25925,6 +26283,10 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25925
26283
  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."),
25926
26284
  nth: numberType().int().positive().optional().describe("find=: replace only the Nth occurrence, 1-based (default: all of them). With within=: the Nth containing element"),
25927
26285
  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'),
26286
+ closure: objectType({
26287
+ thread: stringType().describe('The dated thread to record the closure in: its id or title (e.g. "Escalations")'),
26288
+ body: stringType().describe("The closure: what settled it, who ruled, and where its durable substance went")
26289
+ }).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`),
25928
26290
  edits: arrayType(objectType({
25929
26291
  find: stringType().describe("Exact raw string to replace"),
25930
26292
  body: stringType().describe("Raw replacement string"),
@@ -25933,7 +26295,9 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25933
26295
  identity: stringType().optional().describe('Appending to a dated thread: the identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" (required)'),
25934
26296
  icon: stringType().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name; normalized server-side'),
25935
26297
  date: stringType().optional().describe("ISO date (default: today)")
25936
- }, async ({ noteId: noteIdArg, domain, dryRun, body, title, section, occurrence, mode, find, nth, within, edits, identity, icon, date, strict }) => {
26298
+ }, async ({ noteId: noteIdArg, domain, dryRun, body, title, section, occurrence, mode, find, nth, within, closure, edits, identity, icon, date, strict }) => {
26299
+ if (closure && !(within && mode === "remove"))
26300
+ 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")');
25937
26301
  if (domain !== undefined) {
25938
26302
  if (noteIdArg)
25939
26303
  return err("conflicting_params", "Pass noteId= or domain=, not both.", "domain= edits every note in the domain; noteId= edits one.");
@@ -25997,11 +26361,16 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25997
26361
  const missHint = (needle, source, alreadyConsumed) => {
25998
26362
  if (alreadyConsumed)
25999
26363
  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.`;
26364
+ if (/Last updated/i.test(needle))
26365
+ 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.`;
26000
26366
  if (looksEntityEscaped(needle))
26001
26367
  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>".`;
26002
26368
  if (spansBlockBoundary(needle))
26003
26369
  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).`;
26004
- return `Not found in the note body (exact, attribute-tolerant or ignoring inline tags) \u2014 already replaced on a retry, or the text genuinely differs.`;
26370
+ const closed = closedBlockTag(needle);
26371
+ if (closed && !within)
26372
+ 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}>").`;
26373
+ 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".'}`;
26005
26374
  };
26006
26375
  const missContext = (needle, source) => {
26007
26376
  const near = nearestContext(source, needle);
@@ -26021,7 +26390,9 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26021
26390
  exact.push({ start: at, length: find.length });
26022
26391
  const rx = exact.length ? null : tolerantFindRegex(find);
26023
26392
  const tolerantHits = rx ? [...current.matchAll(rx)].map((m) => ({ start: m.index, length: m[0].length })) : [];
26024
- const hits = exact.length ? exact : tolerantHits.length ? tolerantHits : inlineTolerantSpans(current, find);
26393
+ const inlineHits = exact.length || tolerantHits.length ? [] : inlineTolerantSpans(current, find);
26394
+ const visibleHits = exact.length || tolerantHits.length || inlineHits.length ? [] : visibleTextSpans(current, find, within);
26395
+ const hits = exact.length ? exact : tolerantHits.length ? tolerantHits : inlineHits.length ? inlineHits : visibleHits;
26025
26396
  const res = editWithin(current, hits, within, action, body ?? "", nth);
26026
26397
  if (!res.ok) {
26027
26398
  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.`;
@@ -26039,6 +26410,26 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26039
26410
  }
26040
26411
  if (res.html === current)
26041
26412
  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." });
26413
+ let closureThread = null;
26414
+ let closureBlock = "";
26415
+ if (closure) {
26416
+ closureThread = /^[A-Za-z0-9]{12}$/.test(closure.thread) ? await trilium.getNote(closure.thread).catch(() => null) : await findExisting("thread", closure.thread);
26417
+ if (!closureThread || ownedLabel(closureThread, "noteType") !== "thread")
26418
+ 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".`);
26419
+ if (isCollectionThread(closureThread))
26420
+ return collectionAppendRefusal(closureThread);
26421
+ const rendered = renderBody(closure.body).html;
26422
+ const removedText = toText(res.element, 400);
26423
+ const closureHtml = `<p><strong>Closed:</strong> ${escapeHtml(removedText)}</p>
26424
+ ${rendered}`;
26425
+ if (!identity)
26426
+ return err("missing_identity", "closure= appends to a dated thread, which needs identity=; nothing was written.", 'Pass identity="LLM \xB7 environment \xB7 agent/mode".');
26427
+ const badIdentity = identityRefusal(identity, closureHtml);
26428
+ if (badIdentity)
26429
+ return badIdentity;
26430
+ closureBlock = `<h3>${escapeHtml(identity)}</h3>
26431
+ ${closureHtml}`;
26432
+ }
26042
26433
  await trilium.createRevision(noteId).catch(() => null);
26043
26434
  const withinResult = sanitizeHtml(res.html);
26044
26435
  const stampedWithin = bumpLastUpdated(withinResult.html, d);
@@ -26049,16 +26440,32 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26049
26440
  if (labelOf2(note, "status") === "dormant")
26050
26441
  await trilium.updateLabelValue(noteId, "status", "active");
26051
26442
  const relsWithin = relationSnippet(note);
26443
+ let closureReceipt = null;
26444
+ if (closureThread) {
26445
+ const entry = await appendThreadEntry(closureThread.noteId, closureBlock, d);
26446
+ await trilium.updateLabelValue(closureThread.noteId, "updated", d);
26447
+ if (labelOf2(closureThread, "status") === "dormant")
26448
+ await trilium.updateLabelValue(closureThread.noteId, "status", "active");
26449
+ closureReceipt = {
26450
+ threadId: closureThread.noteId,
26451
+ thread: closureThread.title,
26452
+ entryId: entry.noteId,
26453
+ action: entry.action,
26454
+ next: "Run consistency() on any fact the closed row asserted elsewhere, so no stale sentence survives it."
26455
+ };
26456
+ }
26052
26457
  return txt3({
26053
26458
  ok: true,
26054
26459
  noteId,
26055
26460
  mode: `within:${within}:${action}`,
26056
26461
  date: d,
26057
26462
  element: toText(res.element, 200),
26463
+ ...closureReceipt ? { closure: closureReceipt } : {},
26058
26464
  ...titledWithin.retitled ? { retitled: titledWithin.retitled } : {},
26059
26465
  ...iconWithin ? { icon: iconWithin } : {},
26060
26466
  ...relsWithin ? { relations: relsWithin } : {},
26061
26467
  ...structuralFindings(noteKind, stampedWithin.html),
26468
+ ...stampedWithin.bumped ? { lastUpdated: "bumped to today \u2014 the line is server-owned" } : {},
26062
26469
  ...withinResult.warnings.length ? { sanitized: withinResult.warnings } : {}
26063
26470
  });
26064
26471
  }
@@ -26138,6 +26545,7 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26138
26545
  ...iconApplied ? { icon: iconApplied } : {},
26139
26546
  ...rels ? { relations: rels } : {},
26140
26547
  ...structure,
26548
+ ...stamped.bumped ? { lastUpdated: "bumped to today \u2014 the line is server-owned" } : {},
26141
26549
  ...replacedResult.warnings.length ? { sanitized: replacedResult.warnings } : {}
26142
26550
  });
26143
26551
  }
@@ -26230,6 +26638,9 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26230
26638
  if (!identity && !leadingIdentification(html)) {
26231
26639
  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.`);
26232
26640
  }
26641
+ const badIdentity = identityRefusal(identity, html);
26642
+ if (badIdentity)
26643
+ return badIdentity;
26233
26644
  const block = identity && !leadingIdentification(html) ? `<h3>${escapeHtml(identity)}</h3>
26234
26645
  ${html}` : html;
26235
26646
  threadEntryResult = await appendThreadEntry(noteId, block, d);
@@ -26268,7 +26679,7 @@ ${block}`), d).html;
26268
26679
  } : {},
26269
26680
  ...sectionMiss,
26270
26681
  ...sectionHint ? { hint: sectionHint } : {},
26271
- ...threadEntryResult ? { entryId: threadEntryResult.noteId, entryAction: threadEntryResult.action } : {},
26682
+ ...threadEntryResult ? { entryId: threadEntryResult.noteId, entryAction: threadEntryResult.action, entry: `today's [${d}] entry in this thread; revise(entryId) edits it` } : {},
26272
26683
  ...titled.retitled ? { retitled: titled.retitled } : {},
26273
26684
  ...titled.cascaded ? { domainCascade: `#domain updated on ${titled.cascaded} descendant note(s)` } : {},
26274
26685
  ...structuralFindings(noteKind, finalContent),
@@ -26540,8 +26951,10 @@ calling twice is safe. Use remove=true to delete an edge.`, {
26540
26951
  }
26541
26952
  }
26542
26953
  });
26543
- 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.`, {
26544
- pattern: stringType().optional().describe("Regex over note bodies. One capture group = the value that should agree across notes. Omit when using subject= (prose mode)."),
26954
+ 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.`, {
26955
+ 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)."),
26956
+ 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"),
26957
+ caseSensitive: booleanType().optional().describe('Match case exactly (default false: "Blueprints" and "blueprints" both match)'),
26545
26958
  subject: stringType().optional().describe("A fact in prose \u2014 returns notes asserting about it however phrased (instead of pattern=)"),
26546
26959
  staleAfterDays: numberType().optional().describe("With pattern: also report values held in exactly one note untouched N+ days (staleSingles)"),
26547
26960
  domain: stringType().optional().describe("Restrict to one knowledge domain"),
@@ -26550,146 +26963,175 @@ calling twice is safe. Use remove=true to delete an edge.`, {
26550
26963
  includeArchived: booleanType().optional().describe("Include archived notes (default false)"),
26551
26964
  limit: numberType().optional().describe("Max notes to examine (default 60)"),
26552
26965
  fast: booleanType().optional().describe("Use Trilium's faster but lossy %= pre-filter (default: scan every in-scope note)")
26553
- }, async ({ pattern, subject, staleAfterDays, domain, kinds, includeRecords, includeArchived, limit, fast }) => {
26554
- const wantsRecords = includeRecords === true || (kinds ?? []).some((k) => RECORD_KINDS.has(k));
26555
- let recordsSkipped = 0;
26556
- const inScope = (n) => {
26557
- const kind = ownedLabel(n, "noteType");
26558
- if (!kind)
26559
- return false;
26560
- if (kinds?.length && !kinds.includes(kind))
26561
- return false;
26562
- if (!wantsRecords && isRecordNote(n)) {
26563
- recordsSkipped++;
26564
- return false;
26565
- }
26566
- return true;
26966
+ }, async (args) => {
26967
+ const contentCache = new Map;
26968
+ const contentOf = (id) => {
26969
+ let p = contentCache.get(id);
26970
+ if (!p) {
26971
+ p = trilium.getNoteContent(id).catch(() => "");
26972
+ contentCache.set(id, p);
26973
+ }
26974
+ return p;
26567
26975
  };
26568
- 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.` } : {};
26569
- if (!pattern && !subject)
26570
- 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.');
26571
- if (subject && !pattern) {
26976
+ if (args.patterns?.length) {
26977
+ if (args.pattern || args.subject)
26978
+ return err("conflicting_params", "Pass patterns=[\u2026] alone, not with pattern= or subject=.", "Put every pattern in the patterns array.");
26979
+ const results = [];
26980
+ for (const p of args.patterns) {
26981
+ const r = await checkOne({ ...args, pattern: p, patterns: undefined });
26982
+ results.push(JSON.parse(r.content[0].text));
26983
+ }
26984
+ const disagreeing = results.filter((r) => r.agreement === "DISAGREEMENT").length;
26985
+ return txt3({ mode: "multi", patterns: args.patterns.length, disagreeing, results });
26986
+ }
26987
+ return checkOne(args);
26988
+ async function checkOne({ pattern, subject, staleAfterDays, domain, kinds, includeRecords, includeArchived, limit, fast, caseSensitive }) {
26989
+ const wantsRecords = includeRecords === true || (kinds ?? []).some((k) => RECORD_KINDS.has(k));
26990
+ let recordsSkipped = 0;
26991
+ const inScope = (n) => {
26992
+ const kind = ownedLabel(n, "noteType");
26993
+ if (!kind)
26994
+ return false;
26995
+ if (kinds?.length && !kinds.includes(kind))
26996
+ return false;
26997
+ if (!wantsRecords && isRecordNote(n)) {
26998
+ recordsSkipped++;
26999
+ return false;
27000
+ }
27001
+ return true;
27002
+ };
27003
+ 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.` } : {};
27004
+ if (!pattern && !subject)
27005
+ 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.');
27006
+ if (subject && !pattern) {
27007
+ const max = limit ?? 60;
27008
+ const tokens = queryTokens(subject, 8);
27009
+ if (!tokens.length)
27010
+ 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.');
27011
+ const clauses = ["#noteType"];
27012
+ if (domain)
27013
+ clauses.push(`#domain='${slugify(domain)}'`);
27014
+ const notes = await trilium.searchNotes(clauses.join(" AND "), { ancestorNoteId: b().root, limit: wantsRecords ? max : max * 4, includeArchivedNotes: includeArchived ?? false }).then((r) => r.results).catch(() => []);
27015
+ const scoped = notes.filter(inScope).slice(0, max);
27016
+ const threshold = Math.max(1, Math.ceil(tokens.length / 2));
27017
+ const hits = [];
27018
+ for (const n of scoped) {
27019
+ const content = await contentOf(n.noteId);
27020
+ if (!content)
27021
+ continue;
27022
+ const text = stripTagsWithMap(content).text.toLowerCase();
27023
+ const matchedTokens = tokens.filter((t) => text.includes(t));
27024
+ if (matchedTokens.length < threshold)
27025
+ continue;
27026
+ hits.push({ id: n.noteId, title: n.title, kind: ownedLabel(n, "noteType") ?? "", matchedTokens, snippet: toText(content, 200) });
27027
+ }
27028
+ return txt3({
27029
+ mode: "subject",
27030
+ subject,
27031
+ tokens,
27032
+ threshold: `${threshold} of ${tokens.length} token(s)`,
27033
+ notesExamined: scoped.length,
27034
+ notes: hits,
27035
+ total: hits.length,
27036
+ ...domain ? { domain: slugify(domain) } : {},
27037
+ ...recordsNote(),
27038
+ 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.`
27039
+ });
27040
+ }
27041
+ if (!pattern)
27042
+ 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.');
27043
+ let re;
27044
+ const inlineInsensitive = /^\(\?i\)/.test(pattern);
27045
+ const source = inlineInsensitive ? pattern.slice(4) : pattern;
27046
+ const flags = caseSensitive && !inlineInsensitive ? "g" : "gi";
27047
+ try {
27048
+ re = new RegExp(source, flags);
27049
+ } catch (e) {
27050
+ return err("invalid_pattern", `Not a valid regular expression: ${e.message}`, "Escape backslashes \u2014 a JSON string needs \\\\d for \\d.");
27051
+ }
27052
+ const groupCount = new RegExp(`${source}|`).exec("").length - 1;
26572
27053
  const max = limit ?? 60;
26573
- const tokens = queryTokens(subject, 8);
26574
- if (!tokens.length)
26575
- 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.');
26576
- const clauses = ["#noteType"];
27054
+ const clauses = fast ? [`note.content %= '${escapeQueryRegex(pattern)}'`] : ["#noteType"];
26577
27055
  if (domain)
26578
27056
  clauses.push(`#domain='${slugify(domain)}'`);
26579
27057
  const notes = await trilium.searchNotes(clauses.join(" AND "), { ancestorNoteId: b().root, limit: wantsRecords ? max : max * 4, includeArchivedNotes: includeArchived ?? false }).then((r) => r.results).catch(() => []);
26580
27058
  const scoped = notes.filter(inScope).slice(0, max);
26581
- const threshold = Math.max(1, Math.ceil(tokens.length / 2));
26582
- const hits = [];
27059
+ const byValue = new Map;
27060
+ const noCapture = [];
27061
+ let hasCaptureGroup = false;
27062
+ const scan = (haystack) => {
27063
+ const values = [];
27064
+ let matched = false;
27065
+ re.lastIndex = 0;
27066
+ let m;
27067
+ while ((m = re.exec(haystack)) !== null) {
27068
+ matched = true;
27069
+ const captured = groupCount === 0 ? m[0] : m.slice(1).find((g) => g !== undefined);
27070
+ if (captured !== undefined && captured !== "")
27071
+ values.push(toText(captured, 120).trim() || captured.trim());
27072
+ if (m[0] === "")
27073
+ re.lastIndex++;
27074
+ }
27075
+ return { values, matched };
27076
+ };
27077
+ let tagSpanning = 0;
27078
+ const idleSince = (iso) => Math.max(0, Math.floor((Date.now() - new Date(iso.replace(" ", "T")).getTime()) / 86400000));
26583
27079
  for (const n of scoped) {
26584
- const content = await trilium.getNoteContent(n.noteId).catch(() => "");
27080
+ const content = await contentOf(n.noteId);
26585
27081
  if (!content)
26586
27082
  continue;
26587
- const text = stripTagsWithMap(content).text.toLowerCase();
26588
- const matchedTokens = tokens.filter((t) => text.includes(t));
26589
- if (matchedTokens.length < threshold)
26590
- continue;
26591
- hits.push({ id: n.noteId, title: n.title, kind: ownedLabel(n, "noteType") ?? "", matchedTokens, snippet: toText(content, 200) });
27083
+ const stub = { id: n.noteId, title: n.title, kind: ownedLabel(n, "noteType") ?? "", idleDays: idleSince(n.dateModified) };
27084
+ const raw = scan(content);
27085
+ const projected = scan(stripTagsWithMap(content).text);
27086
+ if (projected.matched && !raw.matched)
27087
+ tagSpanning++;
27088
+ const seen = new Set;
27089
+ for (const value of [...raw.values, ...projected.values]) {
27090
+ hasCaptureGroup = true;
27091
+ if (seen.has(value))
27092
+ continue;
27093
+ seen.add(value);
27094
+ if (!byValue.has(value))
27095
+ byValue.set(value, []);
27096
+ byValue.get(value).push(stub);
27097
+ }
27098
+ if ((raw.matched || projected.matched) && !seen.size)
27099
+ noCapture.push(stub);
26592
27100
  }
26593
- return txt3({
26594
- mode: "subject",
26595
- subject,
26596
- tokens,
26597
- threshold: `${threshold} of ${tokens.length} token(s)`,
26598
- notesExamined: scoped.length,
26599
- notes: hits,
26600
- total: hits.length,
26601
- ...domain ? { domain: slugify(domain) } : {},
26602
- ...recordsNote(),
26603
- 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.`
26604
- });
26605
- }
26606
- if (!pattern)
26607
- 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.');
26608
- let re;
26609
- try {
26610
- re = new RegExp(pattern, "gi");
26611
- } catch (e) {
26612
- return err("invalid_pattern", `Not a valid regular expression: ${e.message}`, "Escape backslashes \u2014 a JSON string needs \\\\d for \\d.");
26613
- }
26614
- const max = limit ?? 60;
26615
- const clauses = fast ? [`note.content %= '${escapeQueryRegex(pattern)}'`] : ["#noteType"];
26616
- if (domain)
26617
- clauses.push(`#domain='${slugify(domain)}'`);
26618
- const notes = await trilium.searchNotes(clauses.join(" AND "), { ancestorNoteId: b().root, limit: wantsRecords ? max : max * 4, includeArchivedNotes: includeArchived ?? false }).then((r) => r.results).catch(() => []);
26619
- const scoped = notes.filter(inScope).slice(0, max);
26620
- const byValue = new Map;
26621
- const noCapture = [];
26622
- let hasCaptureGroup = false;
26623
- const scan = (haystack) => {
26624
- const values = [];
26625
- let matched = false;
26626
- re.lastIndex = 0;
26627
- let m;
26628
- while ((m = re.exec(haystack)) !== null) {
26629
- matched = true;
26630
- const captured = m.slice(1).find((g) => g !== undefined);
26631
- if (captured !== undefined)
26632
- values.push(toText(captured, 120).trim() || captured.trim());
26633
- if (m[0] === "")
26634
- re.lastIndex++;
26635
- }
26636
- return { values, matched };
26637
- };
26638
- let tagSpanning = 0;
26639
- const idleSince = (iso) => Math.max(0, Math.floor((Date.now() - new Date(iso.replace(" ", "T")).getTime()) / 86400000));
26640
- for (const n of scoped) {
26641
- const content = await trilium.getNoteContent(n.noteId).catch(() => "");
26642
- if (!content)
26643
- continue;
26644
- const stub = { id: n.noteId, title: n.title, kind: ownedLabel(n, "noteType") ?? "", idleDays: idleSince(n.dateModified) };
26645
- const raw = scan(content);
26646
- const projected = scan(stripTagsWithMap(content).text);
26647
- if (projected.matched && !raw.matched)
26648
- tagSpanning++;
26649
- const seen = new Set;
26650
- for (const value of [...raw.values, ...projected.values]) {
26651
- hasCaptureGroup = true;
26652
- if (seen.has(value))
26653
- continue;
26654
- seen.add(value);
26655
- if (!byValue.has(value))
26656
- byValue.set(value, []);
26657
- byValue.get(value).push(stub);
27101
+ if (!hasCaptureGroup) {
27102
+ return txt3({
27103
+ mode: "presence",
27104
+ pattern,
27105
+ scan: fast ? "fast (%= pre-filter)" : "exhaustive",
27106
+ notesExamined: scoped.length,
27107
+ ...recordsNote(),
27108
+ notes: noCapture,
27109
+ total: noCapture.length,
27110
+ 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.'
27111
+ });
26658
27112
  }
26659
- if ((raw.matched || projected.matched) && !seen.size)
26660
- noCapture.push(stub);
26661
- }
26662
- if (!hasCaptureGroup) {
27113
+ const groups = [...byValue.entries()].map(([value, notes]) => ({ value, count: notes.length, notes })).sort((a, b) => b.count - a.count);
27114
+ 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 })) : [];
27115
+ const agrees = groups.length <= 1;
26663
27116
  return txt3({
26664
- mode: "presence",
27117
+ mode: "consistency",
26665
27118
  pattern,
27119
+ ...groupCount === 0 ? { valueFrom: "the whole match (the pattern has no capture group)" } : {},
27120
+ ...caseSensitive && !inlineInsensitive ? { caseSensitive: true } : {},
26666
27121
  scan: fast ? "fast (%= pre-filter)" : "exhaustive",
27122
+ ...domain ? { domain: slugify(domain) } : {},
26667
27123
  notesExamined: scoped.length,
26668
27124
  ...recordsNote(),
26669
- notes: noCapture,
26670
- total: noCapture.length,
26671
- 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.'
27125
+ ...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.` } : {},
27126
+ distinctValues: groups.length,
27127
+ agreement: groups.length === 0 ? "no-data" : agrees ? "unanimous" : "DISAGREEMENT",
27128
+ groups,
27129
+ ...staleAfterDays ? { staleAfterDays } : {},
27130
+ ...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.` } : {},
27131
+ ...noCapture.length ? { matchedWithoutValue: noCapture } : {},
27132
+ 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.`
26672
27133
  });
26673
27134
  }
26674
- const groups = [...byValue.entries()].map(([value, notes]) => ({ value, count: notes.length, notes })).sort((a, b) => b.count - a.count);
26675
- 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 })) : [];
26676
- const agrees = groups.length <= 1;
26677
- return txt3({
26678
- mode: "consistency",
26679
- pattern,
26680
- scan: fast ? "fast (%= pre-filter)" : "exhaustive",
26681
- ...domain ? { domain: slugify(domain) } : {},
26682
- notesExamined: scoped.length,
26683
- ...recordsNote(),
26684
- ...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.` } : {},
26685
- distinctValues: groups.length,
26686
- agreement: groups.length === 0 ? "no-data" : agrees ? "unanimous" : "DISAGREEMENT",
26687
- groups,
26688
- ...staleAfterDays ? { staleAfterDays } : {},
26689
- ...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.` } : {},
26690
- ...noCapture.length ? { matchedWithoutValue: noCapture } : {},
26691
- 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.`
26692
- });
26693
27135
  });
26694
27136
  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.`, {
26695
27137
  noteId: stringType().describe("Note to outline")
@@ -26738,7 +27180,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
26738
27180
  ...headings.length === 0 ? { note: "No h2\u2013h4 headings \u2014 section= would append a new section rather than match anything." } : {}
26739
27181
  });
26740
27182
  });
26741
- 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.`, {
27183
+ 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.`, {
26742
27184
  noteId: stringType().describe("Note to inspect"),
26743
27185
  content: booleanType().optional().describe("Include the note's raw body content (default: false)"),
26744
27186
  section: stringType().optional().describe("With content=true, return only this heading's section rather than the whole body"),
@@ -26811,11 +27253,39 @@ calling twice is safe. Use remove=true to delete an edge.`, {
26811
27253
  }
26812
27254
  const total = countIn(rawBody);
26813
27255
  const near = total === 0 ? nearestContext(rawBody, find) : null;
27256
+ const matches = [];
27257
+ const seenSpans = new Set;
27258
+ for (let at = rawBody.indexOf(find);at !== -1 && matches.length < 10; at = rawBody.indexOf(find, at + Math.max(1, find.length))) {
27259
+ let el = null;
27260
+ let tag = "";
27261
+ for (const t of ["tr", "li", "p", "blockquote", "td"]) {
27262
+ el = containingElement(rawBody, t, at, at + find.length);
27263
+ if (el) {
27264
+ tag = t;
27265
+ break;
27266
+ }
27267
+ }
27268
+ const start = el?.start ?? Math.max(0, at - 120);
27269
+ const end = el?.end ?? Math.min(rawBody.length, at + find.length + 120);
27270
+ if (seenSpans.has(start))
27271
+ continue;
27272
+ seenSpans.add(start);
27273
+ let sectionName = "(head)";
27274
+ for (const h of headings) {
27275
+ if (h.index < at)
27276
+ sectionName = h.text;
27277
+ else
27278
+ break;
27279
+ }
27280
+ const stored = rawBody.slice(start, end);
27281
+ matches.push({ section: sectionName, element: tag || "text", stored: stored.length > 600 ? `${stored.slice(0, 600)}\u2026` : stored });
27282
+ }
26814
27283
  findReport = {
26815
27284
  find,
26816
27285
  total,
26817
27286
  blocks,
26818
27287
  ...sections.length ? { sections } : {},
27288
+ ...matches.length ? { matches } : {},
26819
27289
  ...near ? { matchedUpTo: near.fragment, storedNearby: near.context } : {},
26820
27290
  ...total === 0 && !near ? { hint: "Not present, and no fragment of it is either \u2014 the string is unrelated to this note's content." } : {}
26821
27291
  };
@@ -27166,7 +27636,7 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
27166
27636
  ...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." }
27167
27637
  });
27168
27638
  });
27169
- 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.`, {
27639
+ 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.`, {
27170
27640
  deep: booleanType().optional().describe("Deep pass: stale-review + orphan/sink + structural lint + duplicate titles across Memory/Threads and Knowledge (default: false)"),
27171
27641
  dryRun: booleanType().optional().describe("Report what would change without changing it"),
27172
27642
  domain: stringType().optional().describe("Narrow the deep passes to one domain's notes (slugged server-side) \u2014 for scoped agents working a single lane"),
@@ -27180,6 +27650,15 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
27180
27650
  ...ack?.length ? { ack } : {},
27181
27651
  ...repair?.length ? { repair } : {}
27182
27652
  });
27653
+ if (deep && !domain) {
27654
+ const seals = await verifySeals(trilium, b(), localToday()).catch(() => null);
27655
+ if (seals) {
27656
+ for (const f of seals.findings) {
27657
+ 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.`);
27658
+ }
27659
+ 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";
27660
+ }
27661
+ }
27183
27662
  if (deep && !dryRun && b().sizes && Object.keys(b().sizes).length) {
27184
27663
  saveConfig(b());
27185
27664
  }
@@ -27743,6 +28222,37 @@ and every thread day-child, in time order with its identification line.`, {
27743
28222
  registerMemoryTools(server, trilium, brainRef);
27744
28223
  registerKnowledgeTools(server, trilium, brainRef);
27745
28224
  registerInsightsTools(server, trilium, brainRef);
28225
+ const registry = server._registeredTools ?? {};
28226
+ for (const name of CONTENT_WRITE_TOOLS) {
28227
+ const tool = registry[name];
28228
+ const inner = tool?.handler;
28229
+ if (!tool || typeof inner !== "function")
28230
+ continue;
28231
+ tool.handler = async (...args) => {
28232
+ const result = await inner(...args);
28233
+ if (!wroteSinceClose && wroteContent(result)) {
28234
+ await markStep("write").catch(() => null);
28235
+ wroteSinceClose = true;
28236
+ }
28237
+ return result;
28238
+ };
28239
+ }
28240
+ }
28241
+ var CONTENT_WRITE_TOOLS = ["remember", "revise", "split", "resolve", "withdraw", "recover", "forget", "label", "connect", "attach", "detach", "claim"];
28242
+ function wroteContent(result) {
28243
+ const text = result?.content?.[0]?.text;
28244
+ if (typeof text !== "string")
28245
+ return true;
28246
+ try {
28247
+ const r = JSON.parse(text);
28248
+ if (r.error || r.ok === false || r.unchanged === true || r.action === "already_written" || r.mode === "already_written")
28249
+ return false;
28250
+ if (r.replaced === 0 && !r.closure)
28251
+ return false;
28252
+ return true;
28253
+ } catch {
28254
+ return true;
28255
+ }
27746
28256
  }
27747
28257
 
27748
28258
  // src/annotations.ts
@@ -28083,10 +28593,24 @@ var asArray = (v) => {
28083
28593
  return v;
28084
28594
  }
28085
28595
  };
28596
+ var asObject = (v) => {
28597
+ if (typeof v !== "string")
28598
+ return v;
28599
+ const t = v.trim();
28600
+ if (!t.startsWith("{"))
28601
+ return v;
28602
+ try {
28603
+ const parsed = JSON.parse(t);
28604
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : v;
28605
+ } catch {
28606
+ return v;
28607
+ }
28608
+ };
28086
28609
  var COERCERS = {
28087
28610
  ZodBoolean: asBoolean,
28088
28611
  ZodNumber: asNumber,
28089
- ZodArray: asArray
28612
+ ZodArray: asArray,
28613
+ ZodObject: asObject
28090
28614
  };
28091
28615
  function coerceToolArgs(server) {
28092
28616
  const registry = server._registeredTools;
@@ -28613,7 +29137,7 @@ data: ${JSON.stringify(message)}
28613
29137
  }
28614
29138
 
28615
29139
  // src/oauth.ts
28616
- import { createHmac, randomBytes, createHash, timingSafeEqual } from "crypto";
29140
+ import { createHmac, randomBytes, createHash as createHash2, timingSafeEqual } from "crypto";
28617
29141
  import { existsSync as existsSync2, readFileSync as readFileSync2, statSync, writeFileSync as writeFileSync2 } from "fs";
28618
29142
 
28619
29143
  // src/network-security.ts
@@ -29537,7 +30061,7 @@ var tokenError = (error, description, status = 400) => new Response(JSON.stringi
29537
30061
  headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }
29538
30062
  });
29539
30063
  function refreshKey(token) {
29540
- return "sha256:" + createHash("sha256").update(token).digest("hex");
30064
+ return "sha256:" + createHash2("sha256").update(token).digest("hex");
29541
30065
  }
29542
30066
  function issueTokens(base, clientId, resource, scope) {
29543
30067
  const store = loadStore();
@@ -29583,7 +30107,7 @@ async function handleToken(req, base) {
29583
30107
  return tokenError("invalid_grant", "redirect_uri mismatch.");
29584
30108
  if (!verifier)
29585
30109
  return tokenError("invalid_request", "code_verifier is required.");
29586
- const computed = createHash("sha256").update(verifier).digest("base64url");
30110
+ const computed = createHash2("sha256").update(verifier).digest("base64url");
29587
30111
  if (computed !== record.codeChallenge)
29588
30112
  return tokenError("invalid_grant", "PKCE verification failed.");
29589
30113
  return new Response(JSON.stringify(issueTokens(base, record.clientId, record.resource, record.scope)), {
@@ -29690,7 +30214,7 @@ function createServer(origin = null) {
29690
30214
  const s = new McpServer({
29691
30215
  name: BRAND2.name,
29692
30216
  title: BRAND2.name,
29693
- version: "12.8.0",
30217
+ version: "12.9.1",
29694
30218
  description: BRAND2.description,
29695
30219
  websiteUrl: BRAND2.website,
29696
30220
  icons: brandIcons(origin)