brainllm 12.8.0 → 12.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -17351,6 +17351,9 @@ class Protocol {
17351
17351
  this.setRequestHandler(GetTaskPayloadRequestSchema, async (request, extra) => {
17352
17352
  const handleTaskResult = async () => {
17353
17353
  const taskId = request.params.taskId;
17354
+ if (!await this._taskStore.getTask(taskId, extra.sessionId)) {
17355
+ throw new McpError(ErrorCode.InvalidParams, `Task not found: ${taskId}`);
17356
+ }
17354
17357
  if (this._taskMessageQueue) {
17355
17358
  let queuedMessage;
17356
17359
  while (queuedMessage = await this._taskMessageQueue.dequeue(taskId, extra.sessionId)) {
@@ -17381,12 +17384,12 @@ class Protocol {
17381
17384
  throw new McpError(ErrorCode.InvalidParams, `Task not found: ${taskId}`);
17382
17385
  }
17383
17386
  if (!isTerminal(task.status)) {
17384
- await this._waitForTaskUpdate(taskId, extra.signal);
17387
+ await this._waitForTaskUpdate(taskId, extra.signal, extra.sessionId);
17385
17388
  return await handleTaskResult();
17386
17389
  }
17387
17390
  if (isTerminal(task.status)) {
17388
17391
  const result = await this._taskStore.getTaskResult(taskId, extra.sessionId);
17389
- this._clearTaskQueue(taskId);
17392
+ this._clearTaskQueue(taskId, extra.sessionId);
17390
17393
  return {
17391
17394
  ...result,
17392
17395
  _meta: {
@@ -17423,7 +17426,7 @@ class Protocol {
17423
17426
  throw new McpError(ErrorCode.InvalidParams, `Cannot cancel task in terminal status: ${task.status}`);
17424
17427
  }
17425
17428
  await this._taskStore.updateTaskStatus(request.params.taskId, "cancelled", "Client cancelled task execution.", extra.sessionId);
17426
- this._clearTaskQueue(request.params.taskId);
17429
+ this._clearTaskQueue(request.params.taskId, extra.sessionId);
17427
17430
  const cancelledTask = await this._taskStore.getTask(request.params.taskId, extra.sessionId);
17428
17431
  if (!cancelledTask) {
17429
17432
  throw new McpError(ErrorCode.InvalidParams, `Task not found after cancellation: ${request.params.taskId}`);
@@ -17546,6 +17549,19 @@ class Protocol {
17546
17549
  const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler;
17547
17550
  const capturedTransport = this._transport;
17548
17551
  const relatedTaskId = request.params?._meta?.[RELATED_TASK_META_KEY]?.taskId;
17552
+ const sessionId = capturedTransport?.sessionId;
17553
+ const store = this._taskStore;
17554
+ let relatedTaskFound = true;
17555
+ let relatedTaskLookup;
17556
+ if (relatedTaskId && store && this._taskMessageQueue && sessionId !== undefined) {
17557
+ relatedTaskFound = false;
17558
+ relatedTaskLookup = (async () => {
17559
+ if (!await store.getTask(relatedTaskId, sessionId)) {
17560
+ throw new McpError(ErrorCode.InvalidParams, `Task not found: ${relatedTaskId}`);
17561
+ }
17562
+ relatedTaskFound = true;
17563
+ })();
17564
+ }
17549
17565
  if (handler === undefined) {
17550
17566
  const errorResponse = {
17551
17567
  jsonrpc: "2.0",
@@ -17555,7 +17571,10 @@ class Protocol {
17555
17571
  message: "Method not found"
17556
17572
  }
17557
17573
  };
17558
- if (relatedTaskId && this._taskMessageQueue) {
17574
+ if (relatedTaskId && relatedTaskLookup) {
17575
+ const queuedError = { type: "error", message: errorResponse, timestamp: Date.now() };
17576
+ relatedTaskLookup.then(() => this._enqueueTaskMessage(relatedTaskId, queuedError, sessionId), () => capturedTransport?.send(errorResponse)).catch((error) => this._onerror(new Error(`Failed to send an error response: ${error}`)));
17577
+ } else if (relatedTaskId && this._taskMessageQueue) {
17559
17578
  this._enqueueTaskMessage(relatedTaskId, {
17560
17579
  type: "error",
17561
17580
  message: errorResponse,
@@ -17606,7 +17625,10 @@ class Protocol {
17606
17625
  closeSSEStream: extra?.closeSSEStream,
17607
17626
  closeStandaloneSSEStream: extra?.closeStandaloneSSEStream
17608
17627
  };
17609
- Promise.resolve().then(() => {
17628
+ (relatedTaskLookup ?? Promise.resolve()).then(() => {
17629
+ if (relatedTaskLookup && abortController.signal.aborted) {
17630
+ throw new McpError(ErrorCode.ConnectionClosed, "Request was cancelled");
17631
+ }
17610
17632
  if (taskCreationParams) {
17611
17633
  this.assertTaskHandlerCapability(request.method);
17612
17634
  }
@@ -17641,7 +17663,7 @@ class Protocol {
17641
17663
  ...error["data"] !== undefined && { data: error["data"] }
17642
17664
  }
17643
17665
  };
17644
- if (relatedTaskId && this._taskMessageQueue) {
17666
+ if (relatedTaskId && this._taskMessageQueue && relatedTaskFound) {
17645
17667
  await this._enqueueTaskMessage(relatedTaskId, {
17646
17668
  type: "error",
17647
17669
  message: errorResponse,
@@ -18029,7 +18051,7 @@ class Protocol {
18029
18051
  throw new Error("Cannot enqueue task message: taskStore and taskMessageQueue are not configured");
18030
18052
  }
18031
18053
  const maxQueueSize = this._options?.maxTaskQueueSize;
18032
- await this._taskMessageQueue.enqueue(taskId, message, sessionId, maxQueueSize);
18054
+ await this._taskMessageQueue.enqueue(taskId, message, sessionId ?? this._transport?.sessionId, maxQueueSize);
18033
18055
  }
18034
18056
  async _clearTaskQueue(taskId, sessionId) {
18035
18057
  if (this._taskMessageQueue) {
@@ -18048,10 +18070,10 @@ class Protocol {
18048
18070
  }
18049
18071
  }
18050
18072
  }
18051
- async _waitForTaskUpdate(taskId, signal) {
18073
+ async _waitForTaskUpdate(taskId, signal, sessionId) {
18052
18074
  let interval = this._options?.defaultTaskPollInterval ?? 1000;
18053
18075
  try {
18054
- const task = await this._taskStore?.getTask(taskId);
18076
+ const task = await this._taskStore?.getTask(taskId, sessionId);
18055
18077
  if (task?.pollInterval) {
18056
18078
  interval = task.pollInterval;
18057
18079
  }
@@ -18718,6 +18740,43 @@ class ExperimentalMcpServerTasks {
18718
18740
  }
18719
18741
  }
18720
18742
  // node_modules/@modelcontextprotocol/sdk/dist/esm/server/mcp.js
18743
+ function toolInputElementCount(value, max) {
18744
+ let count = 0;
18745
+ const stack = [value];
18746
+ while (stack.length > 0) {
18747
+ const node = stack.pop();
18748
+ if (node === null || typeof node !== "object")
18749
+ continue;
18750
+ if (Array.isArray(node)) {
18751
+ for (const child of node) {
18752
+ if (++count > max)
18753
+ return count;
18754
+ if (child !== null && typeof child === "object")
18755
+ stack.push(child);
18756
+ }
18757
+ } else {
18758
+ for (const key in node) {
18759
+ if (!Object.prototype.hasOwnProperty.call(node, key))
18760
+ continue;
18761
+ if (++count > max)
18762
+ return count;
18763
+ const child = node[key];
18764
+ if (child !== null && typeof child === "object")
18765
+ stack.push(child);
18766
+ }
18767
+ }
18768
+ }
18769
+ return count;
18770
+ }
18771
+ function resolveMaxToolInputElements(value) {
18772
+ if (value === undefined || value === Infinity)
18773
+ return;
18774
+ if (typeof value !== "number" || Number.isNaN(value) || value < 1) {
18775
+ throw new RangeError(`maxToolInputElements must be a number of at least 1, or Infinity, got ${String(value)}`);
18776
+ }
18777
+ return value;
18778
+ }
18779
+
18721
18780
  class McpServer {
18722
18781
  constructor(serverInfo, options) {
18723
18782
  this._registeredResources = {};
@@ -18729,6 +18788,7 @@ class McpServer {
18729
18788
  this._resourceHandlersInitialized = false;
18730
18789
  this._promptHandlersInitialized = false;
18731
18790
  this.server = new Server(serverInfo, options);
18791
+ this._maxToolInputElements = resolveMaxToolInputElements(options?.maxToolInputElements);
18732
18792
  }
18733
18793
  get experimental() {
18734
18794
  if (!this._experimental) {
@@ -18835,12 +18895,15 @@ class McpServer {
18835
18895
  };
18836
18896
  }
18837
18897
  async validateToolInput(tool, args, toolName) {
18898
+ if (this._maxToolInputElements !== undefined && toolInputElementCount(args, this._maxToolInputElements) > this._maxToolInputElements) {
18899
+ throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for tool ${toolName}: arguments contain more than the maximum of ${this._maxToolInputElements} elements`);
18900
+ }
18838
18901
  if (!tool.inputSchema) {
18839
18902
  return;
18840
18903
  }
18841
18904
  const inputObj = normalizeObjectSchema(tool.inputSchema);
18842
18905
  const schemaToParse = inputObj ?? tool.inputSchema;
18843
- const parseResult = await safeParseAsync2(schemaToParse, args);
18906
+ const parseResult = await safeParseAsync2(schemaToParse, args ?? {});
18844
18907
  if (!parseResult.success) {
18845
18908
  const error = "error" in parseResult ? parseResult.error : "Unknown error";
18846
18909
  const errorMessage = getParseErrorMessage(error);
@@ -19065,7 +19128,7 @@ class McpServer {
19065
19128
  }
19066
19129
  if (prompt.argsSchema) {
19067
19130
  const argsObj = normalizeObjectSchema(prompt.argsSchema);
19068
- const parseResult = await safeParseAsync2(argsObj, request.params.arguments);
19131
+ const parseResult = await safeParseAsync2(argsObj, request.params.arguments ?? {});
19069
19132
  if (!parseResult.success) {
19070
19133
  const error = "error" in parseResult ? parseResult.error : "Unknown error";
19071
19134
  const errorMessage = getParseErrorMessage(error);
@@ -21591,6 +21654,16 @@ function looksLikeHtml(body) {
21591
21654
  return true;
21592
21655
  return false;
21593
21656
  }
21657
+ function isStructuralTag(name) {
21658
+ const tag = name.toLowerCase();
21659
+ return SAFE_TAGS.has(tag) || BLOCK_ELEMENTS.has(tag);
21660
+ }
21661
+ function looksLikeStructuralHtml(body) {
21662
+ for (const m of body.matchAll(HTML_TAG))
21663
+ if (isStructuralTag(m[1]))
21664
+ return true;
21665
+ return false;
21666
+ }
21594
21667
  var ENCODED_TAG = /&lt;\/?[a-z][a-z0-9-]*(?:\s[^&<>]*?)?\s*\/?&gt;/i;
21595
21668
  function looksLikeEncodedHtml(body) {
21596
21669
  return !looksLikeHtml(body) && ENCODED_TAG.test(body);
@@ -21658,7 +21731,7 @@ function renderMarkup(raw) {
21658
21731
  const body = raw.replace(/\u0000/g, "");
21659
21732
  if (!body.trim())
21660
21733
  return { html: "<p></p>", convertedRuns: 0 };
21661
- if (looksLikeHtml(body))
21734
+ if (looksLikeStructuralHtml(body))
21662
21735
  return mixedToHtml(body);
21663
21736
  if (looksLikeEncodedHtml(body))
21664
21737
  return { html: decodeEncodedHtml(body), convertedRuns: 0 };
@@ -21747,7 +21820,7 @@ function mixedToHtml(body) {
21747
21820
  }
21748
21821
  function markdownToHtml(body, inlineHtml = false) {
21749
21822
  const kept = [];
21750
- const source = inlineHtml ? body.replace(HTML_TAG, (tag, name) => isHtmlElement(name) ? `\x00${kept.push(tag) - 1}\x00` : tag) : body;
21823
+ const source = inlineHtml ? body.replace(HTML_TAG, (tag, name) => isStructuralTag(name) ? `\x00${kept.push(tag) - 1}\x00` : tag) : body;
21751
21824
  const lines = source.replace(/\r\n/g, `
21752
21825
  `).split(`
21753
21826
  `);
@@ -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,38 @@ function unbalancedTags(html) {
22680
22805
  }
22681
22806
  return [...new Set(stack)];
22682
22807
  }
22808
+ function tableShapeIssues(html) {
22809
+ const issues = [];
22810
+ const cellSpan = (attrs) => Number(/colspan\s*=\s*"?(\d+)/i.exec(attrs ?? "")?.[1] ?? 1);
22811
+ for (const table of html.matchAll(/<table\b[^>]*>([\s\S]*?)<\/table>/gi)) {
22812
+ const rows = [...table[1].matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)].map((r) => [...r[1].matchAll(/<(t[hd])\b([^>]*)>([\s\S]*?)<\/\1>/gi)].map((c) => ({
22813
+ span: cellSpan(c[2]),
22814
+ text: decodeEntities(c[3].replace(/<[^>]+>/g, "")).trim()
22815
+ })));
22816
+ if (rows.length < 2)
22817
+ continue;
22818
+ const width = rows[0].reduce((sum, c) => sum + c.span, 0);
22819
+ for (const cells of rows.slice(1)) {
22820
+ const label = (cells[0]?.text ?? "").slice(0, 60) || "(empty row)";
22821
+ const count = cells.reduce((sum, c) => sum + c.span, 0);
22822
+ if (count !== width) {
22823
+ issues.push(`row "${label}" has ${count} cell(s), its header has ${width}`);
22824
+ continue;
22825
+ }
22826
+ const piped = cells.find((c) => (c.text.match(/\s\|\s/g)?.length ?? 0) >= Math.max(1, width - 2) && width > 1);
22827
+ if (piped && cells.filter((c) => c.text).length < width) {
22828
+ issues.push(`row "${label}" carries pipe-separated values inside one cell \u2014 send each value as its own <td>`);
22829
+ }
22830
+ }
22831
+ }
22832
+ return issues;
22833
+ }
22683
22834
  var LARGE_NOTE_CHARS = 40000;
22684
22835
  function structureReport(html) {
22685
22836
  return {
22686
22837
  duplicateHeadings: duplicateHeadings(html),
22687
22838
  unbalancedTags: unbalancedTags(html),
22839
+ tableShape: tableShapeIssues(html),
22688
22840
  size: html.length
22689
22841
  };
22690
22842
  }
@@ -23518,6 +23670,9 @@ async function sweep(trilium, cfg, opts = {}) {
23518
23670
  if (structure.unbalancedTags.length) {
23519
23671
  report.flagged.push(`unbalanced tags: ${n.title} [${n.noteId}] \u2014 <${structure.unbalancedTags.join(">, <")}> left open; the next write will auto-close them, or fix now with revise(mode=replace)`);
23520
23672
  }
23673
+ if (structure.tableShape.length) {
23674
+ report.flagged.push(`table shape: ${n.title} [${n.noteId}] \u2014 ${structure.tableShape.slice(0, 3).join("; ")}${structure.tableShape.length > 3 ? ` (+${structure.tableShape.length - 3} more)` : ""}. Rewrite the row with one <td> per column: revise(find=<anchor>, within="tr")`);
23675
+ }
23521
23676
  const profile = sectionProfile(content);
23522
23677
  const biggest = profile.largest[0];
23523
23678
  const crowded = profile.count >= SECTION_EDIT_RISK_COUNT;
@@ -23590,13 +23745,15 @@ async function sweep(trilium, cfg, opts = {}) {
23590
23745
  byDomain.get(domSlug).push(n);
23591
23746
  }
23592
23747
  const headingCache = new Map;
23593
- const headingsOf = async (n) => {
23748
+ const shapeOf = async (n) => {
23594
23749
  if (!headingCache.has(n.noteId)) {
23595
23750
  const content = await trilium.getNoteContent(n.noteId).catch(() => "");
23596
- headingCache.set(n.noteId, headingOutline(content).filter((h) => h.level <= 3).map((h) => h.text));
23751
+ const words = new Set(content.replace(/<[^>]+>/g, " ").toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 3));
23752
+ headingCache.set(n.noteId, { headings: headingOutline(content).filter((h) => h.level <= 3).map((h) => h.text), words });
23597
23753
  }
23598
23754
  return headingCache.get(n.noteId);
23599
23755
  };
23756
+ const GENERIC_HEADING = /^(last updated\b.*|overview|context|goal|sources|revision|resolution|notes?|summary)$/i;
23600
23757
  for (const [domSlug, notes] of byDomain) {
23601
23758
  const reported = new Set;
23602
23759
  for (let i = 0;i < notes.length; i++) {
@@ -23613,10 +23770,19 @@ async function sweep(trilium, cfg, opts = {}) {
23613
23770
  reported.add(pairKey);
23614
23771
  if (acknowledged(a) || acknowledged(bn))
23615
23772
  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()));
23773
+ const [sa, sb] = await Promise.all([shapeOf(a), shapeOf(bn)]);
23774
+ const ha = sa.headings, hb = sb.headings;
23775
+ const overlap = ha.filter((h) => !GENERIC_HEADING.test(h) && hb.some((x) => x.toLowerCase() === h.toLowerCase()));
23776
+ let common = 0;
23777
+ for (const w of sa.words)
23778
+ if (sb.words.has(w))
23779
+ common++;
23780
+ const union = sa.words.size + sb.words.size - common;
23781
+ const similarity = union ? common / union : 0;
23782
+ if (!overlap.length && similarity < 0.45)
23783
+ continue;
23618
23784
  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.`));
23785
+ report.flagged.push(`near-duplicate subject: '${a.title}' [${a.noteId}] and '${bn.title}' [${bn.noteId}] in Domain/${domSlug} ` + `share '${shared.join("', '")}' and ${Math.round(similarity * 100)}% of their vocabulary. Sections \u2014 '${a.title}': ${show(ha)} | '${bn.title}': ${show(hb)}. ` + (overlap.length ? `${overlap.length} heading(s) in common (${overlap.slice(0, 5).join(", ")}), which is what a genuine split subject looks like \u2014 read both, merge into the older one, and forget() the other.` : `No headings in common, but most of their wording is shared \u2014 read both; if they are distinct, maintain(ack=["${a.noteId}"]) silences this.`));
23620
23786
  }
23621
23787
  }
23622
23788
  }
@@ -24016,6 +24182,96 @@ async function catchUpDeletions(trilium, cfg, today) {
24016
24182
  return { windowDays, coverage: "full", deletionsFound: total, regenerated };
24017
24183
  }
24018
24184
 
24185
+ // src/seal.ts
24186
+ import { createHash } from "crypto";
24187
+ var GENESIS = "genesis";
24188
+ function sealText(html) {
24189
+ return decodeEntities(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
24190
+ }
24191
+ function sha(text) {
24192
+ return createHash("sha256").update(text, "utf8").digest("hex");
24193
+ }
24194
+ function sealRecord(text) {
24195
+ return [text.length, sha(text).slice(0, 16)];
24196
+ }
24197
+ function dayDigest(prev, date, records) {
24198
+ const canonical = Object.keys(records).sort().map((id) => `${id}:${records[id][0]}:${records[id][1]}`).join(",");
24199
+ return sha(`${prev}
24200
+ ${date}
24201
+ ${canonical}`);
24202
+ }
24203
+ function recordIntact(current, sealed) {
24204
+ if (current.length < sealed[0])
24205
+ return false;
24206
+ return sha(current.slice(0, sealed[0])).slice(0, 16) === sealed[1];
24207
+ }
24208
+ async function dayRecords(trilium, cfg, date) {
24209
+ const find = (ancestor, kind) => ancestor ? trilium.searchNotes(`#noteType=${kind} #created='${date}'`, { ancestorNoteId: ancestor, fastSearch: true, limit: 200, includeArchivedNotes: true }).then((r) => r.results).catch(() => []) : Promise.resolve([]);
24210
+ const [sessions, diary, entries] = await Promise.all([
24211
+ find(cfg.memory.sessions, "session"),
24212
+ find(cfg.llm.diary, "diary"),
24213
+ find(cfg.memory.threads, "threadEntry")
24214
+ ]);
24215
+ return [...sessions, ...diary, ...entries.filter((n) => n.title.trim() === `[${date}]`)];
24216
+ }
24217
+ async function sealedLogs(trilium, cfg) {
24218
+ if (!cfg.insights.logs)
24219
+ return [];
24220
+ const res = await trilium.searchNotes("#noteType=log #seal", { ancestorNoteId: cfg.insights.logs, fastSearch: true, limit: 1000, includeArchivedNotes: true }).catch(() => ({ results: [] }));
24221
+ return res.results.filter((n) => ownedLabel(n, "seal")).sort((a, b) => (ownedLabel(a, "created") ?? "").localeCompare(ownedLabel(b, "created") ?? ""));
24222
+ }
24223
+ async function sealDay(trilium, cfg, date, logNoteId) {
24224
+ const earlier = (await sealedLogs(trilium, cfg)).filter((n) => (ownedLabel(n, "created") ?? "") < date);
24225
+ const prev = earlier.length ? ownedLabel(earlier[earlier.length - 1], "seal") : GENESIS;
24226
+ const records = {};
24227
+ for (const n of await dayRecords(trilium, cfg, date)) {
24228
+ const content = await trilium.getNoteContent(n.noteId).catch(() => "");
24229
+ if (content)
24230
+ records[n.noteId] = sealRecord(sealText(content));
24231
+ }
24232
+ const digest = dayDigest(prev, date, records);
24233
+ await trilium.updateLabelValue(logNoteId, "sealRecords", JSON.stringify(records));
24234
+ await trilium.updateLabelValue(logNoteId, "sealPrev", prev);
24235
+ await trilium.updateLabelValue(logNoteId, "seal", digest);
24236
+ return { date, digest, records: Object.keys(records).length };
24237
+ }
24238
+ async function verifySeals(trilium, cfg, today, days = 7) {
24239
+ const logs = (await sealedLogs(trilium, cfg)).filter((n) => (ownedLabel(n, "created") ?? "") < today);
24240
+ const findings = [];
24241
+ const window = logs.slice(-days);
24242
+ for (const log of window) {
24243
+ const date = ownedLabel(log, "created") ?? "";
24244
+ const digest = ownedLabel(log, "seal") ?? "";
24245
+ const prev = ownedLabel(log, "sealPrev") ?? "";
24246
+ const i = logs.indexOf(log);
24247
+ const expectedPrev = i > 0 ? ownedLabel(logs[i - 1], "seal") ?? "" : prev;
24248
+ if (prev !== expectedPrev) {
24249
+ findings.push({ date, noteId: log.noteId, problem: `the chain breaks here \u2014 this seal links to ${prev.slice(0, 12)}\u2026, but the previous sealed day's digest is ${expectedPrev.slice(0, 12)}\u2026` });
24250
+ }
24251
+ let records;
24252
+ try {
24253
+ records = JSON.parse(ownedLabel(log, "sealRecords") ?? "{}");
24254
+ } catch {
24255
+ findings.push({ date, noteId: log.noteId, problem: "the sealed record list is unreadable" });
24256
+ continue;
24257
+ }
24258
+ if (dayDigest(prev, date, records) !== digest) {
24259
+ findings.push({ date, noteId: log.noteId, problem: "the sealed record list no longer matches its digest \u2014 the seal labels were edited" });
24260
+ }
24261
+ for (const [noteId, sealed] of Object.entries(records)) {
24262
+ const content = await trilium.getNoteContent(noteId).catch(() => null);
24263
+ if (content === null) {
24264
+ findings.push({ date, noteId, problem: "a sealed record is gone (deleted or unreadable)" });
24265
+ continue;
24266
+ }
24267
+ if (!recordIntact(sealText(content), sealed)) {
24268
+ findings.push({ date, noteId, problem: "a sealed record was changed after its day was sealed \u2014 its sealed text is no longer the start of its body. diff(noteId) and the note's revisions show what changed" });
24269
+ }
24270
+ }
24271
+ }
24272
+ return { checked: window.length, findings };
24273
+ }
24274
+
24019
24275
  // src/diffing.ts
24020
24276
  function revisionsSince(revisions, cutoff) {
24021
24277
  return revisions.filter((r) => (r.dateCreated ?? "").slice(0, 16) >= cutoff);
@@ -24617,6 +24873,8 @@ function registerTools(server, trilium, brainRef) {
24617
24873
  const preCloseSteps = new Map;
24618
24874
  let preCloseSeq = 0;
24619
24875
  const REQUIRED_PRECLOSE_STEPS = ["session", "addendum", "maintain", "remarks", "diary"];
24876
+ const LIGHT_PRECLOSE_STEPS = ["session", "remarks", "diary"];
24877
+ let wroteSinceClose = false;
24620
24878
  const gateNote = async (date) => {
24621
24879
  const cfg = b();
24622
24880
  if (!cfg.memory.sessions)
@@ -24675,6 +24933,7 @@ function registerTools(server, trilium, brainRef) {
24675
24933
  await trilium.updateLabelValue(noteId, "gate", "");
24676
24934
  preCloseSteps.clear();
24677
24935
  preCloseSeq = 0;
24936
+ wroteSinceClose = false;
24678
24937
  };
24679
24938
  const RECORD_KINDS = new Set(["session", "diary", "log", "threadEntry"]);
24680
24939
  const isRecordNote = (n) => {
@@ -24684,16 +24943,30 @@ function registerTools(server, trilium, brainRef) {
24684
24943
  return kind === "threadEntry" && /^\[\d{4}-\d{2}-\d{2}\]$/.test(n.title.trim());
24685
24944
  };
24686
24945
  const structuralFindings = (kind, html) => {
24687
- if (!html || kind && RECORD_KINDS.has(kind))
24946
+ if (!html)
24688
24947
  return {};
24948
+ const tableShape = tableShapeIssues(html);
24949
+ const shape = tableShape.length ? { tableShape, tableHint: 'A table row disagrees with its header \u2014 rewrite it with one <td> per column (revise(find=<anchor>, within="tr")).' } : {};
24950
+ if (kind && RECORD_KINDS.has(kind))
24951
+ return shape;
24689
24952
  const dupes = duplicateHeadings(html);
24690
24953
  if (!dupes.length)
24691
- return {};
24954
+ return shape;
24692
24955
  return {
24693
24956
  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=."
24957
+ structureHint: "The note now carries duplicated section headings \u2014 merge them with revise(section=\u2026, mode=replace), or target one specifically with occurrence=.",
24958
+ ...shape
24695
24959
  };
24696
24960
  };
24961
+ const identityRefusal = (identity, html) => {
24962
+ const text = leadingIdentification(html) ? leadingIdentityText(html) : identity;
24963
+ if (!text)
24964
+ return null;
24965
+ const problem = identityProblem(text);
24966
+ if (!problem)
24967
+ return null;
24968
+ return err("malformed_identity", `The identification line "${text.slice(0, 100)}" is malformed: ${problem}. Nothing was written.`, 'Pass identity="LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]", e.g. "Claude Opus 5.5 \xB7 Claude Code \xB7 Interactive".');
24969
+ };
24697
24970
  const applyIcon = async (noteId, icon) => {
24698
24971
  const cls = icon ? normalizeIcon(icon) : "";
24699
24972
  if (!cls) {
@@ -24846,7 +25119,7 @@ ${block}` }));
24846
25119
  });
24847
25120
  server.tool("session", `Pre-close review. Returns the six singletons as {id, lastModified} stubs (full=true inlines them), today's diary as {id, blocks, size}, the maintained notes changed today with their write counts, runs the lite maintenance sweep, and returns pending= (what each remaining close step has to do), audit= (do the singletons agree, and do the LLM singletons still serve the master ones) and next[]. Records the session step of the close gate. Idempotent.
24848
25121
 
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.`, {
25122
+ The close sequence next[] lays out: singleton updates from the session, then addendum(), maintain(), remarks(), diary() and close(). close() checks that addendum, maintain, remarks and diary ran, in the order session \u2192 remarks \u2192 diary. scope="agent" omits the singleton updates for scoped or autonomous runs. A session that wrote no content gets a light close: session() returns only the short next[] (remarks, a one-paragraph diary, close) and close() does not require addendum() or maintain().`, {
24850
25123
  date: stringType().optional().describe("ISO date YYYY-MM-DD (default: today)"),
24851
25124
  full: booleanType().optional().describe("Inline every singleton's content and the diary body (default: stubs only)"),
24852
25125
  scope: enumType(["interactive", "agent"]).optional().describe(`"agent" for a scoped/autonomous run whose brief excludes the user's personal singletons \u2014 omits those steps from next[] (default: "interactive")`),
@@ -24856,6 +25129,19 @@ The close sequence next[] lays out: singleton updates from the session, then add
24856
25129
  const cfg = b();
24857
25130
  if (!cfg.master.root || !cfg.llm.root)
24858
25131
  throw new Error("BrainLLM not bootstrapped \u2014 run bootstrap.");
25132
+ if (!(await readGate(d)).has("write")) {
25133
+ await markStep("session", d);
25134
+ return txt3({
25135
+ date: d,
25136
+ closeMode: "light",
25137
+ note: "No content was written this session, so addendum() and maintain() are not required and there is nothing to audit. If you learned something durable about the user or yourself, revise that singleton first \u2014 that makes it a full close, and session() will say so when re-run.",
25138
+ next: [
25139
+ "Call remarks() \u2014 one short cue for a short session.",
25140
+ "Call diary() \u2014 one short paragraph: what the session was and anything worth the team knowing.",
25141
+ "Call close() \u2014 commit the session log."
25142
+ ]
25143
+ });
25144
+ }
24859
25145
  const fetchSingleton = async (id) => {
24860
25146
  if (!full) {
24861
25147
  const note = await trilium.getNote(id);
@@ -24957,6 +25243,13 @@ The close sequence next[] lays out: singleton updates from the session, then add
24957
25243
  if (!cfg.root)
24958
25244
  return txt3({ status: "uninitialized", action: "Run bootstrap first." });
24959
25245
  await markStep("remarks");
25246
+ if (!(await readGate(localToday())).has("write")) {
25247
+ return txt3({
25248
+ closeMode: "light",
25249
+ cue: "One short paragraph, first person: what this session was, how it went, and anything about BrainLLM worth the team knowing. Skip what has nothing real to say.",
25250
+ next: ["Write it via diary(body, identity), then call close()."]
25251
+ });
25252
+ }
24960
25253
  return txt3({
24961
25254
  cues: {
24962
25255
  experience: [
@@ -24977,7 +25270,7 @@ The close sequence next[] lays out: singleton updates from the session, then add
24977
25270
  ]
24978
25271
  });
24979
25272
  });
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.`, {
25273
+ server.tool("close", `Writes the session log, normally the last call of a session. Refuses unless session(), addendum(), maintain(), remarks() and diary() ran and session \u2192 remarks \u2192 diary held; a session that wrote no content (light close) needs only session(), remarks() and diary() (force=true bypasses a step with genuinely nothing to do; bypassed steps are reported). identity= is required. Writes a timestamped block to today's [yyyy-mm-dd] session note (title= becomes its heading), regenerates the daily log, backs up the database and resets the gate. continuing=true is a second close the same day.`, {
24981
25274
  summary: stringType().describe("What happened this session \u2014 factual, concise prose"),
24982
25275
  title: stringType().optional().describe("Short session title \u2014 appears as an <h2> heading above Summary"),
24983
25276
  identity: stringType().describe(`Identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" \u2014 rendered as the block's h3 (required)`),
@@ -25000,9 +25293,11 @@ The close sequence next[] lays out: singleton updates from the session, then add
25000
25293
  }
25001
25294
  }
25002
25295
  const gate = await readGate(gateDate);
25003
- const missing = continued ? [] : REQUIRED_PRECLOSE_STEPS.filter((step) => !gate.has(step));
25296
+ const light = !gate.has("write");
25297
+ const required = light ? LIGHT_PRECLOSE_STEPS : REQUIRED_PRECLOSE_STEPS;
25298
+ const missing = continued ? [] : required.filter((step) => !gate.has(step));
25004
25299
  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.`);
25300
+ return err("preclose_incomplete", `close() refused \u2014 these pre-close steps haven't run yet this session: ${missing.join(", ")}.${light ? " (No content was written this session, so addendum() and maintain() are not required.)" : ""}`, `Call ${missing.map((s) => `${s}()`).join(", ")} first, or pass force=true if one of them genuinely has nothing to log.`);
25006
25301
  }
25007
25302
  const seq = (step) => gate.get(step) ?? 0;
25008
25303
  const orderOk = continued || missing.length > 0 || seq("session") < seq("remarks") && seq("remarks") < seq("diary");
@@ -25018,6 +25313,9 @@ The close sequence next[] lays out: singleton updates from the session, then add
25018
25313
  if (!identity && !leadingIdentification(summaryHtml)) {
25019
25314
  return err("missing_identity", 'Session addendums open with the canonical identification line (h3): "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]".', `Pass identity="Claude \u2026 \xB7 <environment> \xB7 <agent/mode>" on close() \u2014 the server renders it as the block's h3.`);
25020
25315
  }
25316
+ const badIdentity = identityRefusal(identity, summaryHtml);
25317
+ if (badIdentity)
25318
+ return badIdentity;
25021
25319
  const identityBlock = identity && !leadingIdentification(summaryHtml) ? `<h3>${escapeHtml(identity)}</h3>
25022
25320
  ` : "";
25023
25321
  const titleBlock = title ? `<h2>${escapeHtml(title)}</h2>
@@ -25072,6 +25370,7 @@ ${contentBlock}` }));
25072
25370
  if (!hasEdge(logNote, noteId))
25073
25371
  await trilium.addRelation(logReport.noteId, "references", noteId).catch(() => null);
25074
25372
  }
25373
+ const sealReport = logReport?.noteId ? await sealDay(trilium, cfg, d, logReport.noteId).catch(() => null) : null;
25075
25374
  let backupStatus = "disabled";
25076
25375
  let backupName = rollingBackupName(d);
25077
25376
  if (backup !== false) {
@@ -25093,6 +25392,8 @@ ${contentBlock}` }));
25093
25392
  ...caughtUp?.coverage === "unknown" ? { deletionCatchUp: "unknown \u2014 Trilium's deletion history could not be read; the configured catch-up window is not fully verified" } : caughtUp && caughtUp.deletionsFound ? { deletionCatchUp: `${caughtUp.deletionsFound} deletion(s) caught up \u2014 logs regenerated for: ${caughtUp.regenerated.join(", ")}` } : {},
25094
25393
  ...iconSet ? { icon: iconSet } : {},
25095
25394
  ...continued ? { continuing: true } : {},
25395
+ ...sealReport ? { seal: `${sealReport.records} record(s) of ${d} sealed (${sealReport.digest.slice(0, 12)}\u2026), chained to the previous sealed day` } : { seal: "not written \u2014 the day's log note was unavailable" },
25396
+ ...light && !continued ? { closeMode: "light \u2014 no content was written this session, so addendum() and maintain() were not required" } : {},
25096
25397
  ...missing.length || !orderOk ? { bypassed: [...missing, ...!orderOk ? ["ordering(session\u2192remarks\u2192diary)"] : []] } : {},
25097
25398
  ...warnings.length ? { sanitized: warnings } : {}
25098
25399
  });
@@ -25190,6 +25491,9 @@ ${contentBlock}` }));
25190
25491
  if (!identity && !leadingIdentification(sanitized.html)) {
25191
25492
  return err("missing_identity", 'Diary addendums open with the canonical identification line (h3): "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]".', `Pass identity="Claude \u2026 \xB7 <environment> \xB7 <agent/mode>" on diary() \u2014 the server renders it as the block's h3.`);
25192
25493
  }
25494
+ const badIdentity = identityRefusal(identity, sanitized.html);
25495
+ if (badIdentity)
25496
+ return badIdentity;
25193
25497
  const html = identity && !leadingIdentification(sanitized.html) ? `<h3>${escapeHtml(identity)}</h3>
25194
25498
  ${sanitized.html}` : sanitized.html;
25195
25499
  const found = await trilium.searchNotes(`#noteType=diary #created='${d}'`, { ancestorNoteId: parentId, fastSearch: true, limit: 1 });
@@ -25372,7 +25676,8 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25372
25676
  ...createdDomain ? { createdDomain: domainTitle } : {},
25373
25677
  ...revisionChanges.length ? { revision: revisionChanges } : {},
25374
25678
  ...sourceMerge,
25375
- ...revisionKeys.length ? { revisionRows: revisionKeys } : {},
25679
+ ...revisionKeys.length ? { revisionRows: revisionKeys.length } : {},
25680
+ ...revisionChanges.some((c) => c.startsWith("added:")) && revisionKeys.length ? { revisionKeys, revisionKeysNote: "A new Revision row was added. If it was meant to update an existing source, its key must match one of these exactly." } : {},
25376
25681
  ...placeholderLeft ? { structureHint: "The Revision table still holds only its placeholder row. Every source marked \u2705 was verified by someone \u2014 record that with revision=[{source, marker, date}] so the table says so too; marker dates live there, never inline." } : {},
25377
25682
  ...looseProse ? { placementHint: "The body was appended to the Sources section as a loose block, which is rarely what a maintained source list wants. Fold it into the existing entries with revise(noteId, find=\u2026) \u2014 or pass list markup so it joins the list structurally." } : {},
25378
25683
  ...connected.length ? { connected } : {},
@@ -25470,6 +25775,9 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
25470
25775
  if (kind === "thread" && !identity && !leadingIdentification(html)) {
25471
25776
  return err("missing_identity", 'Thread addendums open with the canonical identification line (h3): "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]".', `Pass identity="Claude \u2026 \xB7 <environment> \xB7 <agent/mode>" \u2014 the server renders it as the addendum's h3.`);
25472
25777
  }
25778
+ const badIdentity = identityRefusal(identity, html);
25779
+ if (badIdentity)
25780
+ return badIdentity;
25473
25781
  const block = identity && !leadingIdentification(html) ? `<h3>${escapeHtml(identity)}</h3>
25474
25782
  ${html}` : html;
25475
25783
  if (kind === "thread") {
@@ -25489,7 +25797,7 @@ ${html}` : html;
25489
25797
  const connected = await wireRequested(existing.noteId);
25490
25798
  const iconSet = await applyIcon(existing.noteId, icon);
25491
25799
  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 } : {} });
25800
+ return txt3({ action: "updated", noteId: existing.noteId, entryId: entry.noteId, entryAction: entry.action, entry: `today's [${d}] entry in this thread; revise(entryId) edits it`, kind, title: existing.title, ...connected.length ? { connected } : {}, ...iconSet ? { icon: iconSet } : {}, ...relations ? { relations } : {}, ...sanitizeWarnings.length ? { sanitized: sanitizeWarnings } : {} });
25493
25801
  }
25494
25802
  const current = await trilium.getNoteContent(existing.noteId);
25495
25803
  if (isDuplicateAppend(current, block))
@@ -25553,7 +25861,7 @@ ${block}`), d);
25553
25861
  ...sanitizeWarnings.length ? { sanitized: sanitizeWarnings } : {}
25554
25862
  });
25555
25863
  });
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.`, {
25864
+ server.tool("recall", `Ranked search across the whole brain \u2014 label, title and full-text strategies merged, with kind and status. Pass domain= whenever you know the area. orderBy/orderDirection sort by date; fastSearch= scans titles and labels only; regex= matches note bodies with a real regular expression; a fuzzy pass runs automatically when exact results are thin (hits marked fuzzy: true are leads, not answers). Recently changed notes rank slightly higher at equal relevance. Regex hits carry evidence: the matched passage; records are included unless kinds= narrows. includeArchived= includes archived notes. A thin result is evidence about the query, not the brain.`, {
25557
25865
  query: stringType().describe("What to find \u2014 natural phrasing is fine"),
25558
25866
  kinds: arrayType(enumType(Kinds)).optional().describe("Restrict to these kinds"),
25559
25867
  domain: stringType().optional().describe("Restrict to a knowledge domain"),
@@ -25616,10 +25924,21 @@ ${block}`), d);
25616
25924
  } catch (e) {
25617
25925
  return err("invalid_pattern", `Not a valid regular expression: ${e.message}`, "Escape backslashes \u2014 a JSON string needs \\\\d for \\d.");
25618
25926
  }
25619
- const candidates = (await run(`note.content %= '${escapeQueryRegex(regex)}'`)).filter(filterNote);
25927
+ const backendPattern = triliumSearchForm(regex);
25928
+ const widened = backendPattern !== regex;
25929
+ const candidates = (await run(`note.content %= '${escapeQueryRegex(backendPattern)}'`)).filter(filterNote);
25620
25930
  const confirmed = [];
25621
25931
  let rejected = 0;
25622
25932
  let tagSpanning = 0;
25933
+ let recordHits = 0;
25934
+ const evidenceIn = (text) => {
25935
+ const m = new RegExp(re.source, "i").exec(text);
25936
+ if (!m)
25937
+ return "";
25938
+ const from = Math.max(0, m.index - 80);
25939
+ const to = Math.min(text.length, m.index + m[0].length + 80);
25940
+ return `${from > 0 ? "\u2026" : ""}${text.slice(from, to).replace(/\s+/g, " ").trim()}${to < text.length ? "\u2026" : ""}`;
25941
+ };
25623
25942
  for (const n of candidates) {
25624
25943
  if (confirmed.length >= max)
25625
25944
  break;
@@ -25629,18 +25948,28 @@ ${block}`), d);
25629
25948
  continue;
25630
25949
  }
25631
25950
  const rawHit = re.test(content);
25632
- const projectedHit = !rawHit && re.test(stripTagsWithMap(content).text);
25951
+ const projected = stripTagsWithMap(content).text;
25952
+ const projectedHit = re.test(projected);
25633
25953
  if (rawHit || projectedHit) {
25634
- if (projectedHit)
25954
+ if (projectedHit && !rawHit)
25635
25955
  tagSpanning++;
25636
- confirmed.push(n);
25956
+ const kind = labelOf2(n, "noteType");
25957
+ if (kind === "session" || kind === "diary" || kind === "log" || kind === "threadEntry" && /^\[\d{4}-\d{2}-\d{2}\]$/.test(n.title.trim()))
25958
+ recordHits++;
25959
+ confirmed.push({ note: n, evidence: decodeEntities(projectedHit ? evidenceIn(projected) : evidenceIn(toText(content, 1e5))) });
25637
25960
  } else
25638
25961
  rejected++;
25639
25962
  }
25640
- const results = await Promise.all(confirmed.map(buildResult));
25963
+ const results = await Promise.all(confirmed.map(async ({ note, evidence }, i) => {
25964
+ const { snippet: _snippet, ...base } = await buildResult(note, i);
25965
+ return { ...base, ...evidence ? { evidence } : {} };
25966
+ }));
25641
25967
  const notes = [
25642
25968
  rejected ? `${rejected} backend candidate(s) did not actually match the pattern and were dropped \u2014 results are verified against the real regex, not just the search index.` : null,
25643
25969
  tagSpanning ? `${tagSpanning} match(es) were found only after stripping markup \u2014 the phrase is split by an inline tag there.` : null,
25970
+ widened ? "Diacritic characters (\xB7, accents) were normalised away for Trilium's pre-filter, which strips them from note text before matching; every result was then verified against your exact pattern." : null,
25971
+ recordHits ? `${recordHits} result(s) are records (sessions, diary, dated thread entries) \u2014 included by default; kinds= narrows to maintained notes.` : null,
25972
+ candidates.length >= 30 && confirmed.length < max ? "The backend returned its 30-candidate cap, so further matches may exist \u2014 narrow with domain= or kinds=, or use consistency() for an exhaustive scan." : null,
25644
25973
  results.length === 0 ? "No bodies matched that pattern, searched both as stored HTML and tag-stripped. Note that Trilium's %= pre-filter reads a striptags'd copy, so a pattern anchored ON tags may never reach verification \u2014 consistency() scans exhaustively if you need certainty." : null,
25645
25974
  results.length === 0 && regex.includes("\\") ? `The pattern reached the server as /${regex}/i. In it, \\d is a digit and \\\\ is one literal backslash (a Windows path separator); a backslash doubled once too often matches a literal backslash instead of escaping. If that is not the pattern you meant, adjust the escaping and retry.` : null
25646
25975
  ].filter(Boolean);
@@ -25701,7 +26030,12 @@ ${block}`), d);
25701
26030
  fuzzyIds.add(n.noteId);
25702
26031
  add(byFuzzy, 0.5);
25703
26032
  }
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);
26033
+ const now = Date.now();
26034
+ const recency = (n) => {
26035
+ const age = Math.max(0, (now - new Date(n.dateModified.replace(" ", "T")).getTime()) / 86400000);
26036
+ return Number.isFinite(age) ? 0.9 * Math.pow(0.5, age / 14) : 0;
26037
+ };
26038
+ const ranked = [...scores.values()].filter(({ note }) => filterNote(note)).map((s) => ({ ...s, rank: s.score + recency(s.note) })).sort((a, b2) => b2.rank - a.rank || (a.note.dateModified < b2.note.dateModified ? 1 : -1)).slice(0, max);
25705
26039
  const results = await Promise.all(ranked.map(async ({ note }, i) => {
25706
26040
  const row = await buildResult(note, i);
25707
26041
  return fuzzyIds.has(note.noteId) ? { ...row, fuzzy: true } : row;
@@ -25713,10 +26047,11 @@ ${block}`), d);
25713
26047
  ...results.length === 0 ? noMatch : {}
25714
26048
  });
25715
26049
  });
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.`, {
26050
+ server.tool("domain", `Everything for a domain, topic or project: its knowledge book (if any) plus every note carrying a matching #domain or #topic slug, grouped by kind, each with idle days and a stale flag; outline=true adds every maintained note's headings and size. The reliable retrieval path for an area; use recall() for keyword search.`, {
25717
26051
  name: stringType().describe("Domain, topic, or project name"),
25718
- includeArchived: booleanType().optional().describe("Include archived/resolved items (default: false)")
25719
- }, async ({ name, includeArchived }) => {
26052
+ includeArchived: booleanType().optional().describe("Include archived/resolved items (default: false)"),
26053
+ outline: booleanType().optional().describe("Also return each maintained note's headings (h2\u2013h4) and size \u2014 plan a multi-note rewrite in one call instead of one outline() per note")
26054
+ }, async ({ name, includeArchived, outline }) => {
25720
26055
  const cfg = b();
25721
26056
  const slug = slugify(name);
25722
26057
  const CAP = 500;
@@ -25743,6 +26078,19 @@ ${block}`), d);
25743
26078
  const staleAfter = cfg.policy.staleAfterDays;
25744
26079
  const idleSince = (iso) => Math.max(0, Math.floor((Date.now() - new Date(iso.replace(" ", "T")).getTime()) / 86400000));
25745
26080
  const RECORD_ROWS = new Set(["session", "diary", "log", "threadEntry"]);
26081
+ const outlines = new Map;
26082
+ if (outline) {
26083
+ await Promise.all(all.filter((n) => {
26084
+ const k = ownedLabel(n, "noteType");
26085
+ return k && k !== "domain" && !RECORD_ROWS.has(k);
26086
+ }).map(async (n) => {
26087
+ const content = await trilium.getNoteContent(n.noteId).catch(() => "");
26088
+ outlines.set(n.noteId, {
26089
+ headings: headingOutline(content).filter((h) => !/^Last updated\b/i.test(h.text)).map((h) => `${"#".repeat(h.level - 1)} ${h.text}`),
26090
+ size: content.length
26091
+ });
26092
+ }));
26093
+ }
25746
26094
  const groups = {};
25747
26095
  let staleCount = 0;
25748
26096
  for (const n of all) {
@@ -25766,7 +26114,8 @@ ${block}`), d);
25766
26114
  ...stale ? { stale: true } : {},
25767
26115
  ...hasLabel(n, "mandate") ? { mandate: true } : {},
25768
26116
  ...hasLabel(n, "archived") ? { archived: true } : {},
25769
- ...relations ? { relations } : {}
26117
+ ...relations ? { relations } : {},
26118
+ ...outlines.has(n.noteId) ? outlines.get(n.noteId) : {}
25770
26119
  });
25771
26120
  }
25772
26121
  const total = all.filter((n) => {
@@ -25910,7 +26259,7 @@ ${block}`), d);
25910
26259
  };
25911
26260
  server.tool("revise", `Edit a note by id. A revision is taken first; "Last updated" lines are bumped on content writes.
25912
26261
 
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.
26262
+ Modes: default append (a dated addendum \u2014 right only for records; a dated thread's append lands in today's child), mode=replace (whole body), section="<heading>" (replace that section's whole body, h2\u2192h3\u2192h4, occurrence= for repeats), section= + mode=before|after (insert a sibling block around the whole section), mode=prepend (top of the section body), mode=remove (delete the section). find="<exact stored text>" replaces every occurrence (nth= for one); edits=[{find, body}] applies several in one write. find= + within="tr" (or li, p, td, \u2026) acts on the element containing a short anchor: replace it, insert a row before/after it, or remove it, so a table row never needs its full stored markup. within= names the element acted on: within="tr" replaces the whole row (body is a full <tr>\u2026</tr>), within="td" one cell (body is a <td>\u2026</td>), and a plain find= inside a cell replaces only that text. Keep anchors short; quotes match in any form, and within= also matches an element by its visible text ("Phase 2 | Claude | Open" for a row). closure={thread, body} with within="tr" and mode="remove" closes a register row: the row is removed and the closure appended to that thread's entry for today, in one call. "Last updated" lines are server-owned: every content write bumps them, so never find= on one. A replacement equal to its match writes nothing and reports unchanged. title= composes with every mode; retitling a domain book cascades its #domain slug.
25914
26263
 
25915
26264
  Check the receipt: matched=false means a NEW section was written (available= lists real headings; strict=true refuses instead). A section replace swaps everything under the heading \u2014 use find= for anything smaller. find= matches stored HTML (tags literal), not rendered text, and is taken literally: a Windows path's backslash is one backslash, never doubled.`, {
25916
26265
  noteId: stringType().optional().describe("Note to update (omit only with domain=)"),
@@ -25925,6 +26274,10 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25925
26274
  find: stringType().optional().describe("Exact raw string to replace throughout the body with body= \u2014 targeted surgery without a read+full-replace. Takes precedence over section/mode."),
25926
26275
  nth: numberType().int().positive().optional().describe("find=: replace only the Nth occurrence, 1-based (default: all of them). With within=: the Nth containing element"),
25927
26276
  within: enumType(WITHIN_TAGS).optional().describe('With find=: act on the <tag> element that contains the find text (a short unique anchor) \u2014 mode="replace" (default) swaps it for body=, "before"/"after" insert body= as a sibling (add a table row), "remove" deletes it'),
26277
+ closure: objectType({
26278
+ thread: stringType().describe('The dated thread to record the closure in: its id or title (e.g. "Escalations")'),
26279
+ body: stringType().describe("The closure: what settled it, who ruled, and where its durable substance went")
26280
+ }).optional().describe(`With within= and mode="remove": close a register row in one call. The row is removed and body= is appended to the thread's entry for today, quoting the removed row. Needs identity=. Run consistency() afterwards on any fact the row asserted elsewhere`),
25928
26281
  edits: arrayType(objectType({
25929
26282
  find: stringType().describe("Exact raw string to replace"),
25930
26283
  body: stringType().describe("Raw replacement string"),
@@ -25933,7 +26286,9 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25933
26286
  identity: stringType().optional().describe('Appending to a dated thread: the identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" (required)'),
25934
26287
  icon: stringType().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name; normalized server-side'),
25935
26288
  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 }) => {
26289
+ }, async ({ noteId: noteIdArg, domain, dryRun, body, title, section, occurrence, mode, find, nth, within, closure, edits, identity, icon, date, strict }) => {
26290
+ if (closure && !(within && mode === "remove"))
26291
+ return err("conflicting_params", 'closure= closes a register row and needs within= with mode="remove"; nothing was written.', 'e.g. revise(noteId, find="<row anchor>", within="tr", mode="remove", closure={thread: "Escalations", body: "Closed on \u2026"}, identity="\u2026")');
25937
26292
  if (domain !== undefined) {
25938
26293
  if (noteIdArg)
25939
26294
  return err("conflicting_params", "Pass noteId= or domain=, not both.", "domain= edits every note in the domain; noteId= edits one.");
@@ -25997,11 +26352,16 @@ Check the receipt: matched=false means a NEW section was written (available= lis
25997
26352
  const missHint = (needle, source, alreadyConsumed) => {
25998
26353
  if (alreadyConsumed)
25999
26354
  return `Not found \u2014 but an EARLIER edit in this same edits= array already replaced this exact string. The array is applied in order against one body, so the second pass had nothing left to match. This is the expected result, not a failure.`;
26355
+ if (/Last updated/i.test(needle))
26356
+ return `Not found \u2014 "Last updated" lines are server-owned and every content write bumps them to today, so this one most likely already moved. Leave it out of find=; the server keeps it current.`;
26000
26357
  if (looksEntityEscaped(needle))
26001
26358
  return `Not found \u2014 the search string carries escaped markup (&lt;\u2026&gt;) while note bodies store real tags. Pass the tag literally, e.g. "<h3>Typography</h3>".`;
26002
26359
  if (spansBlockBoundary(needle))
26003
26360
  return `Not found \u2014 this string spans an element boundary (a closing tag followed by an opening one). Anchor the find INSIDE a single element instead, or target the heading directly with section= (with mode="before"/"after" to insert around it).`;
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.`;
26361
+ const closed = closedBlockTag(needle);
26362
+ if (closed && !within)
26363
+ return `Not found \u2014 the string ends inside a closing </${closed}>, and where the editor stored that tag decides whether it matches. Use within="${closed}" with a short plain-text anchor from inside the element instead: revise(noteId, find="<a few words>", within="${closed}", body="<${closed}>\u2026</${closed}>").`;
26364
+ return `Not found in the note body (exact, attribute-tolerant, ignoring inline tags${within ? `, or by the visible text of a <${within}>` : ""}) \u2014 already replaced on a retry, or the text genuinely differs.${within ? "" : ' To act on a whole table row by its visible text, use within="tr".'}`;
26005
26365
  };
26006
26366
  const missContext = (needle, source) => {
26007
26367
  const near = nearestContext(source, needle);
@@ -26021,7 +26381,9 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26021
26381
  exact.push({ start: at, length: find.length });
26022
26382
  const rx = exact.length ? null : tolerantFindRegex(find);
26023
26383
  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);
26384
+ const inlineHits = exact.length || tolerantHits.length ? [] : inlineTolerantSpans(current, find);
26385
+ const visibleHits = exact.length || tolerantHits.length || inlineHits.length ? [] : visibleTextSpans(current, find, within);
26386
+ const hits = exact.length ? exact : tolerantHits.length ? tolerantHits : inlineHits.length ? inlineHits : visibleHits;
26025
26387
  const res = editWithin(current, hits, within, action, body ?? "", nth);
26026
26388
  if (!res.ok) {
26027
26389
  const hint = res.reason === "no_match" ? missHint(find, current, false) : res.reason === "no_element" ? `The anchor matched ${res.matches} time(s), but never inside a <${within}>. Check the tag, or drop within= to edit the text itself.` : `The anchor sits in ${res.matches} different <${within}> elements. Lengthen it until it is unique, or pass nth= (1-based, in document order) \u2014 elements[] previews them.`;
@@ -26039,6 +26401,26 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26039
26401
  }
26040
26402
  if (res.html === current)
26041
26403
  return txt3({ ok: true, noteId, mode: `within:${within}:${action}`, unchanged: true, date: d, note: "The body equals the element it would replace, so nothing was written and no revision was taken." });
26404
+ let closureThread = null;
26405
+ let closureBlock = "";
26406
+ if (closure) {
26407
+ closureThread = /^[A-Za-z0-9]{12}$/.test(closure.thread) ? await trilium.getNote(closure.thread).catch(() => null) : await findExisting("thread", closure.thread);
26408
+ if (!closureThread || ownedLabel(closureThread, "noteType") !== "thread")
26409
+ return err("not_found", `closure.thread "${closure.thread}" is not a thread; nothing was written.`, `Pass the thread's id or exact title, e.g. "Escalations".`);
26410
+ if (isCollectionThread(closureThread))
26411
+ return collectionAppendRefusal(closureThread);
26412
+ const rendered = renderBody(closure.body).html;
26413
+ const removedText = toText(res.element, 400);
26414
+ const closureHtml = `<p><strong>Closed:</strong> ${escapeHtml(removedText)}</p>
26415
+ ${rendered}`;
26416
+ if (!identity)
26417
+ return err("missing_identity", "closure= appends to a dated thread, which needs identity=; nothing was written.", 'Pass identity="LLM \xB7 environment \xB7 agent/mode".');
26418
+ const badIdentity = identityRefusal(identity, closureHtml);
26419
+ if (badIdentity)
26420
+ return badIdentity;
26421
+ closureBlock = `<h3>${escapeHtml(identity)}</h3>
26422
+ ${closureHtml}`;
26423
+ }
26042
26424
  await trilium.createRevision(noteId).catch(() => null);
26043
26425
  const withinResult = sanitizeHtml(res.html);
26044
26426
  const stampedWithin = bumpLastUpdated(withinResult.html, d);
@@ -26049,16 +26431,32 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26049
26431
  if (labelOf2(note, "status") === "dormant")
26050
26432
  await trilium.updateLabelValue(noteId, "status", "active");
26051
26433
  const relsWithin = relationSnippet(note);
26434
+ let closureReceipt = null;
26435
+ if (closureThread) {
26436
+ const entry = await appendThreadEntry(closureThread.noteId, closureBlock, d);
26437
+ await trilium.updateLabelValue(closureThread.noteId, "updated", d);
26438
+ if (labelOf2(closureThread, "status") === "dormant")
26439
+ await trilium.updateLabelValue(closureThread.noteId, "status", "active");
26440
+ closureReceipt = {
26441
+ threadId: closureThread.noteId,
26442
+ thread: closureThread.title,
26443
+ entryId: entry.noteId,
26444
+ action: entry.action,
26445
+ next: "Run consistency() on any fact the closed row asserted elsewhere, so no stale sentence survives it."
26446
+ };
26447
+ }
26052
26448
  return txt3({
26053
26449
  ok: true,
26054
26450
  noteId,
26055
26451
  mode: `within:${within}:${action}`,
26056
26452
  date: d,
26057
26453
  element: toText(res.element, 200),
26454
+ ...closureReceipt ? { closure: closureReceipt } : {},
26058
26455
  ...titledWithin.retitled ? { retitled: titledWithin.retitled } : {},
26059
26456
  ...iconWithin ? { icon: iconWithin } : {},
26060
26457
  ...relsWithin ? { relations: relsWithin } : {},
26061
26458
  ...structuralFindings(noteKind, stampedWithin.html),
26459
+ ...stampedWithin.bumped ? { lastUpdated: "bumped to today \u2014 the line is server-owned" } : {},
26062
26460
  ...withinResult.warnings.length ? { sanitized: withinResult.warnings } : {}
26063
26461
  });
26064
26462
  }
@@ -26138,6 +26536,7 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26138
26536
  ...iconApplied ? { icon: iconApplied } : {},
26139
26537
  ...rels ? { relations: rels } : {},
26140
26538
  ...structure,
26539
+ ...stamped.bumped ? { lastUpdated: "bumped to today \u2014 the line is server-owned" } : {},
26141
26540
  ...replacedResult.warnings.length ? { sanitized: replacedResult.warnings } : {}
26142
26541
  });
26143
26542
  }
@@ -26230,6 +26629,9 @@ Check the receipt: matched=false means a NEW section was written (available= lis
26230
26629
  if (!identity && !leadingIdentification(html)) {
26231
26630
  return err("missing_identity", 'Thread addendums open with the canonical identification line (h3): "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]".', `Pass identity="Claude \u2026 \xB7 <environment> \xB7 <agent/mode>" \u2014 the server renders it as the addendum's h3.`);
26232
26631
  }
26632
+ const badIdentity = identityRefusal(identity, html);
26633
+ if (badIdentity)
26634
+ return badIdentity;
26233
26635
  const block = identity && !leadingIdentification(html) ? `<h3>${escapeHtml(identity)}</h3>
26234
26636
  ${html}` : html;
26235
26637
  threadEntryResult = await appendThreadEntry(noteId, block, d);
@@ -26268,7 +26670,7 @@ ${block}`), d).html;
26268
26670
  } : {},
26269
26671
  ...sectionMiss,
26270
26672
  ...sectionHint ? { hint: sectionHint } : {},
26271
- ...threadEntryResult ? { entryId: threadEntryResult.noteId, entryAction: threadEntryResult.action } : {},
26673
+ ...threadEntryResult ? { entryId: threadEntryResult.noteId, entryAction: threadEntryResult.action, entry: `today's [${d}] entry in this thread; revise(entryId) edits it` } : {},
26272
26674
  ...titled.retitled ? { retitled: titled.retitled } : {},
26273
26675
  ...titled.cascaded ? { domainCascade: `#domain updated on ${titled.cascaded} descendant note(s)` } : {},
26274
26676
  ...structuralFindings(noteKind, finalContent),
@@ -26540,8 +26942,10 @@ calling twice is safe. Use remove=true to delete an edge.`, {
26540
26942
  }
26541
26943
  }
26542
26944
  });
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)."),
26945
+ server.tool("consistency", `Does the brain agree with itself? pattern= is a regex with ONE capture group naming the value that should match across notes, e.g. "(\\\\d+) mailboxes"; the result groups every asserting note by value with agreement unanimous or DISAGREEMENT. subject="<fact in prose>" finds notes asserting about a subject however phrased. staleAfterDays=N also reports values held in exactly one note untouched N+ days. Matches stored HTML and tag-stripped text; escape backslashes; scope with domain=/kinds=. Records (sessions, diary, logs, dated thread entries) are skipped by default because they are never rewritten; includeRecords=true or a kinds= naming them brings them back. Scans every in-scope note (fast=true uses Trilium's lossy pre-filter). Matching ignores case unless caseSensitive=true (a leading (?i) is accepted). Without a capture group the whole match is the value compared. patterns=[\u2026] checks several facts in one call over one read of each note. Run it after correcting any fact recorded in more than one place.`, {
26946
+ pattern: stringType().optional().describe("Regex over note bodies. One capture group = the value that should agree across notes; with none, the whole match is the value. Omit when using subject= (prose mode)."),
26947
+ patterns: arrayType(stringType()).max(10).optional().describe("Several patterns checked in one call (each reported as its own result, in order); every note is read once"),
26948
+ caseSensitive: booleanType().optional().describe('Match case exactly (default false: "Blueprints" and "blueprints" both match)'),
26545
26949
  subject: stringType().optional().describe("A fact in prose \u2014 returns notes asserting about it however phrased (instead of pattern=)"),
26546
26950
  staleAfterDays: numberType().optional().describe("With pattern: also report values held in exactly one note untouched N+ days (staleSingles)"),
26547
26951
  domain: stringType().optional().describe("Restrict to one knowledge domain"),
@@ -26550,146 +26954,175 @@ calling twice is safe. Use remove=true to delete an edge.`, {
26550
26954
  includeArchived: booleanType().optional().describe("Include archived notes (default false)"),
26551
26955
  limit: numberType().optional().describe("Max notes to examine (default 60)"),
26552
26956
  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;
26957
+ }, async (args) => {
26958
+ const contentCache = new Map;
26959
+ const contentOf = (id) => {
26960
+ let p = contentCache.get(id);
26961
+ if (!p) {
26962
+ p = trilium.getNoteContent(id).catch(() => "");
26963
+ contentCache.set(id, p);
26964
+ }
26965
+ return p;
26567
26966
  };
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) {
26967
+ if (args.patterns?.length) {
26968
+ if (args.pattern || args.subject)
26969
+ return err("conflicting_params", "Pass patterns=[\u2026] alone, not with pattern= or subject=.", "Put every pattern in the patterns array.");
26970
+ const results = [];
26971
+ for (const p of args.patterns) {
26972
+ const r = await checkOne({ ...args, pattern: p, patterns: undefined });
26973
+ results.push(JSON.parse(r.content[0].text));
26974
+ }
26975
+ const disagreeing = results.filter((r) => r.agreement === "DISAGREEMENT").length;
26976
+ return txt3({ mode: "multi", patterns: args.patterns.length, disagreeing, results });
26977
+ }
26978
+ return checkOne(args);
26979
+ async function checkOne({ pattern, subject, staleAfterDays, domain, kinds, includeRecords, includeArchived, limit, fast, caseSensitive }) {
26980
+ const wantsRecords = includeRecords === true || (kinds ?? []).some((k) => RECORD_KINDS.has(k));
26981
+ let recordsSkipped = 0;
26982
+ const inScope = (n) => {
26983
+ const kind = ownedLabel(n, "noteType");
26984
+ if (!kind)
26985
+ return false;
26986
+ if (kinds?.length && !kinds.includes(kind))
26987
+ return false;
26988
+ if (!wantsRecords && isRecordNote(n)) {
26989
+ recordsSkipped++;
26990
+ return false;
26991
+ }
26992
+ return true;
26993
+ };
26994
+ const recordsNote = () => recordsSkipped ? { recordsSkipped, recordsNote: `${recordsSkipped} record(s) (sessions, diary, logs, dated thread entries) were skipped: they are never rewritten. includeRecords=true matches them too.` } : {};
26995
+ if (!pattern && !subject)
26996
+ return err("missing_param", "consistency() needs either a regex pattern or a prose subject.", 'Pass pattern="(\\\\d+) users" to compare a captured value, or subject="<a fact in prose>" to find every note asserting about that subject however phrased.');
26997
+ if (subject && !pattern) {
26998
+ const max = limit ?? 60;
26999
+ const tokens = queryTokens(subject, 8);
27000
+ if (!tokens.length)
27001
+ return err("no_tokens", "The subject produced no significant content words.", 'A subject of all stop-words ("what was it") cannot find anything \u2014 name the thing you re-measured.');
27002
+ const clauses = ["#noteType"];
27003
+ if (domain)
27004
+ clauses.push(`#domain='${slugify(domain)}'`);
27005
+ const notes = await trilium.searchNotes(clauses.join(" AND "), { ancestorNoteId: b().root, limit: wantsRecords ? max : max * 4, includeArchivedNotes: includeArchived ?? false }).then((r) => r.results).catch(() => []);
27006
+ const scoped = notes.filter(inScope).slice(0, max);
27007
+ const threshold = Math.max(1, Math.ceil(tokens.length / 2));
27008
+ const hits = [];
27009
+ for (const n of scoped) {
27010
+ const content = await contentOf(n.noteId);
27011
+ if (!content)
27012
+ continue;
27013
+ const text = stripTagsWithMap(content).text.toLowerCase();
27014
+ const matchedTokens = tokens.filter((t) => text.includes(t));
27015
+ if (matchedTokens.length < threshold)
27016
+ continue;
27017
+ hits.push({ id: n.noteId, title: n.title, kind: ownedLabel(n, "noteType") ?? "", matchedTokens, snippet: toText(content, 200) });
27018
+ }
27019
+ return txt3({
27020
+ mode: "subject",
27021
+ subject,
27022
+ tokens,
27023
+ threshold: `${threshold} of ${tokens.length} token(s)`,
27024
+ notesExamined: scoped.length,
27025
+ notes: hits,
27026
+ total: hits.length,
27027
+ ...domain ? { domain: slugify(domain) } : {},
27028
+ ...recordsNote(),
27029
+ note: hits.length ? `${hits.length} note(s) assert something about "${subject}". Open each to read the claim \u2014 or narrow with domain= / kinds=.` : `No note in scope matched ${threshold}+ of the subject's tokens. That is evidence about the phrasing, not the brain \u2014 this mode is deliberately phrase-agnostic, so if you expected a hit, the fact may not be recorded here at all.`
27030
+ });
27031
+ }
27032
+ if (!pattern)
27033
+ return err("missing_param", "No regex pattern given for consistency.", 'Pass pattern="(\\\\d+) users" \u2014 or subject="<a fact in prose>" for the prose-subject mode.');
27034
+ let re;
27035
+ const inlineInsensitive = /^\(\?i\)/.test(pattern);
27036
+ const source = inlineInsensitive ? pattern.slice(4) : pattern;
27037
+ const flags = caseSensitive && !inlineInsensitive ? "g" : "gi";
27038
+ try {
27039
+ re = new RegExp(source, flags);
27040
+ } catch (e) {
27041
+ return err("invalid_pattern", `Not a valid regular expression: ${e.message}`, "Escape backslashes \u2014 a JSON string needs \\\\d for \\d.");
27042
+ }
27043
+ const groupCount = new RegExp(`${source}|`).exec("").length - 1;
26572
27044
  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"];
27045
+ const clauses = fast ? [`note.content %= '${escapeQueryRegex(pattern)}'`] : ["#noteType"];
26577
27046
  if (domain)
26578
27047
  clauses.push(`#domain='${slugify(domain)}'`);
26579
27048
  const notes = await trilium.searchNotes(clauses.join(" AND "), { ancestorNoteId: b().root, limit: wantsRecords ? max : max * 4, includeArchivedNotes: includeArchived ?? false }).then((r) => r.results).catch(() => []);
26580
27049
  const scoped = notes.filter(inScope).slice(0, max);
26581
- const threshold = Math.max(1, Math.ceil(tokens.length / 2));
26582
- const hits = [];
27050
+ const byValue = new Map;
27051
+ const noCapture = [];
27052
+ let hasCaptureGroup = false;
27053
+ const scan = (haystack) => {
27054
+ const values = [];
27055
+ let matched = false;
27056
+ re.lastIndex = 0;
27057
+ let m;
27058
+ while ((m = re.exec(haystack)) !== null) {
27059
+ matched = true;
27060
+ const captured = groupCount === 0 ? m[0] : m.slice(1).find((g) => g !== undefined);
27061
+ if (captured !== undefined && captured !== "")
27062
+ values.push(toText(captured, 120).trim() || captured.trim());
27063
+ if (m[0] === "")
27064
+ re.lastIndex++;
27065
+ }
27066
+ return { values, matched };
27067
+ };
27068
+ let tagSpanning = 0;
27069
+ const idleSince = (iso) => Math.max(0, Math.floor((Date.now() - new Date(iso.replace(" ", "T")).getTime()) / 86400000));
26583
27070
  for (const n of scoped) {
26584
- const content = await trilium.getNoteContent(n.noteId).catch(() => "");
27071
+ const content = await contentOf(n.noteId);
26585
27072
  if (!content)
26586
27073
  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) });
27074
+ const stub = { id: n.noteId, title: n.title, kind: ownedLabel(n, "noteType") ?? "", idleDays: idleSince(n.dateModified) };
27075
+ const raw = scan(content);
27076
+ const projected = scan(stripTagsWithMap(content).text);
27077
+ if (projected.matched && !raw.matched)
27078
+ tagSpanning++;
27079
+ const seen = new Set;
27080
+ for (const value of [...raw.values, ...projected.values]) {
27081
+ hasCaptureGroup = true;
27082
+ if (seen.has(value))
27083
+ continue;
27084
+ seen.add(value);
27085
+ if (!byValue.has(value))
27086
+ byValue.set(value, []);
27087
+ byValue.get(value).push(stub);
27088
+ }
27089
+ if ((raw.matched || projected.matched) && !seen.size)
27090
+ noCapture.push(stub);
26592
27091
  }
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);
27092
+ if (!hasCaptureGroup) {
27093
+ return txt3({
27094
+ mode: "presence",
27095
+ pattern,
27096
+ scan: fast ? "fast (%= pre-filter)" : "exhaustive",
27097
+ notesExamined: scoped.length,
27098
+ ...recordsNote(),
27099
+ notes: noCapture,
27100
+ total: noCapture.length,
27101
+ note: noCapture.length === 0 ? `No note body matched that pattern across ${scoped.length} note(s), searched both as stored HTML and tag-stripped.${fast ? " fast=true used Trilium's %= pre-filter, which reads a striptags'd copy and can drop notes \u2014 re-run without it before concluding anything." : " That is evidence about the pattern, not the brain."}` : 'The pattern has no capture group, so this is a presence check only. Add one \u2014 consistency("(\\\\d+) users") \u2014 to compare the values these notes actually assert.'
27102
+ });
26658
27103
  }
26659
- if ((raw.matched || projected.matched) && !seen.size)
26660
- noCapture.push(stub);
26661
- }
26662
- if (!hasCaptureGroup) {
27104
+ const groups = [...byValue.entries()].map(([value, notes]) => ({ value, count: notes.length, notes })).sort((a, b) => b.count - a.count);
27105
+ const staleSingles = staleAfterDays ? groups.filter((g) => g.count === 1 && g.notes[0].idleDays >= staleAfterDays).map((g) => ({ value: g.value, note: { id: g.notes[0].id, title: g.notes[0].title, kind: g.notes[0].kind }, idleDays: g.notes[0].idleDays })) : [];
27106
+ const agrees = groups.length <= 1;
26663
27107
  return txt3({
26664
- mode: "presence",
27108
+ mode: "consistency",
26665
27109
  pattern,
27110
+ ...groupCount === 0 ? { valueFrom: "the whole match (the pattern has no capture group)" } : {},
27111
+ ...caseSensitive && !inlineInsensitive ? { caseSensitive: true } : {},
26666
27112
  scan: fast ? "fast (%= pre-filter)" : "exhaustive",
27113
+ ...domain ? { domain: slugify(domain) } : {},
26667
27114
  notesExamined: scoped.length,
26668
27115
  ...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.'
27116
+ ...tagSpanning ? { tagSpanning, tagSpanningNote: `${tagSpanning} note(s) matched only once markup was stripped \u2014 the phrase is split by an inline tag there. Before v10.3 those were invisible.` } : {},
27117
+ distinctValues: groups.length,
27118
+ agreement: groups.length === 0 ? "no-data" : agrees ? "unanimous" : "DISAGREEMENT",
27119
+ groups,
27120
+ ...staleAfterDays ? { staleAfterDays } : {},
27121
+ ...staleSingles.length ? { staleSingles, staleSinglesNote: `${staleSingles.length} figure(s) appear in exactly one note, untouched ${staleAfterDays}d+ \u2014 a single copy cannot disagree with itself, so re-measure these against the world rather than against other notes.` } : {},
27122
+ ...noCapture.length ? { matchedWithoutValue: noCapture } : {},
27123
+ note: agrees ? groups.length === 0 ? "No note asserted a value for that pattern. That is evidence about the pattern, not about the brain \u2014 check it against a note you know contains the fact." : `All ${groups[0].count} note(s) agree on "${groups[0].value}".` : `${groups.length} DIFFERENT values are asserted across ${scoped.length} notes. Establish which is true from evidence, correct every note that disagrees, and wire ~corrects from the note that overturns the old claim \u2014 revising in place leaves no trace the wrong value was ever believed.`
26672
27124
  });
26673
27125
  }
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
27126
  });
26694
27127
  server.tool("outline", `A note's heading tree without its body: each h2\u2013h4 with level and occurrence index (and raw stored text where inline markup differs), table key columns, size, and structural findings. Read it before a section= edit you're not sure of. Notes over 15k also get a first-block preview per section.`, {
26695
27128
  noteId: stringType().describe("Note to outline")
@@ -26738,7 +27171,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
26738
27171
  ...headings.length === 0 ? { note: "No h2\u2013h4 headings \u2014 section= would append a new section rather than match anything." } : {}
26739
27172
  });
26740
27173
  });
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.`, {
27174
+ server.tool("inspect", `Everything about one note: every label, relation and attachment, type/mime, parent and child ids, dates. content=true adds the raw body (section= narrows it). find="<literal>" counts occurrences (total, per addendum block, per section), returns the stored element around each one (up to 10: its row, list item or paragraph, ready to anchor a revise), and on a miss shows the nearest stored text. Read-only, safe on any note.`, {
26742
27175
  noteId: stringType().describe("Note to inspect"),
26743
27176
  content: booleanType().optional().describe("Include the note's raw body content (default: false)"),
26744
27177
  section: stringType().optional().describe("With content=true, return only this heading's section rather than the whole body"),
@@ -26811,11 +27244,39 @@ calling twice is safe. Use remove=true to delete an edge.`, {
26811
27244
  }
26812
27245
  const total = countIn(rawBody);
26813
27246
  const near = total === 0 ? nearestContext(rawBody, find) : null;
27247
+ const matches = [];
27248
+ const seenSpans = new Set;
27249
+ for (let at = rawBody.indexOf(find);at !== -1 && matches.length < 10; at = rawBody.indexOf(find, at + Math.max(1, find.length))) {
27250
+ let el = null;
27251
+ let tag = "";
27252
+ for (const t of ["tr", "li", "p", "blockquote", "td"]) {
27253
+ el = containingElement(rawBody, t, at, at + find.length);
27254
+ if (el) {
27255
+ tag = t;
27256
+ break;
27257
+ }
27258
+ }
27259
+ const start = el?.start ?? Math.max(0, at - 120);
27260
+ const end = el?.end ?? Math.min(rawBody.length, at + find.length + 120);
27261
+ if (seenSpans.has(start))
27262
+ continue;
27263
+ seenSpans.add(start);
27264
+ let sectionName = "(head)";
27265
+ for (const h of headings) {
27266
+ if (h.index < at)
27267
+ sectionName = h.text;
27268
+ else
27269
+ break;
27270
+ }
27271
+ const stored = rawBody.slice(start, end);
27272
+ matches.push({ section: sectionName, element: tag || "text", stored: stored.length > 600 ? `${stored.slice(0, 600)}\u2026` : stored });
27273
+ }
26814
27274
  findReport = {
26815
27275
  find,
26816
27276
  total,
26817
27277
  blocks,
26818
27278
  ...sections.length ? { sections } : {},
27279
+ ...matches.length ? { matches } : {},
26819
27280
  ...near ? { matchedUpTo: near.fragment, storedNearby: near.context } : {},
26820
27281
  ...total === 0 && !near ? { hint: "Not present, and no fragment of it is either \u2014 the string is unrelated to this note's content." } : {}
26821
27282
  };
@@ -27166,7 +27627,7 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
27166
27627
  ...found.length === 0 ? { note: "No notes with pending addendums." } : { hint: "Fold each addendum into its note: read the block, then revise(noteId, section='<heading>', body='<merged content>', mode='replace') to absorb it into the right section. These notes must be left clean \u2014 no addendum markers. Addendum-style history belongs only in sessions, diary, and logs. Scoped/autonomous agents: fold only what's in your lane \u2014 leaving personal or out-of-scope addendums for the next interactive session is correct, and calling this tool is what satisfies the pre-close gate." }
27167
27628
  });
27168
27629
  });
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.`, {
27630
+ server.tool("maintain", `Brain hygiene. Lite (automatic in start/close): ages threads active \u2192 dormant \u2192 archived and checks labels. deep=true adds stale notes, orphans and sinks, structural lint (duplicate headings, unbalanced tags, missing required sections, dated prose in timeless notes, oversized notes), duplicate titles, lapsed or broken claims, table rows that disagree with their header, record seals (whether any record of the last seven sealed days was rewritten after close() sealed it) and hygiene passes. dryRun previews. ack=[ids] silences a note you reviewed until its content changes. domain= narrows deep passes to one lane. repair=[ids] unwinds entity double-escaping in place. coverage names any capped pass: structural lint reads at most 40 notes per run and rotates through the rest day by day, so a whole-brain lint spans several runs.`, {
27170
27631
  deep: booleanType().optional().describe("Deep pass: stale-review + orphan/sink + structural lint + duplicate titles across Memory/Threads and Knowledge (default: false)"),
27171
27632
  dryRun: booleanType().optional().describe("Report what would change without changing it"),
27172
27633
  domain: stringType().optional().describe("Narrow the deep passes to one domain's notes (slugged server-side) \u2014 for scoped agents working a single lane"),
@@ -27180,6 +27641,15 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
27180
27641
  ...ack?.length ? { ack } : {},
27181
27642
  ...repair?.length ? { repair } : {}
27182
27643
  });
27644
+ if (deep && !domain) {
27645
+ const seals = await verifySeals(trilium, b(), localToday()).catch(() => null);
27646
+ if (seals) {
27647
+ for (const f of seals.findings) {
27648
+ report.flagged.push(`record seal: ${f.date}${f.noteId ? ` [${f.noteId}]` : ""} \u2014 ${f.problem}. Records are append-only; read the revisions to see whether the change was intended. The finding ages out with the seven-day window.`);
27649
+ }
27650
+ report.seals = seals.checked ? `${seals.checked} sealed day(s) verified, ${seals.findings.length} finding(s)` : "no sealed days yet \u2014 the first close of 12.9.0 seals its day";
27651
+ }
27652
+ }
27183
27653
  if (deep && !dryRun && b().sizes && Object.keys(b().sizes).length) {
27184
27654
  saveConfig(b());
27185
27655
  }
@@ -27743,6 +28213,37 @@ and every thread day-child, in time order with its identification line.`, {
27743
28213
  registerMemoryTools(server, trilium, brainRef);
27744
28214
  registerKnowledgeTools(server, trilium, brainRef);
27745
28215
  registerInsightsTools(server, trilium, brainRef);
28216
+ const registry = server._registeredTools ?? {};
28217
+ for (const name of CONTENT_WRITE_TOOLS) {
28218
+ const tool = registry[name];
28219
+ const inner = tool?.handler;
28220
+ if (!tool || typeof inner !== "function")
28221
+ continue;
28222
+ tool.handler = async (...args) => {
28223
+ const result = await inner(...args);
28224
+ if (!wroteSinceClose && wroteContent(result)) {
28225
+ await markStep("write").catch(() => null);
28226
+ wroteSinceClose = true;
28227
+ }
28228
+ return result;
28229
+ };
28230
+ }
28231
+ }
28232
+ var CONTENT_WRITE_TOOLS = ["remember", "revise", "split", "resolve", "withdraw", "recover", "forget", "label", "connect", "attach", "detach", "claim"];
28233
+ function wroteContent(result) {
28234
+ const text = result?.content?.[0]?.text;
28235
+ if (typeof text !== "string")
28236
+ return true;
28237
+ try {
28238
+ const r = JSON.parse(text);
28239
+ if (r.error || r.ok === false || r.unchanged === true || r.action === "already_written" || r.mode === "already_written")
28240
+ return false;
28241
+ if (r.replaced === 0 && !r.closure)
28242
+ return false;
28243
+ return true;
28244
+ } catch {
28245
+ return true;
28246
+ }
27746
28247
  }
27747
28248
 
27748
28249
  // src/annotations.ts
@@ -28083,10 +28584,24 @@ var asArray = (v) => {
28083
28584
  return v;
28084
28585
  }
28085
28586
  };
28587
+ var asObject = (v) => {
28588
+ if (typeof v !== "string")
28589
+ return v;
28590
+ const t = v.trim();
28591
+ if (!t.startsWith("{"))
28592
+ return v;
28593
+ try {
28594
+ const parsed = JSON.parse(t);
28595
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : v;
28596
+ } catch {
28597
+ return v;
28598
+ }
28599
+ };
28086
28600
  var COERCERS = {
28087
28601
  ZodBoolean: asBoolean,
28088
28602
  ZodNumber: asNumber,
28089
- ZodArray: asArray
28603
+ ZodArray: asArray,
28604
+ ZodObject: asObject
28090
28605
  };
28091
28606
  function coerceToolArgs(server) {
28092
28607
  const registry = server._registeredTools;
@@ -28613,7 +29128,7 @@ data: ${JSON.stringify(message)}
28613
29128
  }
28614
29129
 
28615
29130
  // src/oauth.ts
28616
- import { createHmac, randomBytes, createHash, timingSafeEqual } from "crypto";
29131
+ import { createHmac, randomBytes, createHash as createHash2, timingSafeEqual } from "crypto";
28617
29132
  import { existsSync as existsSync2, readFileSync as readFileSync2, statSync, writeFileSync as writeFileSync2 } from "fs";
28618
29133
 
28619
29134
  // src/network-security.ts
@@ -29537,7 +30052,7 @@ var tokenError = (error, description, status = 400) => new Response(JSON.stringi
29537
30052
  headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }
29538
30053
  });
