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