brainllm 12.9.1 → 13.0.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/README.md +3 -3
- package/SECURITY.md +4 -2
- package/dist/index.js +712 -126
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/brainllm/SKILL.md +20 -13
- package/skills/brainllm/references/domains.md +1 -1
- package/skills/brainllm/references/taxonomy.md +2 -0
- package/skills/brainllm/references/troubleshooting.md +7 -6
package/dist/index.js
CHANGED
|
@@ -22143,11 +22143,12 @@ var TAG_ATTRIBUTES = {
|
|
|
22143
22143
|
img: new Set(["src", "alt", "width", "height", "loading"]),
|
|
22144
22144
|
th: new Set(["colspan", "rowspan", "scope", "headers"]),
|
|
22145
22145
|
td: new Set(["colspan", "rowspan", "headers"]),
|
|
22146
|
-
code: new Set(["class"]),
|
|
22146
|
+
code: new Set(["class", "spellcheck"]),
|
|
22147
22147
|
pre: new Set(["class"]),
|
|
22148
22148
|
span: new Set(["class"]),
|
|
22149
22149
|
mark: new Set(["class"]),
|
|
22150
|
-
ol: new Set(["start", "reversed", "type"])
|
|
22150
|
+
ol: new Set(["start", "reversed", "type"]),
|
|
22151
|
+
li: new Set(["data-list-item-id"])
|
|
22151
22152
|
};
|
|
22152
22153
|
function safeHtmlUrl(value) {
|
|
22153
22154
|
const decoded = decodeEntities(value).replace(/[\u0000\u0001-\u001f\u007f]/g, "").trim();
|
|
@@ -22942,6 +22943,108 @@ function upsertTableRow(html, heading, key, cells) {
|
|
|
22942
22943
|
created: !matchedRow
|
|
22943
22944
|
};
|
|
22944
22945
|
}
|
|
22946
|
+
function findTable(html, heading) {
|
|
22947
|
+
let scope = html;
|
|
22948
|
+
let scopeStart = 0;
|
|
22949
|
+
if (heading !== undefined) {
|
|
22950
|
+
const section = locateSection(html, heading);
|
|
22951
|
+
if (!section)
|
|
22952
|
+
return null;
|
|
22953
|
+
scope = html.slice(section.contentStart, section.end);
|
|
22954
|
+
scopeStart = section.contentStart;
|
|
22955
|
+
}
|
|
22956
|
+
const tableMatch = /<table[^>]*>[\s\S]*?<\/table>/i.exec(scope);
|
|
22957
|
+
if (!tableMatch)
|
|
22958
|
+
return null;
|
|
22959
|
+
const tbodyMatch = /<tbody[^>]*>([\s\S]*?)<\/tbody>/i.exec(tableMatch[0]);
|
|
22960
|
+
if (!tbodyMatch)
|
|
22961
|
+
return null;
|
|
22962
|
+
const firstRow = /<tr[^>]*>([\s\S]*?)<\/tr>/i.exec(tableMatch[0]);
|
|
22963
|
+
if (!firstRow)
|
|
22964
|
+
return null;
|
|
22965
|
+
const columns = [...firstRow[1].matchAll(/<t[hd](?=[\s>/])([^>]*)>/gi)].reduce((sum, c) => sum + Number(/colspan\s*=\s*"?(\d+)/i.exec(c[1] ?? "")?.[1] ?? 1), 0);
|
|
22966
|
+
if (!columns)
|
|
22967
|
+
return null;
|
|
22968
|
+
const tbodyInner = tbodyMatch[1];
|
|
22969
|
+
const tbodyInnerStart = scopeStart + tableMatch.index + tbodyMatch.index + tbodyMatch[0].indexOf(">") + 1;
|
|
22970
|
+
const insertAt = tbodyInnerStart + tbodyInner.length;
|
|
22971
|
+
let placeholder;
|
|
22972
|
+
const rowRe = /<tr[^>]*>[\s\S]*?<\/tr>/gi;
|
|
22973
|
+
let m;
|
|
22974
|
+
while ((m = rowRe.exec(tbodyInner)) !== null) {
|
|
22975
|
+
const first = /<td[^>]*>([\s\S]*?)<\/td>/i.exec(m[0]);
|
|
22976
|
+
const text = first ? decodeEntities(first[1].replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim().toLowerCase() : "";
|
|
22977
|
+
if (text === "" || text === "\u2014 none yet \u2014") {
|
|
22978
|
+
placeholder = { start: tbodyInnerStart + m.index, end: tbodyInnerStart + m.index + m[0].length };
|
|
22979
|
+
break;
|
|
22980
|
+
}
|
|
22981
|
+
}
|
|
22982
|
+
return { insertAt, columns, ...placeholder ? { placeholder } : {} };
|
|
22983
|
+
}
|
|
22984
|
+
function appendRowToTable(html, spot, cells) {
|
|
22985
|
+
const row = `<tr>${cells.map((c) => `<td>${c}</td>`).join("")}</tr>`;
|
|
22986
|
+
const at = spot.placeholder ?? { start: spot.insertAt, end: spot.insertAt };
|
|
22987
|
+
return html.slice(0, at.start) + row + html.slice(at.end);
|
|
22988
|
+
}
|
|
22989
|
+
function fitRowCells(cells, columns) {
|
|
22990
|
+
if (columns <= 0 || cells.length > columns)
|
|
22991
|
+
return null;
|
|
22992
|
+
return [...cells, ...Array.from({ length: columns - cells.length }, () => "")];
|
|
22993
|
+
}
|
|
22994
|
+
function rowCells(row) {
|
|
22995
|
+
return [...row.matchAll(/<(t[hd])(?=[\s>/])[^>]*>([\s\S]*?)<\/\1>/gi)].map((m) => m[2].trim());
|
|
22996
|
+
}
|
|
22997
|
+
function nearestKey(requested, available) {
|
|
22998
|
+
const clean = (s) => decodeEntities(s).replace(/\s+/g, " ").trim();
|
|
22999
|
+
const req = clean(requested);
|
|
23000
|
+
if (!req)
|
|
23001
|
+
return null;
|
|
23002
|
+
const wordSet = (s) => new Set(s.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(Boolean));
|
|
23003
|
+
const reqWords = wordSet(req);
|
|
23004
|
+
let best = null;
|
|
23005
|
+
const rank = (c) => c.reason === "compound" ? [0, c.distance] : [1, c.distance];
|
|
23006
|
+
for (const key of available) {
|
|
23007
|
+
const cand = clean(key);
|
|
23008
|
+
if (!cand || cand.toLowerCase() === req.toLowerCase())
|
|
23009
|
+
continue;
|
|
23010
|
+
let hit = null;
|
|
23011
|
+
const candWords = wordSet(cand);
|
|
23012
|
+
const small = reqWords.size <= candWords.size ? reqWords : candWords;
|
|
23013
|
+
const large = reqWords.size <= candWords.size ? candWords : reqWords;
|
|
23014
|
+
if (small.size) {
|
|
23015
|
+
let extra = 0;
|
|
23016
|
+
let subset = true;
|
|
23017
|
+
for (const w of small)
|
|
23018
|
+
if (!large.has(w)) {
|
|
23019
|
+
subset = false;
|
|
23020
|
+
break;
|
|
23021
|
+
}
|
|
23022
|
+
if (subset) {
|
|
23023
|
+
for (const w of large)
|
|
23024
|
+
if (!small.has(w))
|
|
23025
|
+
extra++;
|
|
23026
|
+
if (extra <= 2)
|
|
23027
|
+
hit = { distance: extra, reason: "compound" };
|
|
23028
|
+
}
|
|
23029
|
+
}
|
|
23030
|
+
if (!hit) {
|
|
23031
|
+
const d = editDistance(req, cand);
|
|
23032
|
+
if (d > 0 && d <= Math.max(2, Math.floor(req.length / 5)))
|
|
23033
|
+
hit = { distance: d, reason: "typo" };
|
|
23034
|
+
}
|
|
23035
|
+
if (!hit)
|
|
23036
|
+
continue;
|
|
23037
|
+
if (!best) {
|
|
23038
|
+
best = { key, ...hit };
|
|
23039
|
+
continue;
|
|
23040
|
+
}
|
|
23041
|
+
const [r1, d1] = rank(hit);
|
|
23042
|
+
const [r2, d2] = rank(best);
|
|
23043
|
+
if (r1 < r2 || r1 === r2 && d1 < d2)
|
|
23044
|
+
best = { key, ...hit };
|
|
23045
|
+
}
|
|
23046
|
+
return best;
|
|
23047
|
+
}
|
|
22945
23048
|
|
|
22946
23049
|
// src/templates.ts
|
|
22947
23050
|
var RESOLUTION_ANCHOR = "<h2>Resolution</h2>";
|
|
@@ -23391,6 +23494,9 @@ function isoDaysAgo(days) {
|
|
|
23391
23494
|
function reviewKey(n) {
|
|
23392
23495
|
return n.blobId ?? n.dateModified;
|
|
23393
23496
|
}
|
|
23497
|
+
function isAcknowledged(n) {
|
|
23498
|
+
return ownedLabel(n, "reviewed") === reviewKey(n);
|
|
23499
|
+
}
|
|
23394
23500
|
var TIMELESS_KINDS = new Set(["thread", "user", "information", "domain"]);
|
|
23395
23501
|
var DATED_PROSE_LIMIT = 4;
|
|
23396
23502
|
var MONTHS = "Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|June?|July?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?";
|
|
@@ -23429,7 +23535,7 @@ async function sweep(trilium, cfg, opts = {}) {
|
|
|
23429
23535
|
const inScope = (n) => !domainSlug || n.attributes.some((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === "domain" && a.value === domainSlug);
|
|
23430
23536
|
let suppressed = 0;
|
|
23431
23537
|
const acknowledged = (n) => {
|
|
23432
|
-
if (
|
|
23538
|
+
if (!isAcknowledged(n))
|
|
23433
23539
|
return false;
|
|
23434
23540
|
suppressed++;
|
|
23435
23541
|
return true;
|
|
@@ -24848,6 +24954,94 @@ function applyFindEdit(source, needle, replacement, index) {
|
|
|
24848
24954
|
const inline = inlineTolerantSpans(source, needle);
|
|
24849
24955
|
return inline.length ? spans(inline, "inline-tolerant") : null;
|
|
24850
24956
|
}
|
|
24957
|
+
var CHANGES_CAP = 25;
|
|
24958
|
+
var boundPreview = (s, max = 1600) => s.length <= max ? s : `${s.slice(0, max)}\u2026 (${s.length - max} more characters)`;
|
|
24959
|
+
function capChanges(changes, cap = CHANGES_CAP) {
|
|
24960
|
+
if (changes.length <= cap)
|
|
24961
|
+
return { changes };
|
|
24962
|
+
return { changes: changes.slice(0, cap), changesTruncated: { shown: cap, total: changes.length } };
|
|
24963
|
+
}
|
|
24964
|
+
function sectionTableRows(html) {
|
|
24965
|
+
const table = /<table[^>]*>[\s\S]*?<\/table>/i.exec(html)?.[0];
|
|
24966
|
+
if (!table)
|
|
24967
|
+
return;
|
|
24968
|
+
const tbody = /<tbody[^>]*>([\s\S]*?)<\/tbody>/i.exec(table)?.[1];
|
|
24969
|
+
const scope = tbody ?? table.replace(/<thead[^>]*>[\s\S]*?<\/thead>/gi, "");
|
|
24970
|
+
return (scope.match(/<tr[^>]*>/gi) ?? []).length;
|
|
24971
|
+
}
|
|
24972
|
+
var RECALL_STRATEGIES = ["label", "title", "text", "fuzzy", "ordered", "regex"];
|
|
24973
|
+
function dominantStrategy(contributions) {
|
|
24974
|
+
let best = null;
|
|
24975
|
+
let bestWeight = 0;
|
|
24976
|
+
for (const s of RECALL_STRATEGIES) {
|
|
24977
|
+
const w = contributions.get(s) ?? 0;
|
|
24978
|
+
if (w > bestWeight) {
|
|
24979
|
+
bestWeight = w;
|
|
24980
|
+
best = s;
|
|
24981
|
+
}
|
|
24982
|
+
}
|
|
24983
|
+
return best;
|
|
24984
|
+
}
|
|
24985
|
+
function passageAround(text, index, length) {
|
|
24986
|
+
const from = Math.max(0, index - 80);
|
|
24987
|
+
const to = Math.min(text.length, index + length + 80);
|
|
24988
|
+
return `${from > 0 ? "\u2026" : ""}${text.slice(from, to).replace(/\s+/g, " ").trim()}${to < text.length ? "\u2026" : ""}`;
|
|
24989
|
+
}
|
|
24990
|
+
function tokenPassage(text, tokens) {
|
|
24991
|
+
const lower = text.toLowerCase();
|
|
24992
|
+
let best = null;
|
|
24993
|
+
for (const t of tokens) {
|
|
24994
|
+
const idx = lower.indexOf(t.toLowerCase());
|
|
24995
|
+
if (idx < 0)
|
|
24996
|
+
continue;
|
|
24997
|
+
if (!best || t.length > best.length)
|
|
24998
|
+
best = { index: idx, length: t.length };
|
|
24999
|
+
}
|
|
25000
|
+
return best ? passageAround(text, best.index, best.length) : null;
|
|
25001
|
+
}
|
|
25002
|
+
function withinModifiedWindow(modified, since, until) {
|
|
25003
|
+
const day = modified.slice(0, 10);
|
|
25004
|
+
const dayOk = /^\d{4}-\d{2}-\d{2}$/.test(day);
|
|
25005
|
+
const isDay = (v) => /^\d{4}-\d{2}-\d{2}$/.test(v);
|
|
25006
|
+
const ms = Date.parse(modified.replace(" ", "T"));
|
|
25007
|
+
if (since) {
|
|
25008
|
+
if (isDay(since)) {
|
|
25009
|
+
if (dayOk && day < since)
|
|
25010
|
+
return false;
|
|
25011
|
+
} else {
|
|
25012
|
+
const s = Date.parse(since);
|
|
25013
|
+
if (Number.isFinite(s) && Number.isFinite(ms) && ms < s)
|
|
25014
|
+
return false;
|
|
25015
|
+
}
|
|
25016
|
+
}
|
|
25017
|
+
if (until) {
|
|
25018
|
+
if (isDay(until)) {
|
|
25019
|
+
if (dayOk && day > until)
|
|
25020
|
+
return false;
|
|
25021
|
+
} else {
|
|
25022
|
+
const u = Date.parse(until);
|
|
25023
|
+
if (Number.isFinite(u) && Number.isFinite(ms) && ms > u)
|
|
25024
|
+
return false;
|
|
25025
|
+
}
|
|
25026
|
+
}
|
|
25027
|
+
return true;
|
|
25028
|
+
}
|
|
25029
|
+
function capWarning(saturated) {
|
|
25030
|
+
const names = [...new Set(saturated)].sort();
|
|
25031
|
+
if (!names.length)
|
|
25032
|
+
return null;
|
|
25033
|
+
return `The backend returned its 30-candidate cap on the ${names.join(", ")} ${names.length === 1 ? "strategy" : "strategies"}, so further matches may exist \u2014 narrow with domain= or kinds=, or use consistency() for an exhaustive scan.`;
|
|
25034
|
+
}
|
|
25035
|
+
var SEARCH_FAILURE_NOTE = "One or more backend search queries failed \u2014 this is a backend failure (Trilium unreachable, or its search index busy), NOT a verdict that nothing matched. Retry the call, and do not conclude from this receipt that the content is unstored.";
|
|
25036
|
+
async function markWithRetry(mark) {
|
|
25037
|
+
for (let attempt = 1;attempt <= 2; attempt++) {
|
|
25038
|
+
try {
|
|
25039
|
+
await mark();
|
|
25040
|
+
return true;
|
|
25041
|
+
} catch {}
|
|
25042
|
+
}
|
|
25043
|
+
return false;
|
|
25044
|
+
}
|
|
24851
25045
|
var err = (code, detail, hint) => txt3({ error: code, detail, ...hint ? { hint } : {} });
|
|
24852
25046
|
var labelOf2 = (n, name) => n.attributes.find((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === name)?.value;
|
|
24853
25047
|
var hasLabel = (n, name) => n.attributes.some((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === name);
|
|
@@ -24884,6 +25078,7 @@ function registerTools(server, trilium, brainRef) {
|
|
|
24884
25078
|
const REQUIRED_PRECLOSE_STEPS = ["session", "addendum", "maintain", "remarks", "diary"];
|
|
24885
25079
|
const LIGHT_PRECLOSE_STEPS = ["session", "remarks", "diary"];
|
|
24886
25080
|
let wroteSinceClose = false;
|
|
25081
|
+
let writeMarkPending = null;
|
|
24887
25082
|
const gateNote = async (date) => {
|
|
24888
25083
|
const cfg = b();
|
|
24889
25084
|
if (!cfg.memory.sessions)
|
|
@@ -24928,6 +25123,16 @@ function registerTools(server, trilium, brainRef) {
|
|
|
24928
25123
|
preCloseSteps.set(step, next);
|
|
24929
25124
|
preCloseSeq = next;
|
|
24930
25125
|
};
|
|
25126
|
+
const markWriteStep = async () => {
|
|
25127
|
+
const ok = await markWithRetry(() => markStep("write"));
|
|
25128
|
+
writeMarkPending = ok ? null : 'the "write" gate mark could not be recorded durably (Trilium label write failed twice)';
|
|
25129
|
+
return ok;
|
|
25130
|
+
};
|
|
25131
|
+
const stampSessionScope = async (date, scope) => {
|
|
25132
|
+
const noteId = await ensureGateNote(date);
|
|
25133
|
+
if (noteId)
|
|
25134
|
+
await trilium.updateLabelValue(noteId, "scope", scope).catch(() => null);
|
|
25135
|
+
};
|
|
24931
25136
|
const readGate = async (date) => {
|
|
24932
25137
|
const noteId = await gateNote(date);
|
|
24933
25138
|
if (!noteId)
|
|
@@ -25009,6 +25214,7 @@ function registerTools(server, trilium, brainRef) {
|
|
|
25009
25214
|
async function appendThreadEntry(threadId, block, d) {
|
|
25010
25215
|
const found = await trilium.searchNotes(`#noteType=threadEntry #created='${d}'`, { ancestorNoteId: threadId, fastSearch: true, limit: 1 });
|
|
25011
25216
|
const time = localNowTime();
|
|
25217
|
+
const marker = `Addendum \u2014 ${time}`;
|
|
25012
25218
|
if (found.results[0]) {
|
|
25013
25219
|
const noteId = found.results[0].noteId;
|
|
25014
25220
|
const current = fixRecordHeader(await trilium.getNoteContent(noteId), "threadEntry", d).html;
|
|
@@ -25018,17 +25224,17 @@ function registerTools(server, trilium, brainRef) {
|
|
|
25018
25224
|
if (incoming && blocks.some((b) => norm(b) === incoming))
|
|
25019
25225
|
return { noteId, action: "already_written" };
|
|
25020
25226
|
await trilium.createRevision(noteId);
|
|
25021
|
-
await trilium.updateNoteContent(noteId, safeAppend(current, `<h2
|
|
25227
|
+
await trilium.updateNoteContent(noteId, safeAppend(current, `<h2>${marker}</h2>`, block));
|
|
25022
25228
|
await trilium.updateLabelValue(noteId, "updated", d);
|
|
25023
|
-
return { noteId, action: "appended" };
|
|
25229
|
+
return { noteId, action: "appended", marker };
|
|
25024
25230
|
}
|
|
25025
|
-
const created = await trilium.createNote(threadId, `[${d}]`, contentFor("threadEntry", { date: d, body: `<h2
|
|
25231
|
+
const created = await trilium.createNote(threadId, `[${d}]`, contentFor("threadEntry", { date: d, body: `<h2>${marker}</h2>
|
|
25026
25232
|
${block}` }));
|
|
25027
25233
|
const noteId = created.note.noteId;
|
|
25028
25234
|
await trilium.addLabel(noteId, "noteType", "threadEntry");
|
|
25029
25235
|
await trilium.addLabel(noteId, "created", d);
|
|
25030
25236
|
await ensureIcon(trilium, noteId);
|
|
25031
|
-
return { noteId, action: "created" };
|
|
25237
|
+
return { noteId, action: "created", marker };
|
|
25032
25238
|
}
|
|
25033
25239
|
function collectionAppendRefusal(book) {
|
|
25034
25240
|
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.`);
|
|
@@ -25083,7 +25289,8 @@ ${block}` }));
|
|
|
25083
25289
|
sessionNoteId = digest.todaySession.id;
|
|
25084
25290
|
const content = await trilium.getNoteContent(sessionNoteId).catch(() => "");
|
|
25085
25291
|
sessionPreview = toText(content, 200);
|
|
25086
|
-
|
|
25292
|
+
const note = await trilium.getNote(sessionNoteId).catch(() => null);
|
|
25293
|
+
newDay = note ? !hasLabel(note, "interactive") : !/<h2(?:\s[^>]*)?>\s*Addendum/i.test(content);
|
|
25087
25294
|
} else {
|
|
25088
25295
|
sessionNoteId = await ensureGateNote(todayStr);
|
|
25089
25296
|
newDay = true;
|
|
@@ -25091,6 +25298,7 @@ ${block}` }));
|
|
|
25091
25298
|
} catch {}
|
|
25092
25299
|
}
|
|
25093
25300
|
let changesSinceLastSession = [];
|
|
25301
|
+
let changesTruncated;
|
|
25094
25302
|
if (digest.lastSession && cfg.root) {
|
|
25095
25303
|
const sinceDate = digest.lastSession.date;
|
|
25096
25304
|
try {
|
|
@@ -25102,12 +25310,14 @@ ${block}` }));
|
|
|
25102
25310
|
deduped.set(h.noteId, h);
|
|
25103
25311
|
}
|
|
25104
25312
|
}
|
|
25105
|
-
|
|
25313
|
+
const capped = capChanges([...deduped.values()].map((h) => ({
|
|
25106
25314
|
id: h.noteId,
|
|
25107
25315
|
title: h.current_title,
|
|
25108
25316
|
changed: h.date.slice(0, 10),
|
|
25109
25317
|
...h.current_isDeleted ? { deleted: true } : {}
|
|
25110
|
-
}));
|
|
25318
|
+
})));
|
|
25319
|
+
changesSinceLastSession = capped.changes;
|
|
25320
|
+
changesTruncated = capped.changesTruncated;
|
|
25111
25321
|
} catch {}
|
|
25112
25322
|
}
|
|
25113
25323
|
return txt3({
|
|
@@ -25121,6 +25331,7 @@ ${block}` }));
|
|
|
25121
25331
|
dormantThreads: digest.reviewQueue.length ? { note: "These threads went dormant from inactivity. Mention them if relevant; revise() or resolve() to act.", items: digest.reviewQueue } : [],
|
|
25122
25332
|
lastSession: digest.lastSession ?? null,
|
|
25123
25333
|
changesSinceLastSession: changesSinceLastSession.length ? changesSinceLastSession : undefined,
|
|
25334
|
+
...changesTruncated ? { changesTruncated } : {},
|
|
25124
25335
|
...newDay ? { newDay: true, newDayHint: "First session of the day \u2014 call day() for the sweep payload (previous session + log + changes in one call)." } : {},
|
|
25125
25336
|
...(depth ?? "digest") === "digest" ? { depthHint: 'Preferences and protocols came back IN FULL \u2014 the two needed whole to orient from the first message. Biography, goals and responsibilities are section headings + preview; pull one with master(which) / llm(which), or a single section with their section= parameter. start(depth="full") serves all five inline.' } : {},
|
|
25126
25337
|
hygiene: { scanned: hygiene.scanned, fixed: hygiene.fixed.length, transitions: hygiene.transitions, flagged: hygiene.flagged, ...hygiene.suppressed ? { suppressed: hygiene.suppressed } : {} }
|
|
@@ -25128,22 +25339,42 @@ ${block}` }));
|
|
|
25128
25339
|
});
|
|
25129
25340
|
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.
|
|
25130
25341
|
|
|
25131
|
-
The close sequence next[] lays out: singleton updates from the session, then addendum(), maintain(), remarks(), diary() and close(). close() checks that addendum, maintain, remarks and diary ran, in the order session \u2192 remarks \u2192 diary. scope="agent" omits the singleton updates for scoped or autonomous runs. A session that wrote no content gets a light close: session() returns only the short next[] (remarks, a one-paragraph diary, close) and close() does not require addendum() or maintain().`, {
|
|
25342
|
+
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(). continuing=true is a second session on a day already closed \u2014 verified against the day's addendum, it returns the short continuation receipt (no singleton review or audit) while still recording the session step.`, {
|
|
25132
25343
|
date: stringType().optional().describe("ISO date YYYY-MM-DD (default: today)"),
|
|
25133
25344
|
full: booleanType().optional().describe("Inline every singleton's content and the diary body (default: stubs only)"),
|
|
25134
25345
|
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")`),
|
|
25346
|
+
continuing: booleanType().optional().describe("A second session on a day already closed \u2014 verified against today's addendum, returns the short continuation receipt (no singleton review). Refused on the day's first session."),
|
|
25135
25347
|
light: booleanType().optional().describe("Deprecated \u2014 light is now the default; accepted for compatibility and ignored")
|
|
25136
|
-
}, async ({ date, full, scope }) => {
|
|
25348
|
+
}, async ({ date, full, scope, continuing }) => {
|
|
25137
25349
|
const d = checkedDate(date);
|
|
25138
25350
|
const cfg = b();
|
|
25139
25351
|
if (!cfg.master.root || !cfg.llm.root)
|
|
25140
25352
|
throw new Error("BrainLLM not bootstrapped \u2014 run bootstrap.");
|
|
25141
|
-
|
|
25353
|
+
let continued = false;
|
|
25354
|
+
if (continuing) {
|
|
25355
|
+
const prior = await trilium.searchNotes(`#noteType=session #created='${d}'`, { ancestorNoteId: cfg.memory.sessions, fastSearch: true, limit: 1 });
|
|
25356
|
+
const priorContent = prior.results[0] ? await trilium.getNoteContent(prior.results[0].noteId).catch(() => "") : "";
|
|
25357
|
+
continued = /<h2(?:\s[^>]*)?>\s*Addendum/i.test(priorContent);
|
|
25358
|
+
if (!continued) {
|
|
25359
|
+
return err("not_a_continuation", `continuing=true needs a session note for ${d} that already carries an addendum \u2014 there is none, so this is the day's first session.`, "Run the full pre-close protocol: session() \u2192 addendum() \u2192 maintain() \u2192 remarks() \u2192 diary() \u2192 close().");
|
|
25360
|
+
}
|
|
25361
|
+
}
|
|
25362
|
+
const writeGate = await readGate(d);
|
|
25363
|
+
let gateNote;
|
|
25364
|
+
if (wroteSinceClose && !writeGate.has("write")) {
|
|
25365
|
+
if (await markWriteStep())
|
|
25366
|
+
writeGate.set("write", 1);
|
|
25367
|
+
else
|
|
25368
|
+
gateNote = writeMarkPending ?? "the durable write gate could not be repaired";
|
|
25369
|
+
}
|
|
25370
|
+
if (!(writeGate.has("write") || wroteSinceClose)) {
|
|
25142
25371
|
await markStep("session", d);
|
|
25372
|
+
await stampSessionScope(d, scope ?? "interactive");
|
|
25143
25373
|
return txt3({
|
|
25144
25374
|
date: d,
|
|
25375
|
+
...continued ? { continuing: true } : {},
|
|
25145
25376
|
closeMode: "light",
|
|
25146
|
-
note: "
|
|
25377
|
+
note: "Light close \u2014 strictly because no content was written this session: addendum() and maintain() are not required and there is nothing to audit. If you learned something durable about the user or yourself, revise that singleton first \u2014 that makes it a full close, and session() will say so when re-run.",
|
|
25147
25378
|
next: [
|
|
25148
25379
|
"Call remarks() \u2014 one short cue for a short session.",
|
|
25149
25380
|
"Call diary() \u2014 one short paragraph: what the session was and anything worth the team knowing.",
|
|
@@ -25151,6 +25382,49 @@ The close sequence next[] lays out: singleton updates from the session, then add
|
|
|
25151
25382
|
]
|
|
25152
25383
|
});
|
|
25153
25384
|
}
|
|
25385
|
+
const diaryEntryOfToday = async () => {
|
|
25386
|
+
if (!cfg.llm.diary)
|
|
25387
|
+
return null;
|
|
25388
|
+
const diarySearch = await trilium.searchNotes(`#noteType=diary #created='${d}'`, { ancestorNoteId: cfg.llm.diary, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
|
|
25389
|
+
if (!diarySearch.results[0])
|
|
25390
|
+
return null;
|
|
25391
|
+
const id = diarySearch.results[0].noteId;
|
|
25392
|
+
const content = await trilium.getNoteContent(id).catch(() => "");
|
|
25393
|
+
return full ? { id, content } : { id, blocks: (content.match(/<h2(?:\s[^>]*)?>\s*Addendum/gi) ?? []).length, size: content.length };
|
|
25394
|
+
};
|
|
25395
|
+
const pendingAddendumCount = async () => {
|
|
25396
|
+
try {
|
|
25397
|
+
const r = await trilium.searchNotes("#noteType note.content *=* 'Addendum'", { ancestorNoteId: cfg.root, fastSearch: false, limit: 40 });
|
|
25398
|
+
const candidates = r.results.filter((n) => {
|
|
25399
|
+
const kind = labelOf2(n, "noteType");
|
|
25400
|
+
return kind && !["session", "diary", "log", "threadEntry", "thread"].includes(kind);
|
|
25401
|
+
});
|
|
25402
|
+
const contents = await Promise.all(candidates.map((n) => trilium.getNoteContent(n.noteId).catch(() => "")));
|
|
25403
|
+
return contents.filter(hasAddendumMarker).length;
|
|
25404
|
+
} catch {
|
|
25405
|
+
return null;
|
|
25406
|
+
}
|
|
25407
|
+
};
|
|
25408
|
+
if (continued) {
|
|
25409
|
+
const hygiene = await sweep(trilium, cfg, { deep: false, dryRun: false }).catch(() => null);
|
|
25410
|
+
const pendingAddendums = await pendingAddendumCount();
|
|
25411
|
+
await markStep("session", d);
|
|
25412
|
+
await stampSessionScope(d, scope ?? "interactive");
|
|
25413
|
+
return txt3({
|
|
25414
|
+
date: d,
|
|
25415
|
+
continuing: true,
|
|
25416
|
+
note: "Continuing session \u2014 the singleton review and audit already ran with the day's first close, so this receipt carries only what the next close needs. If this stretch changed a singleton, revise() it before close() all the same.",
|
|
25417
|
+
diary: await diaryEntryOfToday(),
|
|
25418
|
+
maintenance: hygiene ? { scanned: hygiene.scanned, fixed: hygiene.fixed.length, transitions: hygiene.transitions, flagged: hygiene.flagged, ...hygiene.suppressed ? { suppressed: hygiene.suppressed } : {} } : "skipped",
|
|
25419
|
+
pending: { addendums: pendingAddendums === null ? "unknown" : pendingAddendums },
|
|
25420
|
+
next: [
|
|
25421
|
+
pendingAddendums === 0 ? "Call addendum() \u2014 0 notes currently carry addendum markers, so this should come back clean; the call still satisfies the gate." : pendingAddendums === null ? "Call addendum() \u2014 fold what this stretch's work left behind." : `Call addendum() \u2014 ${pendingAddendums} note(s) carry addendum markers; fold each into its section.`,
|
|
25422
|
+
"Call maintain() \u2014 the same hygiene pass as any session.",
|
|
25423
|
+
"Call remarks(), then diary() \u2014 the cues, then this stretch's record.",
|
|
25424
|
+
"Call close(continuing=true) \u2014 commit this stretch's log; the day's first close already carried the full sequence."
|
|
25425
|
+
]
|
|
25426
|
+
});
|
|
25427
|
+
}
|
|
25154
25428
|
const fetchSingleton = async (id) => {
|
|
25155
25429
|
if (!full) {
|
|
25156
25430
|
const note = await trilium.getNote(id);
|
|
@@ -25172,24 +25446,9 @@ The close sequence next[] lays out: singleton updates from the session, then add
|
|
|
25172
25446
|
fetchSingleton(cfg.llm.protocols),
|
|
25173
25447
|
cfg.llm.selfcorrection ? fetchSingleton(cfg.llm.selfcorrection) : Promise.resolve(null)
|
|
25174
25448
|
]);
|
|
25175
|
-
|
|
25176
|
-
if (cfg.llm.diary) {
|
|
25177
|
-
const diarySearch = await trilium.searchNotes(`#noteType=diary #created='${d}'`, { ancestorNoteId: cfg.llm.diary, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
|
|
25178
|
-
if (diarySearch.results[0]) {
|
|
25179
|
-
const id = diarySearch.results[0].noteId;
|
|
25180
|
-
const content = await trilium.getNoteContent(id).catch(() => "");
|
|
25181
|
-
diaryEntry = full ? { id, content } : { id, blocks: (content.match(/<h2(?:\s[^>]*)?>\s*Addendum/gi) ?? []).length, size: content.length };
|
|
25182
|
-
}
|
|
25183
|
-
}
|
|
25449
|
+
const diaryEntry = await diaryEntryOfToday();
|
|
25184
25450
|
const hygiene = await sweep(trilium, cfg, { deep: false, dryRun: false }).catch(() => null);
|
|
25185
|
-
const pendingAddendums = await
|
|
25186
|
-
const candidates = r.results.filter((n) => {
|
|
25187
|
-
const kind = labelOf2(n, "noteType");
|
|
25188
|
-
return kind && !["session", "diary", "log", "threadEntry", "thread"].includes(kind);
|
|
25189
|
-
});
|
|
25190
|
-
const contents = await Promise.all(candidates.map((n) => trilium.getNoteContent(n.noteId).catch(() => "")));
|
|
25191
|
-
return contents.filter(hasAddendumMarker).length;
|
|
25192
|
-
}).catch(() => null);
|
|
25451
|
+
const pendingAddendums = await pendingAddendumCount();
|
|
25193
25452
|
const singletonStubs = {
|
|
25194
25453
|
biography,
|
|
25195
25454
|
goals,
|
|
@@ -25212,11 +25471,13 @@ The close sequence next[] lays out: singleton updates from the session, then add
|
|
|
25212
25471
|
singletonsWrittenToday: touchedToday.length ? touchedToday : "none"
|
|
25213
25472
|
};
|
|
25214
25473
|
await markStep("session", d);
|
|
25474
|
+
await stampSessionScope(d, scope ?? "interactive");
|
|
25215
25475
|
const scoped = scope === "agent";
|
|
25216
25476
|
return txt3({
|
|
25217
25477
|
date: d,
|
|
25218
25478
|
...scoped ? { scope: "agent" } : {},
|
|
25219
|
-
...!full ? {
|
|
25479
|
+
...!full ? { singletons: "stubs", note: "Singleton content and the diary body are omitted (default) \u2014 fetch via master()/llm() only where lastModified indicates a revision is needed; start() already served all singletons in full, and diary() needs only the id. This is stub mode, not a light close: 'light' means only that no content was written this session." } : {},
|
|
25480
|
+
...gateNote ? { gateNote } : {},
|
|
25220
25481
|
master: { biography, goals, preferences },
|
|
25221
25482
|
llm: { responsibilities, protocols, ...selfcorrection ? { selfcorrection } : {} },
|
|
25222
25483
|
diary: diaryEntry,
|
|
@@ -25255,6 +25516,7 @@ The close sequence next[] lays out: singleton updates from the session, then add
|
|
|
25255
25516
|
if (!(await readGate(localToday())).has("write")) {
|
|
25256
25517
|
return txt3({
|
|
25257
25518
|
closeMode: "light",
|
|
25519
|
+
note: "Light close \u2014 strictly because no content was written this session: addendum() and maintain() are not required and nothing is audited. The full cues return the moment any content is written.",
|
|
25258
25520
|
cue: "One short paragraph, first person: what this session was, how it went, and anything about BrainLLM worth the team knowing. Skip what has nothing real to say.",
|
|
25259
25521
|
next: ["Write it via diary(body, identity), then call close()."]
|
|
25260
25522
|
});
|
|
@@ -25302,7 +25564,9 @@ The close sequence next[] lays out: singleton updates from the session, then add
|
|
|
25302
25564
|
}
|
|
25303
25565
|
}
|
|
25304
25566
|
const gate = await readGate(gateDate);
|
|
25305
|
-
|
|
25567
|
+
if (wroteSinceClose && !gate.has("write") && await markWriteStep())
|
|
25568
|
+
gate.set("write", 1);
|
|
25569
|
+
const light = !(gate.has("write") || wroteSinceClose);
|
|
25306
25570
|
const required = light ? LIGHT_PRECLOSE_STEPS : REQUIRED_PRECLOSE_STEPS;
|
|
25307
25571
|
const missing = continued ? [] : required.filter((step) => !gate.has(step));
|
|
25308
25572
|
if (missing.length && !force) {
|
|
@@ -25391,6 +25655,10 @@ ${contentBlock}` }));
|
|
|
25391
25655
|
}
|
|
25392
25656
|
}
|
|
25393
25657
|
await clearGate(d);
|
|
25658
|
+
const closeScope = await trilium.getNote(noteId).then((n) => labelOf2(n, "scope")).catch(() => null);
|
|
25659
|
+
if (closeScope === "interactive") {
|
|
25660
|
+
await trilium.updateLabelValue(noteId, "interactive", localNowTime()).catch(() => null);
|
|
25661
|
+
}
|
|
25394
25662
|
return txt3({
|
|
25395
25663
|
action,
|
|
25396
25664
|
noteId,
|
|
@@ -25403,6 +25671,7 @@ ${contentBlock}` }));
|
|
|
25403
25671
|
...continued ? { continuing: true } : {},
|
|
25404
25672
|
...sealReport ? { seal: `${sealReport.records} record(s) of ${d} sealed (${sealReport.digest.slice(0, 12)}\u2026), chained to the previous sealed day` } : { seal: "not written \u2014 the day's log note was unavailable" },
|
|
25405
25673
|
...light && !continued ? { closeMode: "light \u2014 no content was written this session, so addendum() and maintain() were not required" } : {},
|
|
25674
|
+
...writeMarkPending ? { gateNote: writeMarkPending } : {},
|
|
25406
25675
|
...missing.length || !orderOk ? { bypassed: [...missing, ...!orderOk ? ["ordering(session\u2192remarks\u2192diary)"] : []] } : {},
|
|
25407
25676
|
...warnings.length ? { sanitized: warnings } : {}
|
|
25408
25677
|
});
|
|
@@ -25536,7 +25805,7 @@ ${html}` }));
|
|
|
25536
25805
|
});
|
|
25537
25806
|
server.tool("remember", `Store something the moment it matters; the server owns placement, labels and dedup.
|
|
25538
25807
|
|
|
25539
|
-
Kinds: biography | goals | preferences | responsibilities | protocols (singletons, upsert) \xB7 thread (goal= required to create; shape="collection" for a titled-entry thread; appends need identity=) \xB7 threadEntry (a titled entry in a collection thread: thread= + title) \xB7 user (knowledge about the user) \xB7 information (domain= + title) \xB7 sources (domain=; revision=[\u2026] upserts rows).
|
|
25808
|
+
Kinds: biography | goals | preferences | responsibilities | protocols (singletons, upsert) \xB7 thread (goal= required to create; shape="collection" for a titled-entry thread; appends need identity=) \xB7 threadEntry (a titled entry in a collection thread: thread= + title) \xB7 user (knowledge about the user) \xB7 information (domain= + title) \xB7 sources (domain=; revision=[\u2026] upserts rows; dryRun=true previews rows and near-duplicate keys without writing).
|
|
25540
25809
|
|
|
25541
25810
|
Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopting an existing note, and check action= on the receipt. connect=[{relation, toNoteId}] wires relations in the same call. diary/session/log/claim/domain have dedicated paths.`, {
|
|
25542
25811
|
kind: enumType(Kinds).describe("What kind of memory this is"),
|
|
@@ -25557,17 +25826,20 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
25557
25826
|
mandate: booleanType().optional().describe("kind=information: flag the note as a standing brief a session must follow (#mandate)"),
|
|
25558
25827
|
mustCreate: booleanType().optional().describe("Refuse instead of adopting a note that already has this title"),
|
|
25559
25828
|
strict: booleanType().optional().describe("Refuse the write when the body needs structural repair (unclosed tags, <br> runs) instead of accepting the repaired form"),
|
|
25829
|
+
dryRun: booleanType().optional().describe('kind="sources": preview which Revision rows would be added or updated \u2014 and which body groups would merge \u2014 without writing anything. The way to check a new source key before it lands; default false (the call writes), so re-run without dryRun to apply'),
|
|
25560
25830
|
connect: arrayType(objectType({
|
|
25561
25831
|
relation: enumType(RelationTypes),
|
|
25562
25832
|
toNoteId: stringType()
|
|
25563
25833
|
})).optional().describe("Relations to wire from this note in the same call \u2014 same semantics as connect() (idempotent, worksWith wired both ways)"),
|
|
25564
25834
|
icon: stringType().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name ("brain"); normalized server-side'),
|
|
25565
25835
|
date: stringType().optional().describe("ISO date override (default: today)")
|
|
25566
|
-
}, async ({ kind, title, body, goal, shape, thread, identity, domain, revision, topics, supersedes, mandate, mustCreate, strict, connect: connectRels, icon, date }) => {
|
|
25836
|
+
}, async ({ kind, title, body, goal, shape, thread, identity, domain, revision, topics, supersedes, mandate, mustCreate, strict, dryRun, connect: connectRels, icon, date }) => {
|
|
25567
25837
|
const refuseAdoption = (existingId, existingTitle, where) => err("already_exists", `A ${kind} note titled "${existingTitle}" already exists ${where} [${existingId}] \u2014 mustCreate=true refuses to adopt it.`, `Read it first with ${kind === "thread" ? "memory" : "knowledge"}(${existingId}). To add to it deliberately, re-run without mustCreate, or use revise(${existingId}, \u2026). To keep both, pick a title that distinguishes them.`);
|
|
25568
25838
|
const opts = { domain, topics, date, ...mandate ? { mandate: true } : {} };
|
|
25569
25839
|
const d = checkedDate(date);
|
|
25570
25840
|
const { html, warnings: sanitizeWarnings } = renderBody(body ?? "");
|
|
25841
|
+
if (dryRun !== undefined && kind !== "sources")
|
|
25842
|
+
return err("conflicting_params", 'dryRun= applies only to remember(kind="sources"); nothing was written.', "For other kinds, read first (or use maintain(dryRun=true) for hygiene) and then write without dryRun.");
|
|
25571
25843
|
if (strict) {
|
|
25572
25844
|
const repairs = repairedStructure(sanitizeWarnings);
|
|
25573
25845
|
if (repairs.length) {
|
|
@@ -25610,6 +25882,20 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
25610
25882
|
return wiredEdges;
|
|
25611
25883
|
};
|
|
25612
25884
|
const ORPHAN_HINT = "Unconnected \u2014 wire a real relation now with connect() (or pass connect=[{relation, toNoteId}] on remember) so this note doesn't surface as an orphan in maintain(deep=true).";
|
|
25885
|
+
const sourceKeyHints = (added, existing) => {
|
|
25886
|
+
const pairs = [];
|
|
25887
|
+
for (const a of added) {
|
|
25888
|
+
const near = nearestKey(a, existing);
|
|
25889
|
+
if (near)
|
|
25890
|
+
pairs.push({ added: a, near });
|
|
25891
|
+
}
|
|
25892
|
+
if (!pairs.length)
|
|
25893
|
+
return null;
|
|
25894
|
+
return {
|
|
25895
|
+
didYouMean: pairs[0].near.key,
|
|
25896
|
+
didYouMeanNote: `${pairs.map((p) => `"${p.added}" \u2192 "${p.near.key}"`).join("; ")}. New Revision row(s) sit beside an existing source whose key is close \u2014 if one was meant to update that source, its key must match it exactly.`
|
|
25897
|
+
};
|
|
25898
|
+
};
|
|
25613
25899
|
if (isSingleton(kind)) {
|
|
25614
25900
|
const id = kindHome(b(), kind);
|
|
25615
25901
|
if (!id)
|
|
@@ -25623,6 +25909,48 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
25623
25909
|
if (kind === "sources") {
|
|
25624
25910
|
if (!domain)
|
|
25625
25911
|
return err("missing_param", 'kind="sources" requires a domain.', 'Call remember(kind="sources", domain="<domain name>", body="...")');
|
|
25912
|
+
if (dryRun) {
|
|
25913
|
+
const slug = slugify(domain) || "general";
|
|
25914
|
+
const domRes = await trilium.searchNotes(`#noteType=domain #domain='${escapeQueryValue(slug)}'`, { ancestorNoteId: b().knowledge.domains, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
|
|
25915
|
+
const domainNote = domRes.results[0];
|
|
25916
|
+
const srcRes = domainNote ? await trilium.searchNotes("#noteType=sources", { ancestorNoteId: domainNote.noteId, fastSearch: true, limit: 1 }).catch(() => ({ results: [] })) : { results: [] };
|
|
25917
|
+
const display = domainNote?.title ?? domain.replace(/\s+/g, " ").trim();
|
|
25918
|
+
const preview = { ok: true, action: "dry_run", dryRun: true, kind, date: d, domain: display };
|
|
25919
|
+
const sidPreview = srcRes.results[0]?.noteId;
|
|
25920
|
+
if (!domainNote)
|
|
25921
|
+
preview.wouldCreate = `domain "${display}" and its canonical Sources note`;
|
|
25922
|
+
else if (!sidPreview)
|
|
25923
|
+
preview.wouldCreate = `${domainNote.title} \u2192 Sources note`;
|
|
25924
|
+
else {
|
|
25925
|
+
const content = await trilium.getNoteContent(sidPreview);
|
|
25926
|
+
const existingKeys = tableRows(content, "Revision").map((cells) => cells[0]).filter(Boolean);
|
|
25927
|
+
const previewChanges = [];
|
|
25928
|
+
const previewAdded = [];
|
|
25929
|
+
let sim = content;
|
|
25930
|
+
for (const row of revision ?? []) {
|
|
25931
|
+
const result = upsertTableRow(sim, "Revision", row.source, [row.marker, row.date ?? d]);
|
|
25932
|
+
if (result.matched || result.created) {
|
|
25933
|
+
sim = result.html;
|
|
25934
|
+
previewChanges.push(`${result.matched ? "would update" : "would add"}: ${row.source}`);
|
|
25935
|
+
if (!result.matched)
|
|
25936
|
+
previewAdded.push(row.source);
|
|
25937
|
+
}
|
|
25938
|
+
}
|
|
25939
|
+
if (previewChanges.length)
|
|
25940
|
+
preview.revision = previewChanges;
|
|
25941
|
+
if (html && toText(html, 50) && !content.includes(html)) {
|
|
25942
|
+
const merged = mergeUnderSection(content, html, "Sources");
|
|
25943
|
+
preview.body = `would merge into the Sources section${merged.mergedInto.length ? `: ${merged.mergedInto.join(", ")}` : ""}${merged.appended.length ? `; new group(s): ${merged.appended.join(", ")}` : ""}`;
|
|
25944
|
+
}
|
|
25945
|
+
const hints = sourceKeyHints(previewAdded, existingKeys);
|
|
25946
|
+
if (hints) {
|
|
25947
|
+
preview.didYouMean = hints.didYouMean;
|
|
25948
|
+
preview.didYouMeanNote = hints.didYouMeanNote;
|
|
25949
|
+
}
|
|
25950
|
+
}
|
|
25951
|
+
preview.next = "Nothing was written. Re-run without dryRun (or with dryRun=false) to apply exactly this.";
|
|
25952
|
+
return txt3(preview);
|
|
25953
|
+
}
|
|
25626
25954
|
const { domainId, domainTitle, createdDomain, sourcesId } = await resolveDomain(trilium, b(), domain);
|
|
25627
25955
|
const found = sourcesId ? { results: [] } : await trilium.searchNotes("#noteType=sources", { ancestorNoteId: domainId, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
|
|
25628
25956
|
let sid = sourcesId ?? found.results[0]?.noteId;
|
|
@@ -25648,8 +25976,11 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
25648
25976
|
}
|
|
25649
25977
|
}
|
|
25650
25978
|
const revisionChanges = [];
|
|
25979
|
+
const addedKeys = [];
|
|
25980
|
+
let existingKeys = [];
|
|
25651
25981
|
if (sid && revision?.length) {
|
|
25652
25982
|
let current = await trilium.getNoteContent(sid);
|
|
25983
|
+
existingKeys = tableRows(current, "Revision").map((cells) => cells[0]).filter(Boolean);
|
|
25653
25984
|
let changed = false;
|
|
25654
25985
|
for (const row of revision) {
|
|
25655
25986
|
const result = upsertTableRow(current, "Revision", row.source, [row.marker, row.date ?? d]);
|
|
@@ -25657,6 +25988,8 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
25657
25988
|
current = result.html;
|
|
25658
25989
|
changed = true;
|
|
25659
25990
|
revisionChanges.push(`${result.matched ? "updated" : "added"}: ${row.source}`);
|
|
25991
|
+
if (!result.matched)
|
|
25992
|
+
addedKeys.push(row.source);
|
|
25660
25993
|
}
|
|
25661
25994
|
}
|
|
25662
25995
|
if (changed) {
|
|
@@ -25676,6 +26009,7 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
25676
26009
|
const placeholderLeft = hasPlaceholderRow(finalHtml, "Revision");
|
|
25677
26010
|
const bodyWritten = !!html && !!toText(html, 50);
|
|
25678
26011
|
const looseProse = bodyWritten && !createdDomain && !/<(?:ul|ol|li|h[34]|table)\b/i.test(html);
|
|
26012
|
+
const keyHints = sourceKeyHints(addedKeys, existingKeys);
|
|
25679
26013
|
return txt3({
|
|
25680
26014
|
action: wrote ? "maintained" : "already_written",
|
|
25681
26015
|
noteId: sid,
|
|
@@ -25687,6 +26021,7 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
25687
26021
|
...sourceMerge,
|
|
25688
26022
|
...revisionKeys.length ? { revisionRows: revisionKeys.length } : {},
|
|
25689
26023
|
...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." } : {},
|
|
26024
|
+
...keyHints ? { didYouMean: keyHints.didYouMean, didYouMeanNote: keyHints.didYouMeanNote } : {},
|
|
25690
26025
|
...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." } : {},
|
|
25691
26026
|
...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." } : {},
|
|
25692
26027
|
...connected.length ? { connected } : {},
|
|
@@ -25792,6 +26127,7 @@ ${html}` : html;
|
|
|
25792
26127
|
if (kind === "thread") {
|
|
25793
26128
|
if (isCollectionThread(existing))
|
|
25794
26129
|
return collectionAppendRefusal(existing);
|
|
26130
|
+
const runHeading = leadingIdentityText(block);
|
|
25795
26131
|
const entry = await appendThreadEntry(existing.noteId, block, d);
|
|
25796
26132
|
if (entry.action === "already_written") {
|
|
25797
26133
|
return txt3({ action: "already_written", noteId: existing.noteId, entryId: entry.noteId, kind, title: existing.title });
|
|
@@ -25806,7 +26142,7 @@ ${html}` : html;
|
|
|
25806
26142
|
const connected = await wireRequested(existing.noteId);
|
|
25807
26143
|
const iconSet = await applyIcon(existing.noteId, icon);
|
|
25808
26144
|
const relations = relationSnippet(existing);
|
|
25809
|
-
return txt3({ action: "updated", noteId: existing.noteId, entryId: entry.noteId, entryAction: entry.action, entry: `today's [${d}] entry in this thread; revise(entryId) edits it`, kind, title: existing.title, ...connected.length ? { connected } : {}, ...iconSet ? { icon: iconSet } : {}, ...relations ? { relations } : {}, ...sanitizeWarnings.length ? { sanitized: sanitizeWarnings } : {} });
|
|
26145
|
+
return txt3({ action: "updated", noteId: existing.noteId, entryId: entry.noteId, entryAction: entry.action, ...entry.marker ? { entryMarker: entry.marker } : {}, ...runHeading ? { runHeading } : {}, 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 } : {} });
|
|
25810
26146
|
}
|
|
25811
26147
|
const current = await trilium.getNoteContent(existing.noteId);
|
|
25812
26148
|
if (isDuplicateAppend(current, block))
|
|
@@ -25870,7 +26206,7 @@ ${block}`), d);
|
|
|
25870
26206
|
...sanitizeWarnings.length ? { sanitized: sanitizeWarnings } : {}
|
|
25871
26207
|
});
|
|
25872
26208
|
});
|
|
25873
|
-
server.tool("recall", `Ranked search across the whole brain \u2014 label, title and full-text strategies merged, with kind and status. Pass domain= whenever you know the area. orderBy/orderDirection sort by date; fastSearch= scans titles and labels only; regex= matches note bodies with a real regular expression; a fuzzy pass runs automatically when exact results are thin (hits marked fuzzy: true are leads, not answers). Recently changed notes rank slightly higher at equal relevance.
|
|
26209
|
+
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. since=/until= (ISO dates) keep only notes last modified inside the window \u2014 ordered, ranked, regex and fuzzy paths all honour them. Every hit names matchedBy (label, title, text, ordered, fuzzy, regex) and the top hits carry evidence \u2014 the matched passage; a label hit quotes its label, a title hit its title, regex hits carry evidence on every hit. Records are included unless kinds= narrows. includeArchived= includes archived notes. A thin result is evidence about the query, not the brain.`, {
|
|
25874
26210
|
query: stringType().describe("What to find \u2014 natural phrasing is fine"),
|
|
25875
26211
|
kinds: arrayType(enumType(Kinds)).optional().describe("Restrict to these kinds"),
|
|
25876
26212
|
domain: stringType().optional().describe("Restrict to a knowledge domain"),
|
|
@@ -25880,8 +26216,10 @@ ${block}`), d);
|
|
|
25880
26216
|
orderDirection: enumType(["asc", "desc"]).optional().describe("asc | desc (default: desc for dates, asc for title)"),
|
|
25881
26217
|
fastSearch: booleanType().optional().describe("Title/label only \u2014 faster, skips full-text body scan"),
|
|
25882
26218
|
regex: stringType().optional().describe("Regular expression matched against note bodies (Trilium %= operator). Takes precedence over the keyword strategies; escape backslashes."),
|
|
25883
|
-
fuzzy: booleanType().optional().describe("Force the fuzzy pass on (default: automatic when exact strategies return few results) or off")
|
|
25884
|
-
|
|
26219
|
+
fuzzy: booleanType().optional().describe("Force the fuzzy pass on (default: automatic when exact strategies return few results) or off"),
|
|
26220
|
+
since: stringType().optional().describe("Only notes last modified on or after this ISO date (YYYY-MM-DD covers that whole day; a full ISO timestamp compares exactly)"),
|
|
26221
|
+
until: stringType().optional().describe("Only notes last modified on or before this ISO date (inclusive; a date-only bound covers that whole day)")
|
|
26222
|
+
}, async ({ query, kinds, domain, includeArchived, limit, orderBy, orderDirection, fastSearch, regex, fuzzy, since, until }) => {
|
|
25885
26223
|
const cfg = b();
|
|
25886
26224
|
const max = limit ?? 10;
|
|
25887
26225
|
const fast = fastSearch ?? false;
|
|
@@ -25889,13 +26227,26 @@ ${block}`), d);
|
|
|
25889
26227
|
const tokens = queryTokens(query);
|
|
25890
26228
|
const domSlug = domain ? slugify(domain) : null;
|
|
25891
26229
|
const kindSet = kinds?.length ? new Set(kinds) : null;
|
|
25892
|
-
const
|
|
26230
|
+
for (const [name, value] of [["since", since], ["until", until]]) {
|
|
26231
|
+
if (value !== undefined && Number.isNaN(Date.parse(value)))
|
|
26232
|
+
return err("invalid_param", `${name}="${value}" is not an ISO date.`, `Use YYYY-MM-DD (covers that whole day) or a full ISO timestamp, e.g. ${name}=2026-10-01.`);
|
|
26233
|
+
}
|
|
26234
|
+
let strategyErrors = 0;
|
|
26235
|
+
const saturated = new Set;
|
|
26236
|
+
const run = (q, useFast = false, strategy, ord) => trilium.searchNotes(q, {
|
|
25893
26237
|
ancestorNoteId: cfg.root,
|
|
25894
26238
|
limit: 30,
|
|
25895
26239
|
fastSearch: useFast,
|
|
25896
26240
|
includeArchivedNotes: includeArchived ?? false,
|
|
25897
26241
|
...ord ?? {}
|
|
25898
|
-
}).then((r) =>
|
|
26242
|
+
}).then((r) => {
|
|
26243
|
+
if (strategy && r.results.length >= 30)
|
|
26244
|
+
saturated.add(strategy);
|
|
26245
|
+
return r.results;
|
|
26246
|
+
}).catch(() => {
|
|
26247
|
+
strategyErrors++;
|
|
26248
|
+
return [];
|
|
26249
|
+
});
|
|
25899
26250
|
const filterNote = (note) => {
|
|
25900
26251
|
const k = labelOf2(note, "noteType");
|
|
25901
26252
|
if (!k)
|
|
@@ -25904,9 +26255,25 @@ ${block}`), d);
|
|
|
25904
26255
|
return false;
|
|
25905
26256
|
if (domSlug && labelOf2(note, "domain") !== domSlug)
|
|
25906
26257
|
return false;
|
|
26258
|
+
if (!withinModifiedWindow(note.dateModified, since, until))
|
|
26259
|
+
return false;
|
|
25907
26260
|
return true;
|
|
25908
26261
|
};
|
|
25909
|
-
const
|
|
26262
|
+
const evidenceFor = (note, content, matchedBy) => {
|
|
26263
|
+
if (matchedBy === "label") {
|
|
26264
|
+
const parts = [
|
|
26265
|
+
labelOf2(note, "topic") === slug ? `#topic=${slug}` : null,
|
|
26266
|
+
labelOf2(note, "domain") === slug ? `#domain=${slug}` : null
|
|
26267
|
+
].filter(Boolean);
|
|
26268
|
+
return parts.length ? parts.join(" ") : null;
|
|
26269
|
+
}
|
|
26270
|
+
if (matchedBy === "title" || matchedBy === "fuzzy")
|
|
26271
|
+
return note.title.length > 200 ? `${note.title.slice(0, 199)}\u2026` : note.title;
|
|
26272
|
+
if (matchedBy === "regex")
|
|
26273
|
+
return null;
|
|
26274
|
+
return tokenPassage(toText(content, 1e5), tokens);
|
|
26275
|
+
};
|
|
26276
|
+
const buildResult = async (note, i, matchedBy) => {
|
|
25910
26277
|
const relations = relationSnippet(note);
|
|
25911
26278
|
const base = {
|
|
25912
26279
|
id: note.noteId,
|
|
@@ -25915,11 +26282,13 @@ ${block}`), d);
|
|
|
25915
26282
|
status: labelOf2(note, "status"),
|
|
25916
26283
|
updated: note.dateModified.slice(0, 10),
|
|
25917
26284
|
...hasLabel(note, "archived") ? { archived: true } : {},
|
|
25918
|
-
...relations ? { relations } : {}
|
|
26285
|
+
...relations ? { relations } : {},
|
|
26286
|
+
...matchedBy ? { matchedBy } : {}
|
|
25919
26287
|
};
|
|
25920
26288
|
if (i < 3) {
|
|
25921
26289
|
const content = await trilium.getNoteContent(note.noteId).catch(() => "");
|
|
25922
|
-
|
|
26290
|
+
const evidence = matchedBy ? evidenceFor(note, content, matchedBy) : null;
|
|
26291
|
+
return { ...base, snippet: toText(content, 280), ...evidence ? { evidence } : {} };
|
|
25923
26292
|
}
|
|
25924
26293
|
return base;
|
|
25925
26294
|
};
|
|
@@ -25942,11 +26311,7 @@ ${block}`), d);
|
|
|
25942
26311
|
let recordHits = 0;
|
|
25943
26312
|
const evidenceIn = (text) => {
|
|
25944
26313
|
const m = new RegExp(re.source, "i").exec(text);
|
|
25945
|
-
|
|
25946
|
-
return "";
|
|
25947
|
-
const from = Math.max(0, m.index - 80);
|
|
25948
|
-
const to = Math.min(text.length, m.index + m[0].length + 80);
|
|
25949
|
-
return `${from > 0 ? "\u2026" : ""}${text.slice(from, to).replace(/\s+/g, " ").trim()}${to < text.length ? "\u2026" : ""}`;
|
|
26314
|
+
return m ? passageAround(text, m.index, m[0].length) : "";
|
|
25950
26315
|
};
|
|
25951
26316
|
for (const n of candidates) {
|
|
25952
26317
|
if (confirmed.length >= max)
|
|
@@ -25971,49 +26336,60 @@ ${block}`), d);
|
|
|
25971
26336
|
}
|
|
25972
26337
|
const results = await Promise.all(confirmed.map(async ({ note, evidence }, i) => {
|
|
25973
26338
|
const { snippet: _snippet, ...base } = await buildResult(note, i);
|
|
25974
|
-
return { ...base, ...evidence ? { evidence } : {} };
|
|
26339
|
+
return { ...base, ...evidence ? { evidence } : {}, matchedBy: "regex" };
|
|
25975
26340
|
}));
|
|
26341
|
+
const failed = strategyErrors > 0 && results.length === 0;
|
|
25976
26342
|
const notes = [
|
|
25977
26343
|
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,
|
|
25978
26344
|
tagSpanning ? `${tagSpanning} match(es) were found only after stripping markup \u2014 the phrase is split by an inline tag there.` : null,
|
|
25979
26345
|
widened ? "Diacritic characters (\xB7, accents) were normalised away for Trilium's pre-filter, which strips them from note text before matching; every result was then verified against your exact pattern." : null,
|
|
25980
26346
|
recordHits ? `${recordHits} result(s) are records (sessions, diary, dated thread entries) \u2014 included by default; kinds= narrows to maintained notes.` : null,
|
|
25981
26347
|
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,
|
|
25982
|
-
|
|
25983
|
-
results.length === 0 &&
|
|
26348
|
+
failed ? SEARCH_FAILURE_NOTE : null,
|
|
26349
|
+
results.length === 0 && !failed ? "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,
|
|
26350
|
+
results.length === 0 && !failed && 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
|
|
25984
26351
|
].filter(Boolean);
|
|
25985
26352
|
return txt3({
|
|
25986
26353
|
mode: "regex",
|
|
25987
26354
|
pattern: regex,
|
|
25988
26355
|
results,
|
|
26356
|
+
...failed ? { searchFailed: true } : {},
|
|
25989
26357
|
...notes.length ? { note: notes.join(" ") } : {}
|
|
25990
26358
|
});
|
|
25991
26359
|
}
|
|
25992
26360
|
if (orderBy) {
|
|
25993
26361
|
const ord = { orderBy, orderDirection: orderDirection ?? (orderBy === "title" ? "asc" : "desc") };
|
|
25994
26362
|
const q = query.trim() ? escapeQueryValue(query) : "#noteType";
|
|
25995
|
-
const notes = await run(q, fast, ord);
|
|
26363
|
+
const notes = await run(q, fast, "ordered", ord);
|
|
25996
26364
|
const filtered = notes.filter(filterNote).slice(0, max);
|
|
25997
|
-
const results = await Promise.all(filtered.map(buildResult));
|
|
25998
|
-
|
|
26365
|
+
const results = await Promise.all(filtered.map((n, i) => buildResult(n, i, "ordered")));
|
|
26366
|
+
const failed = strategyErrors > 0 && results.length === 0;
|
|
26367
|
+
const notices = [capWarning(saturated), results.length === 0 ? failed ? SEARCH_FAILURE_NOTE : noMatch.note : null].filter(Boolean);
|
|
26368
|
+
return txt3({
|
|
26369
|
+
results,
|
|
26370
|
+
...failed ? { searchFailed: true } : {},
|
|
26371
|
+
...notices.length ? { note: notices.join(" ") } : {}
|
|
26372
|
+
});
|
|
25999
26373
|
}
|
|
26000
26374
|
const scores = new Map;
|
|
26001
|
-
const add = (notes, weight) => {
|
|
26375
|
+
const add = (notes, weight, strategy) => {
|
|
26002
26376
|
for (const n of notes) {
|
|
26003
26377
|
const entry = scores.get(n.noteId);
|
|
26004
|
-
if (entry)
|
|
26378
|
+
if (entry) {
|
|
26005
26379
|
entry.score += weight;
|
|
26006
|
-
|
|
26007
|
-
|
|
26380
|
+
entry.by.set(strategy, (entry.by.get(strategy) ?? 0) + weight);
|
|
26381
|
+
} else {
|
|
26382
|
+
scores.set(n.noteId, { note: n, score: weight, by: new Map([[strategy, weight]]) });
|
|
26383
|
+
}
|
|
26008
26384
|
}
|
|
26009
26385
|
};
|
|
26010
26386
|
const [byLabel, byTitleRaw, byText] = await Promise.all([
|
|
26011
|
-
slug.length >= 3 ? run(`#topic='${slug}' OR #domain='${slug}'`, true) : Promise.resolve([]),
|
|
26012
|
-
tokens.length ? run(tokens.map((t) => `note.title *=* '${escapeQueryValue(t)}'`).join(" OR "), fast || undefined) : Promise.resolve([]),
|
|
26013
|
-
fast ? Promise.resolve([]) : run(escapeQueryValue(query))
|
|
26387
|
+
slug.length >= 3 ? run(`#topic='${slug}' OR #domain='${slug}'`, true, "label") : Promise.resolve([]),
|
|
26388
|
+
tokens.length ? run(tokens.map((t) => `note.title *=* '${escapeQueryValue(t)}'`).join(" OR "), fast || undefined, "title") : Promise.resolve([]),
|
|
26389
|
+
fast ? Promise.resolve([]) : run(escapeQueryValue(query), false, "text")
|
|
26014
26390
|
]);
|
|
26015
|
-
add(byLabel, 3);
|
|
26016
|
-
add(byText, 1);
|
|
26391
|
+
add(byLabel, 3, "label");
|
|
26392
|
+
add(byText, 1, "text");
|
|
26017
26393
|
const queryKey = titleKey(query);
|
|
26018
26394
|
for (const n of byTitleRaw) {
|
|
26019
26395
|
const lowerTitle = n.title.toLowerCase();
|
|
@@ -26026,18 +26402,18 @@ ${block}`), d);
|
|
|
26026
26402
|
weight += 8;
|
|
26027
26403
|
else if (queryKey.startsWith(noteKey) || noteKey.startsWith(queryKey))
|
|
26028
26404
|
weight += 4;
|
|
26029
|
-
add([n], weight);
|
|
26405
|
+
add([n], weight, "title");
|
|
26030
26406
|
}
|
|
26031
26407
|
const EXACT_ENOUGH = 5;
|
|
26032
26408
|
const fuzzyTokens = tokens.filter((t) => t.length >= 3);
|
|
26033
26409
|
const wantFuzzy = fuzzy ?? (scores.size < EXACT_ENOUGH && fuzzyTokens.length > 0);
|
|
26034
26410
|
const fuzzyIds = new Set;
|
|
26035
26411
|
if (wantFuzzy && fuzzyTokens.length) {
|
|
26036
|
-
const byFuzzy = await run(fuzzyTokens.map((t) => `note.title ~* '${escapeQueryValue(t)}'`).join(" OR "), fast);
|
|
26412
|
+
const byFuzzy = await run(fuzzyTokens.map((t) => `note.title ~* '${escapeQueryValue(t)}'`).join(" OR "), fast, "fuzzy");
|
|
26037
26413
|
for (const n of byFuzzy)
|
|
26038
26414
|
if (!scores.has(n.noteId))
|
|
26039
26415
|
fuzzyIds.add(n.noteId);
|
|
26040
|
-
add(byFuzzy, 0.5);
|
|
26416
|
+
add(byFuzzy, 0.5, "fuzzy");
|
|
26041
26417
|
}
|
|
26042
26418
|
const now = Date.now();
|
|
26043
26419
|
const recency = (n) => {
|
|
@@ -26045,15 +26421,23 @@ ${block}`), d);
|
|
|
26045
26421
|
return Number.isFinite(age) ? 0.9 * Math.pow(0.5, age / 14) : 0;
|
|
26046
26422
|
};
|
|
26047
26423
|
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);
|
|
26048
|
-
const results = await Promise.all(ranked.map(async ({ note }, i) => {
|
|
26049
|
-
const row = await buildResult(note, i);
|
|
26424
|
+
const results = await Promise.all(ranked.map(async ({ note, by }, i) => {
|
|
26425
|
+
const row = await buildResult(note, i, dominantStrategy(by));
|
|
26050
26426
|
return fuzzyIds.has(note.noteId) ? { ...row, fuzzy: true } : row;
|
|
26051
26427
|
}));
|
|
26052
26428
|
const fuzzyCount = results.filter((r) => ("fuzzy" in r)).length;
|
|
26429
|
+
const failed = strategyErrors > 0 && results.length === 0;
|
|
26430
|
+
const notices = [
|
|
26431
|
+
capWarning(saturated),
|
|
26432
|
+
failed ? SEARCH_FAILURE_NOTE : null,
|
|
26433
|
+
strategyErrors > 0 && results.length > 0 ? `${strategyErrors} of the ranking queries failed at the backend \u2014 these results come from the strategies that responded, so the ranking may be partial.` : null,
|
|
26434
|
+
results.length === 0 && !failed ? noMatch.note : null
|
|
26435
|
+
].filter(Boolean);
|
|
26053
26436
|
return txt3({
|
|
26054
26437
|
results,
|
|
26055
26438
|
...fuzzyCount ? { fuzzyMatches: `${fuzzyCount} result(s) came from the fuzzy pass (marked fuzzy: true) \u2014 treat them as leads to verify, not as answers.` } : {},
|
|
26056
|
-
...
|
|
26439
|
+
...failed ? { searchFailed: true } : {},
|
|
26440
|
+
...notices.length ? { note: notices.join(" ") } : {}
|
|
26057
26441
|
});
|
|
26058
26442
|
});
|
|
26059
26443
|
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.`, {
|
|
@@ -26110,7 +26494,7 @@ ${block}`), d);
|
|
|
26110
26494
|
groups[kind] = [];
|
|
26111
26495
|
const relations = relationSnippet(n);
|
|
26112
26496
|
const idle = idleSince(n.dateModified);
|
|
26113
|
-
const stale = !RECORD_ROWS.has(kind) && idle >= staleAfter && !hasLabel(n, "archived");
|
|
26497
|
+
const stale = !RECORD_ROWS.has(kind) && idle >= staleAfter && !hasLabel(n, "archived") && !isAcknowledged(n);
|
|
26114
26498
|
if (stale)
|
|
26115
26499
|
staleCount++;
|
|
26116
26500
|
groups[kind].push({
|
|
@@ -26155,21 +26539,49 @@ ${block}`), d);
|
|
|
26155
26539
|
});
|
|
26156
26540
|
});
|
|
26157
26541
|
const READ_BUDGET = 80000;
|
|
26158
|
-
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
|
|
26542
|
+
server.tool("read", `Several note bodies in one round trip: ids=[\u2026] (up to 10) \u2192 {notes:[{id, title, kind, chars, content, relations}]}. Every entry carries chars, the size of that note's body in characters, so follow-up reads can be planned from this response alone. Bodies are returned in order until about 80k characters; the rest come back under deferred[] with their sizes, to read in a second call. section="<heading>" reads one section of each note instead of its whole body (h2\u2192h3\u2192h4 by heading text, occurrence= among repeats) \u2014 the cheap read of a huge note; a section holding a table also reports tableRows, its row count. 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.`, {
|
|
26159
26543
|
ids: arrayType(stringType()).min(1).max(10).describe("Note ids to read \u2014 one body per id, one round trip (cap 10)"),
|
|
26160
|
-
|
|
26161
|
-
|
|
26544
|
+
section: stringType().optional().describe("Read only this heading's section of each note (h2/h3/h4, same matching as the singletons' section=) instead of each whole body \u2014 the cheap read of a huge note; a miss returns the note's real headings under available[]"),
|
|
26545
|
+
occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)"),
|
|
26546
|
+
text: booleanType().optional().describe("Return readable plain text instead of stored HTML (default false; with section=, the section only)")
|
|
26547
|
+
}, async ({ ids, text, section, occurrence }) => {
|
|
26162
26548
|
const fetched = await Promise.all(ids.map(async (id) => {
|
|
26163
26549
|
try {
|
|
26164
26550
|
const note = await trilium.getNote(id);
|
|
26165
26551
|
const contentResult = await trilium.getNoteContentResult(id, note.type);
|
|
26166
26552
|
const relations = relationSnippet(note);
|
|
26167
26553
|
const raw = typeof contentResult === "string" ? contentResult : contentResult.content;
|
|
26554
|
+
const identity = { id, title: note.title, kind: ownedLabel(note, "noteType") ?? undefined, chars: raw.length };
|
|
26555
|
+
if (section && typeof contentResult !== "string") {
|
|
26556
|
+
return { ...identity, content: "", section, matched: false, hint: "Binary content cannot be sectioned as text; read it without section=." };
|
|
26557
|
+
}
|
|
26558
|
+
if (section) {
|
|
26559
|
+
const found = getSection(raw, section, occurrence ?? 1);
|
|
26560
|
+
if (!found.matched) {
|
|
26561
|
+
return {
|
|
26562
|
+
...identity,
|
|
26563
|
+
content: "",
|
|
26564
|
+
section,
|
|
26565
|
+
matched: false,
|
|
26566
|
+
available: found.available,
|
|
26567
|
+
hint: `No "${section}" heading at h2/h3/h4. available= lists the note's real heading texts \u2014 re-target from there rather than falling back to a whole-note read.`
|
|
26568
|
+
};
|
|
26569
|
+
}
|
|
26570
|
+
const tableRows = sectionTableRows(found.content);
|
|
26571
|
+
return {
|
|
26572
|
+
...identity,
|
|
26573
|
+
content: text ? readableText(found.content) : found.content,
|
|
26574
|
+
section,
|
|
26575
|
+
matched: true,
|
|
26576
|
+
...found.headingCount && found.headingCount > 1 ? { headingCount: found.headingCount, ...occurrence ? {} : { hint: `${found.headingCount} headings share that text \u2014 this is the FIRST. Pass occurrence= (1-${found.headingCount}) to read another.` } } : {},
|
|
26577
|
+
...tableRows !== undefined ? { tableRows } : {},
|
|
26578
|
+
...found.subsections?.length ? { subsections: found.subsections } : {},
|
|
26579
|
+
...relations ? { relations } : {}
|
|
26580
|
+
};
|
|
26581
|
+
}
|
|
26168
26582
|
const content = text && typeof contentResult === "string" ? readableText(raw) : raw;
|
|
26169
26583
|
return {
|
|
26170
|
-
|
|
26171
|
-
title: note.title,
|
|
26172
|
-
kind: ownedLabel(note, "noteType") ?? undefined,
|
|
26584
|
+
...identity,
|
|
26173
26585
|
content,
|
|
26174
26586
|
...typeof contentResult === "string" ? {} : { contentEncoding: contentResult.encoding, mime: contentResult.mime },
|
|
26175
26587
|
...relations ? { relations } : {}
|
|
@@ -26193,7 +26605,7 @@ ${block}`), d);
|
|
|
26193
26605
|
return txt3({
|
|
26194
26606
|
count: notes.length,
|
|
26195
26607
|
notes,
|
|
26196
|
-
...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"}
|
|
26608
|
+
...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"}; a huge one is cheapest one section at a time with section=.` } : {}
|
|
26197
26609
|
});
|
|
26198
26610
|
});
|
|
26199
26611
|
const reviseDomain = async (name, plan, dry, d) => {
|
|
@@ -26268,12 +26680,12 @@ ${block}`), d);
|
|
|
26268
26680
|
};
|
|
26269
26681
|
server.tool("revise", `Edit a note by id. A revision is taken first; "Last updated" lines are bumped on content writes.
|
|
26270
26682
|
|
|
26271
|
-
Modes: default append (a dated addendum \u2014 right only for records; a dated thread's append lands in today's child), mode=replace (whole body), section="<heading>" (replace that section's whole body, h2\u2192h3\u2192h4, occurrence= for repeats), section= + mode=before|after (insert a sibling block around the whole section), mode=prepend (top of the section body), mode=remove (delete the section). find="<exact stored text>" replaces every occurrence (nth= for one); edits=[{find, body}] applies several in one write. find= + within="tr" (or li, p, td, \u2026) acts on the element containing a short anchor: replace it, insert a row before/after it, or remove it, so a table row never needs its full stored markup. within= names the element acted on: within="tr" replaces the whole row (body is a full <tr>\u2026</tr>), within="td" one cell (body is a <td>\u2026</td>), and a plain find= inside a cell replaces only that text. Keep anchors short; quotes match in any form, and within= also matches an element by its visible text ("Phase 2 | Claude | Open" for a row).
|
|
26683
|
+
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. dryRun=true with within= previews the change first \u2014 the element's full visible text and byte size, and for remove/replace the before/after shape \u2014 with nothing written and no revision taken. 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). append={table, row} adds one row to the table under that heading with no anchor to locate first \u2014 pipes are cell boundaries, a lone placeholder row is replaced, and append= composes with nothing else. closure= with within="tr" and mode="remove" closes a register row in one call: {thread, body} removes the row and appends the closure (quoting it) to that thread's entry for today (needs identity=); {note} moves the removed row into another register note's table, shaped to it instead of into a thread. "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.
|
|
26272
26684
|
|
|
26273
|
-
Check the receipt: matched=false means a NEW section was written (available= lists real headings; strict=true refuses instead). A section replace swaps everything under the heading \u2014 use find= for anything smaller. find= matches stored HTML (tags literal), not rendered text, and is taken literally: a Windows path's backslash is one backslash, never doubled.`, {
|
|
26685
|
+
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. A find= or within= anchor that matches nothing always names the cause in hint=, and a miss shows the stored text nearby when part of the anchor is still there (matchedUpTo= / storedNearby=).`, {
|
|
26274
26686
|
noteId: stringType().optional().describe("Note to update (omit only with domain=)"),
|
|
26275
26687
|
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"),
|
|
26276
|
-
dryRun: booleanType().optional().describe("domain=: preview
|
|
26688
|
+
dryRun: booleanType().optional().describe("domain= or within=: preview what would change, write nothing, take no revision (domain= defaults to true; within= previews only when true). Re-run with dryRun=false to apply"),
|
|
26277
26689
|
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)."),
|
|
26278
26690
|
title: stringType().optional().describe("New title (normalized server-side)"),
|
|
26279
26691
|
section: stringType().optional().describe("Target a section by heading text (h2/h3/h4, in that order); omit for whole-note append/replace"),
|
|
@@ -26282,11 +26694,17 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
26282
26694
|
mode: enumType(["append", "replace", "before", "after", "prepend", "remove"]).optional().describe("append (default) | replace | before | after (around the whole section=) | prepend (top of its body) | remove (delete section=, no body needed)"),
|
|
26283
26695
|
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."),
|
|
26284
26696
|
nth: numberType().int().positive().optional().describe("find=: replace only the Nth occurrence, 1-based (default: all of them). With within=: the Nth containing element"),
|
|
26285
|
-
within: enumType(WITHIN_TAGS).optional().describe('With find=: act on the <tag> element that contains the find text (a short unique anchor) \u2014 mode="replace" (default) swaps it for body=, "before"/"after" insert body= as a sibling (add a table row), "remove" deletes it'),
|
|
26697
|
+
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. dryRun=true previews it first'),
|
|
26286
26698
|
closure: objectType({
|
|
26287
|
-
thread: stringType().describe('
|
|
26288
|
-
body: stringType().describe("
|
|
26289
|
-
|
|
26699
|
+
thread: stringType().optional().describe('Thread variant: the dated thread to record the closure in: its id or title (e.g. "Escalations") \u2014 together with body='),
|
|
26700
|
+
body: stringType().optional().describe("Thread variant: the closure prose: what settled it, who ruled, and where its durable substance went"),
|
|
26701
|
+
note: stringType().optional().describe("Register variant: the register note (id or exact title) whose table receives the closed row \u2014 the row moves there instead of into a thread entry"),
|
|
26702
|
+
table: stringType().optional().describe("Register variant: the heading of the table in that note (default: its first table)")
|
|
26703
|
+
}).optional().describe(`With within= and mode="remove": close a register row in one call. {thread, body} appends the closure to that thread's entry for today, quoting the removed row \u2014 needs identity=. {note} moves the removed row into another register note's table instead, shaped to that table. Run consistency() afterwards on any fact the row asserted elsewhere`),
|
|
26704
|
+
append: objectType({
|
|
26705
|
+
table: stringType().describe("The heading (h2/h3/h4) of the table that receives the row"),
|
|
26706
|
+
row: stringType().describe('One row, cells separated by "|" \u2014 e.g. "Item | Owner | Open"')
|
|
26707
|
+
}).optional().describe('Append one row to the table under append.table without first locating an anchor row \u2014 pipes are cell boundaries, one <td> per cell (plain text, escaped server-side). A lone placeholder row is replaced. e.g. append={table: "Backlog", row: "Ship v13 | Claude | 2026-10-15"}'),
|
|
26290
26708
|
edits: arrayType(objectType({
|
|
26291
26709
|
find: stringType().describe("Exact raw string to replace"),
|
|
26292
26710
|
body: stringType().describe("Raw replacement string"),
|
|
@@ -26295,20 +26713,30 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
26295
26713
|
identity: stringType().optional().describe('Appending to a dated thread: the identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" (required)'),
|
|
26296
26714
|
icon: stringType().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name; normalized server-side'),
|
|
26297
26715
|
date: stringType().optional().describe("ISO date (default: today)")
|
|
26298
|
-
}, async ({ noteId: noteIdArg, domain, dryRun, body, title, section, occurrence, mode, find, nth, within, closure, edits, identity, icon, date, strict }) => {
|
|
26299
|
-
if (closure
|
|
26300
|
-
|
|
26716
|
+
}, async ({ noteId: noteIdArg, domain, dryRun, body, title, section, occurrence, mode, find, nth, within, closure, append, edits, identity, icon, date, strict }) => {
|
|
26717
|
+
if (closure) {
|
|
26718
|
+
if (!(within && mode === "remove"))
|
|
26719
|
+
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")');
|
|
26720
|
+
if (closure.thread !== undefined === (closure.note !== undefined))
|
|
26721
|
+
return err("conflicting_params", "closure= targets exactly one of a thread or a register note: pass {thread, body} or {note}.", 'e.g. closure={thread: "Escalations", body: "Closed on \u2026"} or closure={note: "Decisions", table: "Decisions"}');
|
|
26722
|
+
if (closure.thread !== undefined && closure.body === undefined)
|
|
26723
|
+
return err("missing_param", "closure={thread}= records the closure as prose and needs body=.", 'Pass body="<what settled it, who ruled, and where its durable substance went>".');
|
|
26724
|
+
if (closure.note !== undefined && closure.body !== undefined)
|
|
26725
|
+
return err("conflicting_params", "closure.body= belongs to the {thread} variant; {note} moves the row itself and appends no prose.", "Drop body=, or target a thread instead.");
|
|
26726
|
+
if (closure.note === undefined && closure.table !== undefined)
|
|
26727
|
+
return err("conflicting_params", "closure.table= names a table inside closure.note= and takes no effect without it.", 'Pass closure={note: "<register>", table: "<heading>"}, or drop table=.');
|
|
26728
|
+
}
|
|
26301
26729
|
if (domain !== undefined) {
|
|
26302
26730
|
if (noteIdArg)
|
|
26303
26731
|
return err("conflicting_params", "Pass noteId= or domain=, not both.", "domain= edits every note in the domain; noteId= edits one.");
|
|
26304
26732
|
if (find === undefined && edits === undefined)
|
|
26305
26733
|
return err("missing_param", "domain= needs find= (with body=) or edits=.", 'e.g. revise(domain="myClerkBook", find="Code Priority Queue", body="Backlog")');
|
|
26306
|
-
if (within || section || title || mode || identity || icon)
|
|
26734
|
+
if (within || section || title || mode || identity || icon || append)
|
|
26307
26735
|
return err("conflicting_params", "domain= takes only find=/body=, edits=, nth= and dryRun=.", "Use noteId= for element, section, title or icon edits.");
|
|
26308
26736
|
return reviseDomain(domain, edits ?? [{ find, body: body ?? "", nth }], dryRun ?? true, checkedDate(date));
|
|
26309
26737
|
}
|
|
26310
|
-
if (dryRun !== undefined)
|
|
26311
|
-
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.");
|
|
26738
|
+
if (dryRun !== undefined && !within)
|
|
26739
|
+
return err("conflicting_params", "dryRun= applies only to revise(domain=) and revise(within=); nothing was written.", "For one note, read it first (or outline()) and then revise without dryRun. diff(noteId) shows what a write changed.");
|
|
26312
26740
|
if (!noteIdArg)
|
|
26313
26741
|
return err("missing_param", "revise needs noteId= (or domain= for a domain-wide find/replace).", "Pass the id of the note to edit.");
|
|
26314
26742
|
const noteId = noteIdArg;
|
|
@@ -26316,6 +26744,12 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
26316
26744
|
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.");
|
|
26317
26745
|
if (within && find === undefined)
|
|
26318
26746
|
return err("missing_param", "within= needs find= as its anchor.", 'Pass a short text unique to the element, e.g. revise(noteId, find="Phase 2", within="tr", body="<tr>\u2026</tr>").');
|
|
26747
|
+
if (append) {
|
|
26748
|
+
if (body !== undefined || find !== undefined || edits !== undefined || section !== undefined || within !== undefined || mode !== undefined || closure !== undefined)
|
|
26749
|
+
return err("conflicting_params", "append= is a one-call row append and combines with none of body=/find=/edits=/section=/within=/mode=/closure=.", 'e.g. revise(noteId, append={table: "Backlog", row: "Ship v13 | Claude | 2026-10-15"}).');
|
|
26750
|
+
if (!append.table.trim() || !append.row.split("|").some((c) => c.trim()))
|
|
26751
|
+
return err("missing_param", 'append needs a table heading and one row of cells separated by "|".', 'e.g. append={table: "Backlog", row: "Ship v13 | Claude | 2026-10-15"}.');
|
|
26752
|
+
}
|
|
26319
26753
|
const d = checkedDate(date);
|
|
26320
26754
|
const note = await trilium.getNote(noteId);
|
|
26321
26755
|
const noteKind = labelOf2(note, "noteType");
|
|
@@ -26346,6 +26780,40 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
26346
26780
|
}
|
|
26347
26781
|
return { retitled: cleanTitle, cascaded };
|
|
26348
26782
|
};
|
|
26783
|
+
if (append) {
|
|
26784
|
+
const cells = append.row.split("|").map((c) => c.trim());
|
|
26785
|
+
const current = await trilium.getNoteContent(noteId);
|
|
26786
|
+
const spot = findTable(current, append.table);
|
|
26787
|
+
if (!spot) {
|
|
26788
|
+
const section = getSection(current, append.table);
|
|
26789
|
+
const available = section.available ?? [];
|
|
26790
|
+
const near = !section.matched && available.length ? nearestHeading(append.table, available) : null;
|
|
26791
|
+
const suggestion = near && near.distance <= Math.max(2, Math.floor(append.table.length / 5)) ? near : null;
|
|
26792
|
+
return err("table_not_found", section.matched ? `"${append.table}" holds no table with a <tbody> to append to \u2014 nothing was written.` : `No "${append.table}" heading (h2/h3/h4) with a table under it \u2014 nothing was written.`, section.matched ? `Point append.table at the heading a table already sits under, or write the table's skeleton first (mode="append" with a <table> body).` : `Available headings: ${available.length ? available.join(" \xB7 ") : "(none)"}${suggestion ? ` \u2014 did you mean "${suggestion.heading}"?` : "."} outline(noteId) lists them.`);
|
|
26793
|
+
}
|
|
26794
|
+
await trilium.createRevision(noteId).catch(() => null);
|
|
26795
|
+
const stamped = bumpLastUpdated(appendRowToTable(current, spot, cells.map((c) => escapeHtml(c))), d);
|
|
26796
|
+
await trilium.updateNoteContent(noteId, stamped.html);
|
|
26797
|
+
const titledAppend = await applyTitle();
|
|
26798
|
+
const iconAppend = await applyIcon(noteId, icon);
|
|
26799
|
+
await trilium.updateLabelValue(noteId, "updated", d);
|
|
26800
|
+
if (labelOf2(note, "status") === "dormant")
|
|
26801
|
+
await trilium.updateLabelValue(noteId, "status", "active");
|
|
26802
|
+
const relsAppend = relationSnippet(note);
|
|
26803
|
+
return txt3({
|
|
26804
|
+
ok: true,
|
|
26805
|
+
noteId,
|
|
26806
|
+
mode: `append-row:${append.table}`,
|
|
26807
|
+
date: d,
|
|
26808
|
+
row: cells.join(" | "),
|
|
26809
|
+
cells: cells.length,
|
|
26810
|
+
...titledAppend.retitled ? { retitled: titledAppend.retitled } : {},
|
|
26811
|
+
...iconAppend ? { icon: iconAppend } : {},
|
|
26812
|
+
...relsAppend ? { relations: relsAppend } : {},
|
|
26813
|
+
...structuralFindings(noteKind, stamped.html),
|
|
26814
|
+
...stamped.bumped ? { lastUpdated: "bumped to today \u2014 the line is server-owned" } : {}
|
|
26815
|
+
});
|
|
26816
|
+
}
|
|
26349
26817
|
if (find !== undefined || edits !== undefined) {
|
|
26350
26818
|
if (find !== undefined && edits !== undefined)
|
|
26351
26819
|
return err("conflicting_params", "Pass either find= or edits=, not both.", "Fold the single find/body pair into the edits array.");
|
|
@@ -26404,31 +26872,89 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
26404
26872
|
date: d,
|
|
26405
26873
|
reason: res.reason,
|
|
26406
26874
|
hint,
|
|
26875
|
+
...dryRun === true ? { dryRun: true } : {},
|
|
26407
26876
|
...res.elements ? { elements: res.elements } : {},
|
|
26408
26877
|
...res.reason === "no_match" ? missContext(find, current) : {}
|
|
26409
26878
|
});
|
|
26410
26879
|
}
|
|
26411
|
-
if (res.html === current)
|
|
26880
|
+
if (res.html === current && dryRun !== true)
|
|
26412
26881
|
return txt3({ ok: true, noteId, mode: `within:${within}:${action}`, unchanged: true, date: d, note: "The body equals the element it would replace, so nothing was written and no revision was taken." });
|
|
26413
26882
|
let closureThread = null;
|
|
26414
26883
|
let closureBlock = "";
|
|
26884
|
+
let closureTarget = null;
|
|
26885
|
+
let closureCells = [];
|
|
26886
|
+
let closurePadded = 0;
|
|
26415
26887
|
if (closure) {
|
|
26416
|
-
|
|
26417
|
-
|
|
26418
|
-
|
|
26419
|
-
|
|
26420
|
-
|
|
26421
|
-
|
|
26422
|
-
|
|
26423
|
-
|
|
26888
|
+
if (closure.note !== undefined) {
|
|
26889
|
+
const ref = closure.note;
|
|
26890
|
+
let target = /^[A-Za-z0-9]{12}$/.test(ref) ? await trilium.getNote(ref).catch(() => null) : null;
|
|
26891
|
+
if (!target) {
|
|
26892
|
+
const hits = await trilium.searchNotes(`note.title = '${escapeQueryValue(ref)}'`, { fastSearch: true, limit: 10 }).catch(() => ({ results: [] }));
|
|
26893
|
+
const exact = hits.results.filter((n) => titleKey(n.title) === titleKey(ref));
|
|
26894
|
+
if (exact.length > 1)
|
|
26895
|
+
return err("ambiguous", `closure.note "${ref}" matches ${exact.length} notes \u2014 nothing was written.`, `Pass the note's id: ${exact.slice(0, 5).map((n) => `${n.title} [${n.noteId}]`).join(" \xB7 ")}`);
|
|
26896
|
+
target = exact[0] ?? null;
|
|
26897
|
+
}
|
|
26898
|
+
if (!target)
|
|
26899
|
+
return err("not_found", `closure.note "${ref}" is not a note; nothing was written.`, "Pass the register note's id or its exact title.");
|
|
26900
|
+
if (isContainer(b(), target.noteId))
|
|
26901
|
+
return err("protected_note", `closure.note "${target.title}" is a container \u2014 its content cannot be edited directly.`, "Target a register note (a maintained table).");
|
|
26902
|
+
if (isRecordNote(target))
|
|
26903
|
+
return err("record_note", `closure.note "${target.title}" is a record \u2014 records are never rewritten.`, "Use the {thread, body} variant for prose closures, or target the register that should hold the row.");
|
|
26904
|
+
const targetContent = await trilium.getNoteContent(target.noteId);
|
|
26905
|
+
const spot = findTable(targetContent, closure.table);
|
|
26906
|
+
if (!spot)
|
|
26907
|
+
return err("no_table", `closure.note "${target.title}" ${closure.table ? `has no table under "${closure.table}"` : "holds no table with a <tbody> to append to"} \u2014 nothing was written.`, closure.table ? "Pass the heading of the table that should receive the row (outline its headings first)." : 'Pass table="<heading>" to name the table, or give the note a table to receive the row.');
|
|
26908
|
+
const removedCells = rowCells(res.element);
|
|
26909
|
+
const fitted = fitRowCells(removedCells, spot.columns);
|
|
26910
|
+
if (!fitted)
|
|
26911
|
+
return err("row_shape", `The row has ${removedCells.length} cell(s) and "${target.title}"'s table has ${spot.columns} column(s): appending it would drop data, so nothing was written.`, "Reshape the row first (revise its cells), or target a register whose table matches the row.");
|
|
26912
|
+
closureTarget = { note: target, ...closure.table !== undefined ? { heading: closure.table } : {}, content: targetContent, spot };
|
|
26913
|
+
closureCells = fitted;
|
|
26914
|
+
closurePadded = fitted.length - removedCells.length;
|
|
26915
|
+
} else {
|
|
26916
|
+
closureThread = /^[A-Za-z0-9]{12}$/.test(closure.thread) ? await trilium.getNote(closure.thread).catch(() => null) : await findExisting("thread", closure.thread);
|
|
26917
|
+
if (!closureThread || ownedLabel(closureThread, "noteType") !== "thread")
|
|
26918
|
+
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".`);
|
|
26919
|
+
if (isCollectionThread(closureThread))
|
|
26920
|
+
return collectionAppendRefusal(closureThread);
|
|
26921
|
+
const rendered = renderBody(closure.body ?? "").html;
|
|
26922
|
+
const removedText = toText(res.element, 400);
|
|
26923
|
+
const closureHtml = `<p><strong>Closed:</strong> ${escapeHtml(removedText)}</p>
|
|
26424
26924
|
${rendered}`;
|
|
26425
|
-
|
|
26426
|
-
|
|
26427
|
-
|
|
26428
|
-
|
|
26429
|
-
|
|
26430
|
-
|
|
26925
|
+
if (!identity)
|
|
26926
|
+
return err("missing_identity", "closure= appends to a dated thread, which needs identity=; nothing was written.", 'Pass identity="LLM \xB7 environment \xB7 agent/mode".');
|
|
26927
|
+
const badIdentity = identityRefusal(identity, closureHtml);
|
|
26928
|
+
if (badIdentity)
|
|
26929
|
+
return badIdentity;
|
|
26930
|
+
closureBlock = `<h3>${escapeHtml(identity)}</h3>
|
|
26431
26931
|
${closureHtml}`;
|
|
26932
|
+
}
|
|
26933
|
+
}
|
|
26934
|
+
if (dryRun === true) {
|
|
26935
|
+
const elementText = toText(res.element, 1200);
|
|
26936
|
+
const fullText = toText(res.element, Number.MAX_SAFE_INTEGER);
|
|
26937
|
+
const unchanged = res.html === current;
|
|
26938
|
+
return txt3({
|
|
26939
|
+
ok: true,
|
|
26940
|
+
noteId,
|
|
26941
|
+
mode: `within:${within}:${action}`,
|
|
26942
|
+
dryRun: true,
|
|
26943
|
+
date: d,
|
|
26944
|
+
element: {
|
|
26945
|
+
tag: within,
|
|
26946
|
+
text: elementText,
|
|
26947
|
+
bytes: Buffer.byteLength(res.element, "utf8"),
|
|
26948
|
+
...elementText.length < fullText.length ? { textTruncated: true } : {}
|
|
26949
|
+
},
|
|
26950
|
+
...unchanged ? { unchanged: true, note: "body= equals the element it would replace, so a real run would write nothing and take no revision \u2014 this is the element as it stands." } : {
|
|
26951
|
+
...action === "remove" ? { before: boundPreview(res.element), after: "(removed \u2014 the surrounding content closes up)" } : action === "replace" ? { before: boundPreview(res.element), after: boundPreview(body ?? "") } : { inserts: { position: action, body: boundPreview(body ?? "") } },
|
|
26952
|
+
...closureThread ? { closure: { threadId: closureThread.noteId, thread: closureThread.title, wouldAppend: toText(closureBlock, 300) } } : {},
|
|
26953
|
+
...closureTarget ? { closure: { noteId: closureTarget.note.noteId, note: closureTarget.note.title, table: closureTarget.heading ?? "(first table)", row: closureCells.map((c) => toText(c, 80)).join(" | "), action: "would-append", ...closurePadded ? { padded: `${closurePadded} empty cell(s) added so the row matches the table's shape` } : {} } } : {}
|
|
26954
|
+
},
|
|
26955
|
+
matchedElements: res.matches,
|
|
26956
|
+
next: "Nothing was written and no revision was taken. Re-run with dryRun=false to apply exactly this change."
|
|
26957
|
+
});
|
|
26432
26958
|
}
|
|
26433
26959
|
await trilium.createRevision(noteId).catch(() => null);
|
|
26434
26960
|
const withinResult = sanitizeHtml(res.html);
|
|
@@ -26446,14 +26972,47 @@ ${closureHtml}`;
|
|
|
26446
26972
|
await trilium.updateLabelValue(closureThread.noteId, "updated", d);
|
|
26447
26973
|
if (labelOf2(closureThread, "status") === "dormant")
|
|
26448
26974
|
await trilium.updateLabelValue(closureThread.noteId, "status", "active");
|
|
26975
|
+
const closureHeading = leadingIdentityText(closureBlock);
|
|
26449
26976
|
closureReceipt = {
|
|
26450
26977
|
threadId: closureThread.noteId,
|
|
26451
26978
|
thread: closureThread.title,
|
|
26452
26979
|
entryId: entry.noteId,
|
|
26453
26980
|
action: entry.action,
|
|
26981
|
+
...entry.marker ? { entryMarker: entry.marker } : {},
|
|
26982
|
+
...closureHeading ? { runHeading: closureHeading } : {},
|
|
26454
26983
|
next: "Run consistency() on any fact the closed row asserted elsewhere, so no stale sentence survives it."
|
|
26455
26984
|
};
|
|
26456
26985
|
}
|
|
26986
|
+
if (closureTarget) {
|
|
26987
|
+
const sameNote = closureTarget.note.noteId === noteId;
|
|
26988
|
+
const base = sameNote ? await trilium.getNoteContent(noteId) : closureTarget.content;
|
|
26989
|
+
const targetSpot = sameNote ? findTable(base, closureTarget.heading) : closureTarget.spot;
|
|
26990
|
+
if (targetSpot) {
|
|
26991
|
+
await trilium.createRevision(closureTarget.note.noteId).catch(() => null);
|
|
26992
|
+
const regStamped = bumpLastUpdated(appendRowToTable(base, targetSpot, closureCells), d);
|
|
26993
|
+
await trilium.updateNoteContent(closureTarget.note.noteId, regStamped.html);
|
|
26994
|
+
await trilium.updateLabelValue(closureTarget.note.noteId, "updated", d);
|
|
26995
|
+
if (labelOf2(closureTarget.note, "status") === "dormant")
|
|
26996
|
+
await trilium.updateLabelValue(closureTarget.note.noteId, "status", "active");
|
|
26997
|
+
const marked = hasLabel(closureTarget.note, "register") || ownedLabel(closureTarget.note, "noteType") === "thread";
|
|
26998
|
+
closureReceipt = {
|
|
26999
|
+
noteId: closureTarget.note.noteId,
|
|
27000
|
+
note: closureTarget.note.title,
|
|
27001
|
+
table: closureTarget.heading ?? "(first table)",
|
|
27002
|
+
row: closureCells.map((c) => toText(c, 80)).join(" | "),
|
|
27003
|
+
action: "appended",
|
|
27004
|
+
...closurePadded ? { padded: `${closurePadded} empty cell(s) added so the row matches the table's shape` } : {},
|
|
27005
|
+
...marked ? {} : { registerHint: `The target is not marked #register \u2014 label(noteId, "register", value="") exempts its table's evidence dates from maintain's dated-prose lint.` }
|
|
27006
|
+
};
|
|
27007
|
+
} else {
|
|
27008
|
+
closureReceipt = {
|
|
27009
|
+
noteId: closureTarget.note.noteId,
|
|
27010
|
+
note: closureTarget.note.title,
|
|
27011
|
+
action: "missed",
|
|
27012
|
+
hint: "The row was removed, but the table could no longer be found in the target note \u2014 nothing was appended there. The source note has a revision from before the removal."
|
|
27013
|
+
};
|
|
27014
|
+
}
|
|
27015
|
+
}
|
|
26457
27016
|
return txt3({
|
|
26458
27017
|
ok: true,
|
|
26459
27018
|
noteId,
|
|
@@ -26477,11 +27036,12 @@ ${closureHtml}`;
|
|
|
26477
27036
|
const applied = applyFindEdit(working, e.find, e.body, e.nth);
|
|
26478
27037
|
if (!applied) {
|
|
26479
27038
|
const alreadyConsumed = consumed.has(e.find);
|
|
27039
|
+
const nthOvershot = !alreadyConsumed && e.nth !== undefined && working.includes(e.find);
|
|
26480
27040
|
results.push({
|
|
26481
27041
|
find: e.find,
|
|
26482
27042
|
replaced: 0,
|
|
26483
|
-
hint: missHint(e.find, current, alreadyConsumed),
|
|
26484
|
-
...alreadyConsumed ? {} : missContext(e.find, working)
|
|
27043
|
+
hint: nthOvershot ? `nth=${e.nth} is past the last occurrence of this text, so nothing was replaced. The text itself is present \u2014 check how many occurrences exist, then re-target, or omit nth= to replace every one.` : missHint(e.find, current, alreadyConsumed),
|
|
27044
|
+
...alreadyConsumed || nthOvershot ? {} : missContext(e.find, working)
|
|
26485
27045
|
});
|
|
26486
27046
|
continue;
|
|
26487
27047
|
}
|
|
@@ -26497,7 +27057,7 @@ ${closureHtml}`;
|
|
|
26497
27057
|
mode: edits ? "edits" : "find-replace",
|
|
26498
27058
|
replaced: 0,
|
|
26499
27059
|
date: d,
|
|
26500
|
-
...edits ? { results } : {
|
|
27060
|
+
...edits ? { results, hint: `None of the ${plan.length} edit(s) matched anything \u2014 see results[].hint for the cause of each; nothing was written.` } : {
|
|
26501
27061
|
hint: results[0].hint,
|
|
26502
27062
|
...results[0].matchedUpTo ? { matchedUpTo: results[0].matchedUpTo } : {},
|
|
26503
27063
|
...results[0].storedNearby ? { storedNearby: results[0].storedNearby } : {}
|
|
@@ -26593,6 +27153,7 @@ ${closureHtml}`;
|
|
|
26593
27153
|
let finalContent = null;
|
|
26594
27154
|
let sectionMiss = {};
|
|
26595
27155
|
let threadEntryResult = null;
|
|
27156
|
+
let threadRunHeading = null;
|
|
26596
27157
|
if (body) {
|
|
26597
27158
|
const sanitized = renderBody(body);
|
|
26598
27159
|
const html = sanitized.html;
|
|
@@ -26643,6 +27204,7 @@ ${closureHtml}`;
|
|
|
26643
27204
|
return badIdentity;
|
|
26644
27205
|
const block = identity && !leadingIdentification(html) ? `<h3>${escapeHtml(identity)}</h3>
|
|
26645
27206
|
${html}` : html;
|
|
27207
|
+
threadRunHeading = leadingIdentityText(block);
|
|
26646
27208
|
threadEntryResult = await appendThreadEntry(noteId, block, d);
|
|
26647
27209
|
if (threadEntryResult.action === "already_written") {
|
|
26648
27210
|
return txt3({ ok: true, noteId, mode: "already_written", entryId: threadEntryResult.noteId, date: d });
|
|
@@ -26679,7 +27241,7 @@ ${block}`), d).html;
|
|
|
26679
27241
|
} : {},
|
|
26680
27242
|
...sectionMiss,
|
|
26681
27243
|
...sectionHint ? { hint: sectionHint } : {},
|
|
26682
|
-
...threadEntryResult ? { entryId: threadEntryResult.noteId, entryAction: threadEntryResult.action, entry: `today's [${d}] entry in this thread; revise(entryId) edits it` } : {},
|
|
27244
|
+
...threadEntryResult ? { entryId: threadEntryResult.noteId, entryAction: threadEntryResult.action, ...threadEntryResult.marker ? { entryMarker: threadEntryResult.marker } : {}, ...threadRunHeading ? { runHeading: threadRunHeading } : {}, entry: `today's [${d}] entry in this thread; revise(entryId) edits it` } : {},
|
|
26683
27245
|
...titled.retitled ? { retitled: titled.retitled } : {},
|
|
26684
27246
|
...titled.cascaded ? { domainCascade: `#domain updated on ${titled.cascaded} descendant note(s)` } : {},
|
|
26685
27247
|
...structuralFindings(noteKind, finalContent),
|
|
@@ -26951,7 +27513,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
|
|
|
26951
27513
|
}
|
|
26952
27514
|
}
|
|
26953
27515
|
});
|
|
26954
|
-
server.tool("consistency", `Does the brain agree with itself? pattern= is a regex with ONE capture group naming the value that should match across notes, e.g. "(\\\\d+) mailboxes"; the result groups every asserting note by value with agreement unanimous or DISAGREEMENT. subject="<fact in prose>" finds notes asserting about a subject however phrased. staleAfterDays=N also reports values held in exactly one note untouched N+ days. Matches stored HTML and tag-stripped text; escape backslashes; scope with domain=/kinds=. Records (sessions, diary, logs, dated thread entries) are skipped by default because they are never rewritten; includeRecords=true or a kinds= naming them brings them back. Scans every in-scope note (fast=true uses Trilium's lossy pre-filter). Matching ignores case unless caseSensitive=true (a leading (?i) is accepted). Without a capture group the whole match is the value compared. patterns=[\u2026] checks several facts in one call over one read of each note. Run it after correcting any fact recorded in more than one place.`, {
|
|
27516
|
+
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. Multiple values co-asserted within one note \u2014 two true figures in one cell \u2014 are that note's compound fact, not a contradiction; only values no note asserts together flag as a disagreement. subject="<fact in prose>" finds notes asserting about a subject however phrased. staleAfterDays=N also reports values held in exactly one note untouched N+ days. Matches stored HTML and tag-stripped text; escape backslashes; scope with domain=/kinds=. Records (sessions, diary, logs, dated thread entries) are skipped by default because they are never rewritten; includeRecords=true or a kinds= naming them brings them back. Scans every in-scope note (fast=true uses Trilium's lossy pre-filter). Matching ignores case unless caseSensitive=true (a leading (?i) is accepted). Without a capture group the whole match is the value compared. patterns=[\u2026] checks several facts in one call over one read of each note. Run it after correcting any fact recorded in more than one place.`, {
|
|
26955
27517
|
pattern: stringType().optional().describe("Regex over note bodies. One capture group = the value that should agree across notes; with none, the whole match is the value. Omit when using subject= (prose mode)."),
|
|
26956
27518
|
patterns: arrayType(stringType()).max(10).optional().describe("Several patterns checked in one call (each reported as its own result, in order); every note is read once"),
|
|
26957
27519
|
caseSensitive: booleanType().optional().describe('Match case exactly (default false: "Blueprints" and "blueprints" both match)'),
|
|
@@ -27056,6 +27618,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
|
|
|
27056
27618
|
clauses.push(`#domain='${slugify(domain)}'`);
|
|
27057
27619
|
const notes = await trilium.searchNotes(clauses.join(" AND "), { ancestorNoteId: b().root, limit: wantsRecords ? max : max * 4, includeArchivedNotes: includeArchived ?? false }).then((r) => r.results).catch(() => []);
|
|
27058
27620
|
const scoped = notes.filter(inScope).slice(0, max);
|
|
27621
|
+
const noteValues = new Map;
|
|
27059
27622
|
const byValue = new Map;
|
|
27060
27623
|
const noCapture = [];
|
|
27061
27624
|
let hasCaptureGroup = false;
|
|
@@ -27088,15 +27651,19 @@ calling twice is safe. Use remove=true to delete an edge.`, {
|
|
|
27088
27651
|
const seen = new Set;
|
|
27089
27652
|
for (const value of [...raw.values, ...projected.values]) {
|
|
27090
27653
|
hasCaptureGroup = true;
|
|
27091
|
-
if (seen.has(value))
|
|
27092
|
-
continue;
|
|
27093
27654
|
seen.add(value);
|
|
27655
|
+
}
|
|
27656
|
+
if (seen.size)
|
|
27657
|
+
noteValues.set(n.noteId, { stub, values: seen });
|
|
27658
|
+
if ((raw.matched || projected.matched) && !seen.size)
|
|
27659
|
+
noCapture.push(stub);
|
|
27660
|
+
}
|
|
27661
|
+
for (const { stub, values } of noteValues.values()) {
|
|
27662
|
+
for (const value of values) {
|
|
27094
27663
|
if (!byValue.has(value))
|
|
27095
27664
|
byValue.set(value, []);
|
|
27096
27665
|
byValue.get(value).push(stub);
|
|
27097
27666
|
}
|
|
27098
|
-
if ((raw.matched || projected.matched) && !seen.size)
|
|
27099
|
-
noCapture.push(stub);
|
|
27100
27667
|
}
|
|
27101
27668
|
if (!hasCaptureGroup) {
|
|
27102
27669
|
return txt3({
|
|
@@ -27112,7 +27679,18 @@ calling twice is safe. Use remove=true to delete an edge.`, {
|
|
|
27112
27679
|
}
|
|
27113
27680
|
const groups = [...byValue.entries()].map(([value, notes]) => ({ value, count: notes.length, notes })).sort((a, b) => b.count - a.count);
|
|
27114
27681
|
const staleSingles = staleAfterDays ? groups.filter((g) => g.count === 1 && g.notes[0].idleDays >= staleAfterDays).map((g) => ({ value: g.value, note: { id: g.notes[0].id, title: g.notes[0].title, kind: g.notes[0].kind }, idleDays: g.notes[0].idleDays })) : [];
|
|
27115
|
-
const
|
|
27682
|
+
const valueNotes = [...byValue.values()];
|
|
27683
|
+
let crossNoteConflict = false;
|
|
27684
|
+
for (let i = 0;i < valueNotes.length && !crossNoteConflict; i++) {
|
|
27685
|
+
const ids = new Set(valueNotes[i].map((s) => s.id));
|
|
27686
|
+
for (let j = i + 1;j < valueNotes.length; j++) {
|
|
27687
|
+
if (!valueNotes[j].some((s) => ids.has(s.id))) {
|
|
27688
|
+
crossNoteConflict = true;
|
|
27689
|
+
break;
|
|
27690
|
+
}
|
|
27691
|
+
}
|
|
27692
|
+
}
|
|
27693
|
+
const agrees = !crossNoteConflict;
|
|
27116
27694
|
return txt3({
|
|
27117
27695
|
mode: "consistency",
|
|
27118
27696
|
pattern,
|
|
@@ -27129,7 +27707,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
|
|
|
27129
27707
|
...staleAfterDays ? { staleAfterDays } : {},
|
|
27130
27708
|
...staleSingles.length ? { staleSingles, staleSinglesNote: `${staleSingles.length} figure(s) appear in exactly one note, untouched ${staleAfterDays}d+ \u2014 a single copy cannot disagree with itself, so re-measure these against the world rather than against other notes.` } : {},
|
|
27131
27709
|
...noCapture.length ? { matchedWithoutValue: noCapture } : {},
|
|
27132
|
-
note: agrees ? groups.length === 0 ? "No note asserted a value for that pattern. That is evidence about the pattern, not about the brain \u2014 check it against a note you know contains the fact." : `All ${groups[0].count} note(s) agree on "${groups[0].value}".` : `${groups.length} DIFFERENT values are asserted across ${scoped.length} notes. Establish which is true from evidence, correct every note that disagrees, and wire ~corrects from the note that overturns the old claim \u2014 revising in place leaves no trace the wrong value was ever believed.`
|
|
27710
|
+
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." : groups.length === 1 ? `All ${groups[0].count} note(s) agree on "${groups[0].value}".` : `${groups.length} different values were captured, but every pair of them is asserted together inside a single note \u2014 one note's compound fact (two true figures in one cell), not a contradiction. Re-measure if any value should be the only one.` : `${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.`
|
|
27133
27711
|
});
|
|
27134
27712
|
}
|
|
27135
27713
|
});
|
|
@@ -27323,7 +27901,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
|
|
|
27323
27901
|
};
|
|
27324
27902
|
server.tool("claim", `Does the brain still agree with the world? Register a checkable assertion and record whether it holds. BrainLLM never runs the check \u2014 it stores it as inert text; you run it and report.
|
|
27325
27903
|
|
|
27326
|
-
assertion + check \u2192 register (deduped by assertion); claimId + holds + evidence \u2192 verify (evidence required); claimId alone \u2192 read with history; nothing \u2192 list (status= filters). noteId= links the claim to its source note (~derivedFrom). maintain(deep) surfaces lapsed, never-verified, broken and source-changed claims.`, {
|
|
27904
|
+
assertion + check \u2192 register (deduped by assertion); claimId + holds + evidence \u2192 verify (evidence required); claimId + holds + evidence + retire \u2192 verify and archive in one call; claimId alone \u2192 read with history; nothing \u2192 list (status= filters). noteId= links the claim to its source note (~derivedFrom). maintain(deep) surfaces lapsed, never-verified, broken and source-changed claims.`, {
|
|
27327
27905
|
assertion: stringType().optional().describe('The claim in plain words, e.g. "the parse pipeline runs before validation" \u2014 also the dedup key'),
|
|
27328
27906
|
check: stringType().optional().describe("How to verify it, as INERT text a human or agent runs: a command, a query, a file path, a URL. Never executed by BrainLLM"),
|
|
27329
27907
|
noteId: stringType().optional().describe("The note this claim is made in \u2014 wired ~derivedFrom so the claim and its source stay linked"),
|
|
@@ -27332,7 +27910,7 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
|
|
|
27332
27910
|
holds: booleanType().optional().describe("With claimId: did the check pass? false marks the claim broken and surfaces it in maintain()"),
|
|
27333
27911
|
evidence: stringType().optional().describe("With holds: what you actually observed \u2014 the output, the count, the response. A verification without evidence is an assertion about an assertion"),
|
|
27334
27912
|
status: enumType(["holding", "broken", "lapsed", "all"]).optional().describe("List filter (default: all)"),
|
|
27335
|
-
retire: booleanType().optional().describe("With claimId: archive this claim \u2014 the thing it described is gone")
|
|
27913
|
+
retire: booleanType().optional().describe("With claimId: archive this claim \u2014 the thing it described is gone. On the same call as holds= and evidence=: record that final verification (e.g. the failure) and archive it in one call")
|
|
27336
27914
|
}, async ({ assertion, check, noteId, intervalDays, claimId, holds, evidence, status, retire }) => {
|
|
27337
27915
|
const cfg = b();
|
|
27338
27916
|
if (!cfg.root)
|
|
@@ -27358,7 +27936,7 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
|
|
|
27358
27936
|
const note = await trilium.getNote(claimId).catch(() => null);
|
|
27359
27937
|
if (!note || labelOf2(note, "noteType") !== "claim")
|
|
27360
27938
|
return err("not_found", `${claimId} is not a claim.`, "List them with claim() and no arguments.");
|
|
27361
|
-
if (retire) {
|
|
27939
|
+
if (retire && holds === undefined) {
|
|
27362
27940
|
await trilium.addLabel(claimId, "archived", "").catch(() => null);
|
|
27363
27941
|
return txt3({ ok: true, claimId, action: "retired", assertion: note.title });
|
|
27364
27942
|
}
|
|
@@ -27376,11 +27954,14 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
|
|
|
27376
27954
|
await trilium.updateLabelValue(claimId, "verified", d).catch(() => null);
|
|
27377
27955
|
await trilium.updateLabelValue(claimId, "claimState", holds ? "holding" : "broken").catch(() => null);
|
|
27378
27956
|
await trilium.updateLabelValue(claimId, "updated", d).catch(() => null);
|
|
27957
|
+
if (retire)
|
|
27958
|
+
await trilium.addLabel(claimId, "archived", "").catch(() => null);
|
|
27379
27959
|
const refreshed = await trilium.getNote(claimId).catch(() => note);
|
|
27380
27960
|
return txt3({
|
|
27381
27961
|
ok: true,
|
|
27382
27962
|
action: holds ? "verified" : "marked-broken",
|
|
27383
27963
|
...row(refreshed),
|
|
27964
|
+
...retire ? { retired: true, note: "The verification was recorded and the claim archived in this one call \u2014 its history stays in the Verifications section, and the register no longer lists it." } : {},
|
|
27384
27965
|
...holds ? {} : { hint: "This claim is now BROKEN. Correct every note that asserts it \u2014 consistency() will find the others \u2014 then re-verify. A broken claim left in the register is a known-wrong fact with a timestamp on it." }
|
|
27385
27966
|
});
|
|
27386
27967
|
}
|
|
@@ -27921,7 +28502,8 @@ and every thread day-child, in time order with its identification line.`, {
|
|
|
27921
28502
|
const todaySess = await trilium.searchNotes(`#noteType=session #created='${todayStr}'`, { ancestorNoteId: cfg.memory.sessions, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
|
|
27922
28503
|
if (todaySess.results[0]) {
|
|
27923
28504
|
const content = await trilium.getNoteContent(todaySess.results[0].noteId).catch(() => "");
|
|
27924
|
-
|
|
28505
|
+
const note = await trilium.getNote(todaySess.results[0].noteId).catch(() => null);
|
|
28506
|
+
newDay = note ? !hasLabel(note, "interactive") : !/<h2(?:\s[^>]*)?>\s*Addendum/i.test(content);
|
|
27925
28507
|
}
|
|
27926
28508
|
const sessions = await trilium.searchNotes("#noteType=session", {
|
|
27927
28509
|
ancestorNoteId: cfg.memory.sessions,
|
|
@@ -27941,6 +28523,7 @@ and every thread day-child, in time order with its identification line.`, {
|
|
|
27941
28523
|
previousLog = { id: log.results[0].noteId, content: await trilium.getNoteContent(log.results[0].noteId).catch(() => "") };
|
|
27942
28524
|
}
|
|
27943
28525
|
let changes = [];
|
|
28526
|
+
let changesTruncated;
|
|
27944
28527
|
if (lastSession) {
|
|
27945
28528
|
try {
|
|
27946
28529
|
const history = await trilium.getNoteHistory(cfg.root);
|
|
@@ -27950,12 +28533,14 @@ and every thread day-child, in time order with its identification line.`, {
|
|
|
27950
28533
|
if (!prevEntry || h.current_isDeleted && !prevEntry.current_isDeleted)
|
|
27951
28534
|
deduped.set(h.noteId, h);
|
|
27952
28535
|
}
|
|
27953
|
-
|
|
28536
|
+
const capped = capChanges([...deduped.values()].map((h) => ({
|
|
27954
28537
|
id: h.noteId,
|
|
27955
28538
|
title: h.current_title,
|
|
27956
28539
|
changed: h.date.slice(0, 10),
|
|
27957
28540
|
...h.current_isDeleted ? { deleted: true } : {}
|
|
27958
|
-
}));
|
|
28541
|
+
})));
|
|
28542
|
+
changes = capped.changes;
|
|
28543
|
+
changesTruncated = capped.changesTruncated;
|
|
27959
28544
|
} catch {}
|
|
27960
28545
|
}
|
|
27961
28546
|
return txt3({
|
|
@@ -27964,6 +28549,7 @@ and every thread day-child, in time order with its identification line.`, {
|
|
|
27964
28549
|
lastSession,
|
|
27965
28550
|
previousLog,
|
|
27966
28551
|
changes: changes.length ? changes : undefined,
|
|
28552
|
+
...changesTruncated ? { changesTruncated } : {},
|
|
27967
28553
|
next: [
|
|
27968
28554
|
"Skim lastSession, previousLog and changes for evidenced outputs \u2014 never plausibility.",
|
|
27969
28555
|
"Present what moved in the first message."
|
|
@@ -28231,8 +28817,8 @@ and every thread day-child, in time order with its identification line.`, {
|
|
|
28231
28817
|
tool.handler = async (...args) => {
|
|
28232
28818
|
const result = await inner(...args);
|
|
28233
28819
|
if (!wroteSinceClose && wroteContent(result)) {
|
|
28234
|
-
await markStep("write").catch(() => null);
|
|
28235
28820
|
wroteSinceClose = true;
|
|
28821
|
+
await markWriteStep();
|
|
28236
28822
|
}
|
|
28237
28823
|
return result;
|
|
28238
28824
|
};
|
|
@@ -28245,7 +28831,7 @@ function wroteContent(result) {
|
|
|
28245
28831
|
return true;
|
|
28246
28832
|
try {
|
|
28247
28833
|
const r = JSON.parse(text);
|
|
28248
|
-
if (r.error || r.ok === false || r.unchanged === true || r.action === "already_written" || r.mode === "already_written")
|
|
28834
|
+
if (r.error || r.ok === false || r.unchanged === true || r.action === "already_written" || r.mode === "already_written" || r.dryRun === true)
|
|
28249
28835
|
return false;
|
|
28250
28836
|
if (r.replaced === 0 && !r.closure)
|
|
28251
28837
|
return false;
|
|
@@ -30214,7 +30800,7 @@ function createServer(origin = null) {
|
|
|
30214
30800
|
const s = new McpServer({
|
|
30215
30801
|
name: BRAND2.name,
|
|
30216
30802
|
title: BRAND2.name,
|
|
30217
|
-
version: "
|
|
30803
|
+
version: "13.0.0",
|
|
30218
30804
|
description: BRAND2.description,
|
|
30219
30805
|
websiteUrl: BRAND2.website,
|
|
30220
30806
|
icons: brandIcons(origin)
|