29539
30054
  function refreshKey(token) {
29540
- return "sha256:" + createHash("sha256").update(token).digest("hex");
30055
+ return "sha256:" + createHash2("sha256").update(token).digest("hex");
29541
30056
  }
29542
30057
  function issueTokens(base, clientId, resource, scope) {
29543
30058
  const store = loadStore();
@@ -29583,7 +30098,7 @@ async function handleToken(req, base) {
29583
30098
  return tokenError("invalid_grant", "redirect_uri mismatch.");
29584
30099
  if (!verifier)
29585
30100
  return tokenError("invalid_request", "code_verifier is required.");
29586
- const computed = createHash("sha256").update(verifier).digest("base64url");
30101
+ const computed = createHash2("sha256").update(verifier).digest("base64url");
29587
30102
  if (computed !== record.codeChallenge)
29588
30103
  return tokenError("invalid_grant", "PKCE verification failed.");
29589
30104
  return new Response(JSON.stringify(issueTokens(base, record.clientId, record.resource, record.scope)), {
@@ -29690,7 +30205,7 @@ function createServer(origin = null) {
29690
30205
  const s = new McpServer({
29691
30206
  name: BRAND2.name,
29692
30207
  title: BRAND2.name,
29693
- version: "12.8.0",
30208
+ version: "12.9.0",
29694
30209
  description: BRAND2.description,
29695
30210
  websiteUrl: BRAND2.website,
29696
30211
  icons: brandIcons(origin)