brainllm 12.9.0 → 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 +733 -138
- 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();
|
|
@@ -22807,26 +22808,35 @@ function unbalancedTags(html) {
|
|
|
22807
22808
|
}
|
|
22808
22809
|
function tableShapeIssues(html) {
|
|
22809
22810
|
const issues = [];
|
|
22810
|
-
const
|
|
22811
|
+
const spanOf = (attrs, name) => Number(new RegExp(`${name}\\s*=\\s*"?(\\d+)`, "i").exec(attrs ?? "")?.[1] ?? 1);
|
|
22811
22812
|
for (const table of html.matchAll(/<table\b[^>]*>([\s\S]*?)<\/table>/gi)) {
|
|
22812
22813
|
const rows = [...table[1].matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)].map((r) => [...r[1].matchAll(/<(t[hd])\b([^>]*)>([\s\S]*?)<\/\1>/gi)].map((c) => ({
|
|
22813
|
-
span:
|
|
22814
|
+
span: spanOf(c[2], "colspan"),
|
|
22815
|
+
rows: spanOf(c[2], "rowspan"),
|
|
22814
22816
|
text: decodeEntities(c[3].replace(/<[^>]+>/g, "")).trim()
|
|
22815
22817
|
})));
|
|
22816
22818
|
if (rows.length < 2)
|
|
22817
22819
|
continue;
|
|
22818
22820
|
const width = rows[0].reduce((sum, c) => sum + c.span, 0);
|
|
22819
|
-
|
|
22820
|
-
|
|
22821
|
-
const
|
|
22822
|
-
if (
|
|
22823
|
-
|
|
22824
|
-
|
|
22825
|
-
|
|
22826
|
-
|
|
22827
|
-
|
|
22828
|
-
|
|
22821
|
+
let spanning = [];
|
|
22822
|
+
for (const [i, cells] of rows.entries()) {
|
|
22823
|
+
const inherited = spanning.reduce((sum, s) => sum + s.columns, 0);
|
|
22824
|
+
if (i > 0) {
|
|
22825
|
+
const label = (cells[0]?.text ?? "").slice(0, 60) || "(empty row)";
|
|
22826
|
+
const count = cells.reduce((sum, c) => sum + c.span, 0) + inherited;
|
|
22827
|
+
if (count !== width) {
|
|
22828
|
+
issues.push(`row "${label}" has ${count} cell(s), its header has ${width}`);
|
|
22829
|
+
} else {
|
|
22830
|
+
const piped = cells.find((c) => (c.text.match(/\s\|\s/g)?.length ?? 0) >= Math.max(1, width - 2) && width > 1);
|
|
22831
|
+
if (piped && cells.filter((c) => c.text).length < width) {
|
|
22832
|
+
issues.push(`row "${label}" carries pipe-separated values inside one cell \u2014 send each value as its own <td>`);
|
|
22833
|
+
}
|
|
22834
|
+
}
|
|
22829
22835
|
}
|
|
22836
|
+
spanning = [
|
|
22837
|
+
...spanning.map((s) => ({ ...s, rowsLeft: s.rowsLeft - 1 })).filter((s) => s.rowsLeft > 0),
|
|
22838
|
+
...cells.filter((c) => c.rows > 1).map((c) => ({ columns: c.span, rowsLeft: c.rows - 1 }))
|
|
22839
|
+
];
|
|
22830
22840
|
}
|
|
22831
22841
|
}
|
|
22832
22842
|
return issues;
|
|
@@ -22933,6 +22943,108 @@ function upsertTableRow(html, heading, key, cells) {
|
|
|
22933
22943
|
created: !matchedRow
|
|
22934
22944
|
};
|
|
22935
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
|
+
}
|
|
22936
23048
|
|
|
22937
23049
|
// src/templates.ts
|
|
22938
23050
|
var RESOLUTION_ANCHOR = "<h2>Resolution</h2>";
|
|
@@ -23382,6 +23494,9 @@ function isoDaysAgo(days) {
|
|
|
23382
23494
|
function reviewKey(n) {
|
|
23383
23495
|
return n.blobId ?? n.dateModified;
|
|
23384
23496
|
}
|
|
23497
|
+
function isAcknowledged(n) {
|
|
23498
|
+
return ownedLabel(n, "reviewed") === reviewKey(n);
|
|
23499
|
+
}
|
|
23385
23500
|
var TIMELESS_KINDS = new Set(["thread", "user", "information", "domain"]);
|
|
23386
23501
|
var DATED_PROSE_LIMIT = 4;
|
|
23387
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)?";
|
|
@@ -23420,7 +23535,7 @@ async function sweep(trilium, cfg, opts = {}) {
|
|
|
23420
23535
|
const inScope = (n) => !domainSlug || n.attributes.some((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === "domain" && a.value === domainSlug);
|
|
23421
23536
|
let suppressed = 0;
|
|
23422
23537
|
const acknowledged = (n) => {
|
|
23423
|
-
if (
|
|
23538
|
+
if (!isAcknowledged(n))
|
|
23424
23539
|
return false;
|
|
23425
23540
|
suppressed++;
|
|
23426
23541
|
return true;
|
|
@@ -24839,6 +24954,94 @@ function applyFindEdit(source, needle, replacement, index) {
|
|
|
24839
24954
|
const inline = inlineTolerantSpans(source, needle);
|
|
24840
24955
|
return inline.length ? spans(inline, "inline-tolerant") : null;
|
|
24841
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
|
+
}
|
|
24842
25045
|
var err = (code, detail, hint) => txt3({ error: code, detail, ...hint ? { hint } : {} });
|
|
24843
25046
|
var labelOf2 = (n, name) => n.attributes.find((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === name)?.value;
|
|
24844
25047
|
var hasLabel = (n, name) => n.attributes.some((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === name);
|
|
@@ -24875,6 +25078,7 @@ function registerTools(server, trilium, brainRef) {
|
|
|
24875
25078
|
const REQUIRED_PRECLOSE_STEPS = ["session", "addendum", "maintain", "remarks", "diary"];
|
|
24876
25079
|
const LIGHT_PRECLOSE_STEPS = ["session", "remarks", "diary"];
|
|
24877
25080
|
let wroteSinceClose = false;
|
|
25081
|
+
let writeMarkPending = null;
|
|
24878
25082
|
const gateNote = async (date) => {
|
|
24879
25083
|
const cfg = b();
|
|
24880
25084
|
if (!cfg.memory.sessions)
|
|
@@ -24919,6 +25123,16 @@ function registerTools(server, trilium, brainRef) {
|
|
|
24919
25123
|
preCloseSteps.set(step, next);
|
|
24920
25124
|
preCloseSeq = next;
|
|
24921
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
|
+
};
|
|
24922
25136
|
const readGate = async (date) => {
|
|
24923
25137
|
const noteId = await gateNote(date);
|
|
24924
25138
|
if (!noteId)
|
|
@@ -25000,6 +25214,7 @@ function registerTools(server, trilium, brainRef) {
|
|
|
25000
25214
|
async function appendThreadEntry(threadId, block, d) {
|
|
25001
25215
|
const found = await trilium.searchNotes(`#noteType=threadEntry #created='${d}'`, { ancestorNoteId: threadId, fastSearch: true, limit: 1 });
|
|
25002
25216
|
const time = localNowTime();
|
|
25217
|
+
const marker = `Addendum \u2014 ${time}`;
|
|
25003
25218
|
if (found.results[0]) {
|
|
25004
25219
|
const noteId = found.results[0].noteId;
|
|
25005
25220
|
const current = fixRecordHeader(await trilium.getNoteContent(noteId), "threadEntry", d).html;
|
|
@@ -25009,17 +25224,17 @@ function registerTools(server, trilium, brainRef) {
|
|
|
25009
25224
|
if (incoming && blocks.some((b) => norm(b) === incoming))
|
|
25010
25225
|
return { noteId, action: "already_written" };
|
|
25011
25226
|
await trilium.createRevision(noteId);
|
|
25012
|
-
await trilium.updateNoteContent(noteId, safeAppend(current, `<h2
|
|
25227
|
+
await trilium.updateNoteContent(noteId, safeAppend(current, `<h2>${marker}</h2>`, block));
|
|
25013
25228
|
await trilium.updateLabelValue(noteId, "updated", d);
|
|
25014
|
-
return { noteId, action: "appended" };
|
|
25229
|
+
return { noteId, action: "appended", marker };
|
|
25015
25230
|
}
|
|
25016
|
-
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>
|
|
25017
25232
|
${block}` }));
|
|
25018
25233
|
const noteId = created.note.noteId;
|
|
25019
25234
|
await trilium.addLabel(noteId, "noteType", "threadEntry");
|
|
25020
25235
|
await trilium.addLabel(noteId, "created", d);
|
|
25021
25236
|
await ensureIcon(trilium, noteId);
|
|
25022
|
-
return { noteId, action: "created" };
|
|
25237
|
+
return { noteId, action: "created", marker };
|
|
25023
25238
|
}
|
|
25024
25239
|
function collectionAppendRefusal(book) {
|
|
25025
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.`);
|
|
@@ -25074,7 +25289,8 @@ ${block}` }));
|
|
|
25074
25289
|
sessionNoteId = digest.todaySession.id;
|
|
25075
25290
|
const content = await trilium.getNoteContent(sessionNoteId).catch(() => "");
|
|
25076
25291
|
sessionPreview = toText(content, 200);
|
|
25077
|
-
|
|
25292
|
+
const note = await trilium.getNote(sessionNoteId).catch(() => null);
|
|
25293
|
+
newDay = note ? !hasLabel(note, "interactive") : !/<h2(?:\s[^>]*)?>\s*Addendum/i.test(content);
|
|
25078
25294
|
} else {
|
|
25079
25295
|
sessionNoteId = await ensureGateNote(todayStr);
|
|
25080
25296
|
newDay = true;
|
|
@@ -25082,6 +25298,7 @@ ${block}` }));
|
|
|
25082
25298
|
} catch {}
|
|
25083
25299
|
}
|
|
25084
25300
|
let changesSinceLastSession = [];
|
|
25301
|
+
let changesTruncated;
|
|
25085
25302
|
if (digest.lastSession && cfg.root) {
|
|
25086
25303
|
const sinceDate = digest.lastSession.date;
|
|
25087
25304
|
try {
|
|
@@ -25093,12 +25310,14 @@ ${block}` }));
|
|
|
25093
25310
|
deduped.set(h.noteId, h);
|
|
25094
25311
|
}
|
|
25095
25312
|
}
|
|
25096
|
-
|
|
25313
|
+
const capped = capChanges([...deduped.values()].map((h) => ({
|
|
25097
25314
|
id: h.noteId,
|
|
25098
25315
|
title: h.current_title,
|
|
25099
25316
|
changed: h.date.slice(0, 10),
|
|
25100
25317
|
...h.current_isDeleted ? { deleted: true } : {}
|
|
25101
|
-
}));
|
|
25318
|
+
})));
|
|
25319
|
+
changesSinceLastSession = capped.changes;
|
|
25320
|
+
changesTruncated = capped.changesTruncated;
|
|
25102
25321
|
} catch {}
|
|
25103
25322
|
}
|
|
25104
25323
|
return txt3({
|
|
@@ -25112,6 +25331,7 @@ ${block}` }));
|
|
|
25112
25331
|
dormantThreads: digest.reviewQueue.length ? { note: "These threads went dormant from inactivity. Mention them if relevant; revise() or resolve() to act.", items: digest.reviewQueue } : [],
|
|
25113
25332
|
lastSession: digest.lastSession ?? null,
|
|
25114
25333
|
changesSinceLastSession: changesSinceLastSession.length ? changesSinceLastSession : undefined,
|
|
25334
|
+
...changesTruncated ? { changesTruncated } : {},
|
|
25115
25335
|
...newDay ? { newDay: true, newDayHint: "First session of the day \u2014 call day() for the sweep payload (previous session + log + changes in one call)." } : {},
|
|
25116
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.' } : {},
|
|
25117
25337
|
hygiene: { scanned: hygiene.scanned, fixed: hygiene.fixed.length, transitions: hygiene.transitions, flagged: hygiene.flagged, ...hygiene.suppressed ? { suppressed: hygiene.suppressed } : {} }
|
|
@@ -25119,22 +25339,42 @@ ${block}` }));
|
|
|
25119
25339
|
});
|
|
25120
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.
|
|
25121
25341
|
|
|
25122
|
-
The close sequence next[] lays out: singleton updates from the session, then addendum(), maintain(), remarks(), diary() and close(). close() checks that addendum, maintain, remarks and diary ran, in the order session \u2192 remarks \u2192 diary. scope="agent" omits the singleton updates for scoped or autonomous runs. A session that wrote no content gets a light close: session() returns only the short next[] (remarks, a one-paragraph diary, close) and close() does not require addendum() or maintain().`, {
|
|
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.`, {
|
|
25123
25343
|
date: stringType().optional().describe("ISO date YYYY-MM-DD (default: today)"),
|
|
25124
25344
|
full: booleanType().optional().describe("Inline every singleton's content and the diary body (default: stubs only)"),
|
|
25125
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."),
|
|
25126
25347
|
light: booleanType().optional().describe("Deprecated \u2014 light is now the default; accepted for compatibility and ignored")
|
|
25127
|
-
}, async ({ date, full, scope }) => {
|
|
25348
|
+
}, async ({ date, full, scope, continuing }) => {
|
|
25128
25349
|
const d = checkedDate(date);
|
|
25129
25350
|
const cfg = b();
|
|
25130
25351
|
if (!cfg.master.root || !cfg.llm.root)
|
|
25131
25352
|
throw new Error("BrainLLM not bootstrapped \u2014 run bootstrap.");
|
|
25132
|
-
|
|
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)) {
|
|
25133
25371
|
await markStep("session", d);
|
|
25372
|
+
await stampSessionScope(d, scope ?? "interactive");
|
|
25134
25373
|
return txt3({
|
|
25135
25374
|
date: d,
|
|
25375
|
+
...continued ? { continuing: true } : {},
|
|
25136
25376
|
closeMode: "light",
|
|
25137
|
-
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.",
|
|
25138
25378
|
next: [
|
|
25139
25379
|
"Call remarks() \u2014 one short cue for a short session.",
|
|
25140
25380
|
"Call diary() \u2014 one short paragraph: what the session was and anything worth the team knowing.",
|
|
@@ -25142,6 +25382,49 @@ The close sequence next[] lays out: singleton updates from the session, then add
|
|
|
25142
25382
|
]
|
|
25143
25383
|
});
|
|
25144
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
|
+
}
|
|
25145
25428
|
const fetchSingleton = async (id) => {
|
|
25146
25429
|
if (!full) {
|
|
25147
25430
|
const note = await trilium.getNote(id);
|
|
@@ -25163,24 +25446,9 @@ The close sequence next[] lays out: singleton updates from the session, then add
|
|
|
25163
25446
|
fetchSingleton(cfg.llm.protocols),
|
|
25164
25447
|
cfg.llm.selfcorrection ? fetchSingleton(cfg.llm.selfcorrection) : Promise.resolve(null)
|
|
25165
25448
|
]);
|
|
25166
|
-
|
|
25167
|
-
if (cfg.llm.diary) {
|
|
25168
|
-
const diarySearch = await trilium.searchNotes(`#noteType=diary #created='${d}'`, { ancestorNoteId: cfg.llm.diary, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
|
|
25169
|
-
if (diarySearch.results[0]) {
|
|
25170
|
-
const id = diarySearch.results[0].noteId;
|
|
25171
|
-
const content = await trilium.getNoteContent(id).catch(() => "");
|
|
25172
|
-
diaryEntry = full ? { id, content } : { id, blocks: (content.match(/<h2(?:\s[^>]*)?>\s*Addendum/gi) ?? []).length, size: content.length };
|
|
25173
|
-
}
|
|
25174
|
-
}
|
|
25449
|
+
const diaryEntry = await diaryEntryOfToday();
|
|
25175
25450
|
const hygiene = await sweep(trilium, cfg, { deep: false, dryRun: false }).catch(() => null);
|
|
25176
|
-
const pendingAddendums = await
|
|
25177
|
-
const candidates = r.results.filter((n) => {
|
|
25178
|
-
const kind = labelOf2(n, "noteType");
|
|
25179
|
-
return kind && !["session", "diary", "log", "threadEntry", "thread"].includes(kind);
|
|
25180
|
-
});
|
|
25181
|
-
const contents = await Promise.all(candidates.map((n) => trilium.getNoteContent(n.noteId).catch(() => "")));
|
|
25182
|
-
return contents.filter(hasAddendumMarker).length;
|
|
25183
|
-
}).catch(() => null);
|
|
25451
|
+
const pendingAddendums = await pendingAddendumCount();
|
|
25184
25452
|
const singletonStubs = {
|
|
25185
25453
|
biography,
|
|
25186
25454
|
goals,
|
|
@@ -25203,11 +25471,13 @@ The close sequence next[] lays out: singleton updates from the session, then add
|
|
|
25203
25471
|
singletonsWrittenToday: touchedToday.length ? touchedToday : "none"
|
|
25204
25472
|
};
|
|
25205
25473
|
await markStep("session", d);
|
|
25474
|
+
await stampSessionScope(d, scope ?? "interactive");
|
|
25206
25475
|
const scoped = scope === "agent";
|
|
25207
25476
|
return txt3({
|
|
25208
25477
|
date: d,
|
|
25209
25478
|
...scoped ? { scope: "agent" } : {},
|
|
25210
|
-
...!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 } : {},
|
|
25211
25481
|
master: { biography, goals, preferences },
|
|
25212
25482
|
llm: { responsibilities, protocols, ...selfcorrection ? { selfcorrection } : {} },
|
|
25213
25483
|
diary: diaryEntry,
|
|
@@ -25246,6 +25516,7 @@ The close sequence next[] lays out: singleton updates from the session, then add
|
|
|
25246
25516
|
if (!(await readGate(localToday())).has("write")) {
|
|
25247
25517
|
return txt3({
|
|
25248
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.",
|
|
25249
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.",
|
|
25250
25521
|
next: ["Write it via diary(body, identity), then call close()."]
|
|
25251
25522
|
});
|
|
@@ -25293,7 +25564,9 @@ The close sequence next[] lays out: singleton updates from the session, then add
|
|
|
25293
25564
|
}
|
|
25294
25565
|
}
|
|
25295
25566
|
const gate = await readGate(gateDate);
|
|
25296
|
-
|
|
25567
|
+
if (wroteSinceClose && !gate.has("write") && await markWriteStep())
|
|
25568
|
+
gate.set("write", 1);
|
|
25569
|
+
const light = !(gate.has("write") || wroteSinceClose);
|
|
25297
25570
|
const required = light ? LIGHT_PRECLOSE_STEPS : REQUIRED_PRECLOSE_STEPS;
|
|
25298
25571
|
const missing = continued ? [] : required.filter((step) => !gate.has(step));
|
|
25299
25572
|
if (missing.length && !force) {
|
|
@@ -25382,6 +25655,10 @@ ${contentBlock}` }));
|
|
|
25382
25655
|
}
|
|
25383
25656
|
}
|
|
25384
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
|
+
}
|
|
25385
25662
|
return txt3({
|
|
25386
25663
|
action,
|
|
25387
25664
|
noteId,
|
|
@@ -25394,6 +25671,7 @@ ${contentBlock}` }));
|
|
|
25394
25671
|
...continued ? { continuing: true } : {},
|
|
25395
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" },
|
|
25396
25673
|
...light && !continued ? { closeMode: "light \u2014 no content was written this session, so addendum() and maintain() were not required" } : {},
|
|
25674
|
+
...writeMarkPending ? { gateNote: writeMarkPending } : {},
|
|
25397
25675
|
...missing.length || !orderOk ? { bypassed: [...missing, ...!orderOk ? ["ordering(session\u2192remarks\u2192diary)"] : []] } : {},
|
|
25398
25676
|
...warnings.length ? { sanitized: warnings } : {}
|
|
25399
25677
|
});
|
|
@@ -25527,7 +25805,7 @@ ${html}` }));
|
|
|
25527
25805
|
});
|
|
25528
25806
|
server.tool("remember", `Store something the moment it matters; the server owns placement, labels and dedup.
|
|
25529
25807
|
|
|
25530
|
-
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).
|
|
25531
25809
|
|
|
25532
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.`, {
|
|
25533
25811
|
kind: enumType(Kinds).describe("What kind of memory this is"),
|
|
@@ -25548,17 +25826,20 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
25548
25826
|
mandate: booleanType().optional().describe("kind=information: flag the note as a standing brief a session must follow (#mandate)"),
|
|
25549
25827
|
mustCreate: booleanType().optional().describe("Refuse instead of adopting a note that already has this title"),
|
|
25550
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'),
|
|
25551
25830
|
connect: arrayType(objectType({
|
|
25552
25831
|
relation: enumType(RelationTypes),
|
|
25553
25832
|
toNoteId: stringType()
|
|
25554
25833
|
})).optional().describe("Relations to wire from this note in the same call \u2014 same semantics as connect() (idempotent, worksWith wired both ways)"),
|
|
25555
25834
|
icon: stringType().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name ("brain"); normalized server-side'),
|
|
25556
25835
|
date: stringType().optional().describe("ISO date override (default: today)")
|
|
25557
|
-
}, 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 }) => {
|
|
25558
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.`);
|
|
25559
25838
|
const opts = { domain, topics, date, ...mandate ? { mandate: true } : {} };
|
|
25560
25839
|
const d = checkedDate(date);
|
|
25561
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.");
|
|
25562
25843
|
if (strict) {
|
|
25563
25844
|
const repairs = repairedStructure(sanitizeWarnings);
|
|
25564
25845
|
if (repairs.length) {
|
|
@@ -25601,6 +25882,20 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
25601
25882
|
return wiredEdges;
|
|
25602
25883
|
};
|
|
25603
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
|
+
};
|
|
25604
25899
|
if (isSingleton(kind)) {
|
|
25605
25900
|
const id = kindHome(b(), kind);
|
|
25606
25901
|
if (!id)
|
|
@@ -25614,6 +25909,48 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
25614
25909
|
if (kind === "sources") {
|
|
25615
25910
|
if (!domain)
|
|
25616
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
|
+
}
|
|
25617
25954
|
const { domainId, domainTitle, createdDomain, sourcesId } = await resolveDomain(trilium, b(), domain);
|
|
25618
25955
|
const found = sourcesId ? { results: [] } : await trilium.searchNotes("#noteType=sources", { ancestorNoteId: domainId, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
|
|
25619
25956
|
let sid = sourcesId ?? found.results[0]?.noteId;
|
|
@@ -25639,8 +25976,11 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
25639
25976
|
}
|
|
25640
25977
|
}
|
|
25641
25978
|
const revisionChanges = [];
|
|
25979
|
+
const addedKeys = [];
|
|
25980
|
+
let existingKeys = [];
|
|
25642
25981
|
if (sid && revision?.length) {
|
|
25643
25982
|
let current = await trilium.getNoteContent(sid);
|
|
25983
|
+
existingKeys = tableRows(current, "Revision").map((cells) => cells[0]).filter(Boolean);
|
|
25644
25984
|
let changed = false;
|
|
25645
25985
|
for (const row of revision) {
|
|
25646
25986
|
const result = upsertTableRow(current, "Revision", row.source, [row.marker, row.date ?? d]);
|
|
@@ -25648,6 +25988,8 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
25648
25988
|
current = result.html;
|
|
25649
25989
|
changed = true;
|
|
25650
25990
|
revisionChanges.push(`${result.matched ? "updated" : "added"}: ${row.source}`);
|
|
25991
|
+
if (!result.matched)
|
|
25992
|
+
addedKeys.push(row.source);
|
|
25651
25993
|
}
|
|
25652
25994
|
}
|
|
25653
25995
|
if (changed) {
|
|
@@ -25667,6 +26009,7 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
25667
26009
|
const placeholderLeft = hasPlaceholderRow(finalHtml, "Revision");
|
|
25668
26010
|
const bodyWritten = !!html && !!toText(html, 50);
|
|
25669
26011
|
const looseProse = bodyWritten && !createdDomain && !/<(?:ul|ol|li|h[34]|table)\b/i.test(html);
|
|
26012
|
+
const keyHints = sourceKeyHints(addedKeys, existingKeys);
|
|
25670
26013
|
return txt3({
|
|
25671
26014
|
action: wrote ? "maintained" : "already_written",
|
|
25672
26015
|
noteId: sid,
|
|
@@ -25678,6 +26021,7 @@ Collection kinds dedup by title \u2014 mustCreate=true refuses instead of adopti
|
|
|
25678
26021
|
...sourceMerge,
|
|
25679
26022
|
...revisionKeys.length ? { revisionRows: revisionKeys.length } : {},
|
|
25680
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 } : {},
|
|
25681
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." } : {},
|
|
25682
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." } : {},
|
|
25683
26027
|
...connected.length ? { connected } : {},
|
|
@@ -25783,6 +26127,7 @@ ${html}` : html;
|
|
|
25783
26127
|
if (kind === "thread") {
|
|
25784
26128
|
if (isCollectionThread(existing))
|
|
25785
26129
|
return collectionAppendRefusal(existing);
|
|
26130
|
+
const runHeading = leadingIdentityText(block);
|
|
25786
26131
|
const entry = await appendThreadEntry(existing.noteId, block, d);
|
|
25787
26132
|
if (entry.action === "already_written") {
|
|
25788
26133
|
return txt3({ action: "already_written", noteId: existing.noteId, entryId: entry.noteId, kind, title: existing.title });
|
|
@@ -25797,7 +26142,7 @@ ${html}` : html;
|
|
|
25797
26142
|
const connected = await wireRequested(existing.noteId);
|
|
25798
26143
|
const iconSet = await applyIcon(existing.noteId, icon);
|
|
25799
26144
|
const relations = relationSnippet(existing);
|
|
25800
|
-
return txt3({ action: "updated", noteId: existing.noteId, entryId: entry.noteId, entryAction: entry.action, entry: `today's [${d}] entry in this thread; revise(entryId) edits it`, kind, title: existing.title, ...connected.length ? { connected } : {}, ...iconSet ? { icon: iconSet } : {}, ...relations ? { relations } : {}, ...sanitizeWarnings.length ? { sanitized: sanitizeWarnings } : {} });
|
|
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 } : {} });
|
|
25801
26146
|
}
|
|
25802
26147
|
const current = await trilium.getNoteContent(existing.noteId);
|
|
25803
26148
|
if (isDuplicateAppend(current, block))
|
|
@@ -25861,7 +26206,7 @@ ${block}`), d);
|
|
|
25861
26206
|
...sanitizeWarnings.length ? { sanitized: sanitizeWarnings } : {}
|
|
25862
26207
|
});
|
|
25863
26208
|
});
|
|
25864
|
-
server.tool("recall", `Ranked search across the whole brain \u2014 label, title and full-text strategies merged, with kind and status. Pass domain= whenever you know the area. orderBy/orderDirection sort by date; fastSearch= scans titles and labels only; regex= matches note bodies with a real regular expression; a fuzzy pass runs automatically when exact results are thin (hits marked fuzzy: true are leads, not answers). Recently changed notes rank slightly higher at equal relevance.
|
|
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.`, {
|
|
25865
26210
|
query: stringType().describe("What to find \u2014 natural phrasing is fine"),
|
|
25866
26211
|
kinds: arrayType(enumType(Kinds)).optional().describe("Restrict to these kinds"),
|
|
25867
26212
|
domain: stringType().optional().describe("Restrict to a knowledge domain"),
|
|
@@ -25871,8 +26216,10 @@ ${block}`), d);
|
|
|
25871
26216
|
orderDirection: enumType(["asc", "desc"]).optional().describe("asc | desc (default: desc for dates, asc for title)"),
|
|
25872
26217
|
fastSearch: booleanType().optional().describe("Title/label only \u2014 faster, skips full-text body scan"),
|
|
25873
26218
|
regex: stringType().optional().describe("Regular expression matched against note bodies (Trilium %= operator). Takes precedence over the keyword strategies; escape backslashes."),
|
|
25874
|
-
fuzzy: booleanType().optional().describe("Force the fuzzy pass on (default: automatic when exact strategies return few results) or off")
|
|
25875
|
-
|
|
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 }) => {
|
|
25876
26223
|
const cfg = b();
|
|
25877
26224
|
const max = limit ?? 10;
|
|
25878
26225
|
const fast = fastSearch ?? false;
|
|
@@ -25880,13 +26227,26 @@ ${block}`), d);
|
|
|
25880
26227
|
const tokens = queryTokens(query);
|
|
25881
26228
|
const domSlug = domain ? slugify(domain) : null;
|
|
25882
26229
|
const kindSet = kinds?.length ? new Set(kinds) : null;
|
|
25883
|
-
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, {
|
|
25884
26237
|
ancestorNoteId: cfg.root,
|
|
25885
26238
|
limit: 30,
|
|
25886
26239
|
fastSearch: useFast,
|
|
25887
26240
|
includeArchivedNotes: includeArchived ?? false,
|
|
25888
26241
|
...ord ?? {}
|
|
25889
|
-
}).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
|
+
});
|
|
25890
26250
|
const filterNote = (note) => {
|
|
25891
26251
|
const k = labelOf2(note, "noteType");
|
|
25892
26252
|
if (!k)
|
|
@@ -25895,9 +26255,25 @@ ${block}`), d);
|
|
|
25895
26255
|
return false;
|
|
25896
26256
|
if (domSlug && labelOf2(note, "domain") !== domSlug)
|
|
25897
26257
|
return false;
|
|
26258
|
+
if (!withinModifiedWindow(note.dateModified, since, until))
|
|
26259
|
+
return false;
|
|
25898
26260
|
return true;
|
|
25899
26261
|
};
|
|
25900
|
-
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) => {
|
|
25901
26277
|
const relations = relationSnippet(note);
|
|
25902
26278
|
const base = {
|
|
25903
26279
|
id: note.noteId,
|
|
@@ -25906,11 +26282,13 @@ ${block}`), d);
|
|
|
25906
26282
|
status: labelOf2(note, "status"),
|
|
25907
26283
|
updated: note.dateModified.slice(0, 10),
|
|
25908
26284
|
...hasLabel(note, "archived") ? { archived: true } : {},
|
|
25909
|
-
...relations ? { relations } : {}
|
|
26285
|
+
...relations ? { relations } : {},
|
|
26286
|
+
...matchedBy ? { matchedBy } : {}
|
|
25910
26287
|
};
|
|
25911
26288
|
if (i < 3) {
|
|
25912
26289
|
const content = await trilium.getNoteContent(note.noteId).catch(() => "");
|
|
25913
|
-
|
|
26290
|
+
const evidence = matchedBy ? evidenceFor(note, content, matchedBy) : null;
|
|
26291
|
+
return { ...base, snippet: toText(content, 280), ...evidence ? { evidence } : {} };
|
|
25914
26292
|
}
|
|
25915
26293
|
return base;
|
|
25916
26294
|
};
|
|
@@ -25933,11 +26311,7 @@ ${block}`), d);
|
|
|
25933
26311
|
let recordHits = 0;
|
|
25934
26312
|
const evidenceIn = (text) => {
|
|
25935
26313
|
const m = new RegExp(re.source, "i").exec(text);
|
|
25936
|
-
|
|
25937
|
-
return "";
|
|
25938
|
-
const from = Math.max(0, m.index - 80);
|
|
25939
|
-
const to = Math.min(text.length, m.index + m[0].length + 80);
|
|
25940
|
-
return `${from > 0 ? "\u2026" : ""}${text.slice(from, to).replace(/\s+/g, " ").trim()}${to < text.length ? "\u2026" : ""}`;
|
|
26314
|
+
return m ? passageAround(text, m.index, m[0].length) : "";
|
|
25941
26315
|
};
|
|
25942
26316
|
for (const n of candidates) {
|
|
25943
26317
|
if (confirmed.length >= max)
|
|
@@ -25962,49 +26336,60 @@ ${block}`), d);
|
|
|
25962
26336
|
}
|
|
25963
26337
|
const results = await Promise.all(confirmed.map(async ({ note, evidence }, i) => {
|
|
25964
26338
|
const { snippet: _snippet, ...base } = await buildResult(note, i);
|
|
25965
|
-
return { ...base, ...evidence ? { evidence } : {} };
|
|
26339
|
+
return { ...base, ...evidence ? { evidence } : {}, matchedBy: "regex" };
|
|
25966
26340
|
}));
|
|
26341
|
+
const failed = strategyErrors > 0 && results.length === 0;
|
|
25967
26342
|
const notes = [
|
|
25968
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,
|
|
25969
26344
|
tagSpanning ? `${tagSpanning} match(es) were found only after stripping markup \u2014 the phrase is split by an inline tag there.` : null,
|
|
25970
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,
|
|
25971
26346
|
recordHits ? `${recordHits} result(s) are records (sessions, diary, dated thread entries) \u2014 included by default; kinds= narrows to maintained notes.` : null,
|
|
25972
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,
|
|
25973
|
-
|
|
25974
|
-
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
|
|
25975
26351
|
].filter(Boolean);
|
|
25976
26352
|
return txt3({
|
|
25977
26353
|
mode: "regex",
|
|
25978
26354
|
pattern: regex,
|
|
25979
26355
|
results,
|
|
26356
|
+
...failed ? { searchFailed: true } : {},
|
|
25980
26357
|
...notes.length ? { note: notes.join(" ") } : {}
|
|
25981
26358
|
});
|
|
25982
26359
|
}
|
|
25983
26360
|
if (orderBy) {
|
|
25984
26361
|
const ord = { orderBy, orderDirection: orderDirection ?? (orderBy === "title" ? "asc" : "desc") };
|
|
25985
26362
|
const q = query.trim() ? escapeQueryValue(query) : "#noteType";
|
|
25986
|
-
const notes = await run(q, fast, ord);
|
|
26363
|
+
const notes = await run(q, fast, "ordered", ord);
|
|
25987
26364
|
const filtered = notes.filter(filterNote).slice(0, max);
|
|
25988
|
-
const results = await Promise.all(filtered.map(buildResult));
|
|
25989
|
-
|
|
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
|
+
});
|
|
25990
26373
|
}
|
|
25991
26374
|
const scores = new Map;
|
|
25992
|
-
const add = (notes, weight) => {
|
|
26375
|
+
const add = (notes, weight, strategy) => {
|
|
25993
26376
|
for (const n of notes) {
|
|
25994
26377
|
const entry = scores.get(n.noteId);
|
|
25995
|
-
if (entry)
|
|
26378
|
+
if (entry) {
|
|
25996
26379
|
entry.score += weight;
|
|
25997
|
-
|
|
25998
|
-
|
|
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
|
+
}
|
|
25999
26384
|
}
|
|
26000
26385
|
};
|
|
26001
26386
|
const [byLabel, byTitleRaw, byText] = await Promise.all([
|
|
26002
|
-
slug.length >= 3 ? run(`#topic='${slug}' OR #domain='${slug}'`, true) : Promise.resolve([]),
|
|
26003
|
-
tokens.length ? run(tokens.map((t) => `note.title *=* '${escapeQueryValue(t)}'`).join(" OR "), fast || undefined) : Promise.resolve([]),
|
|
26004
|
-
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")
|
|
26005
26390
|
]);
|
|
26006
|
-
add(byLabel, 3);
|
|
26007
|
-
add(byText, 1);
|
|
26391
|
+
add(byLabel, 3, "label");
|
|
26392
|
+
add(byText, 1, "text");
|
|
26008
26393
|
const queryKey = titleKey(query);
|
|
26009
26394
|
for (const n of byTitleRaw) {
|
|
26010
26395
|
const lowerTitle = n.title.toLowerCase();
|
|
@@ -26017,18 +26402,18 @@ ${block}`), d);
|
|
|
26017
26402
|
weight += 8;
|
|
26018
26403
|
else if (queryKey.startsWith(noteKey) || noteKey.startsWith(queryKey))
|
|
26019
26404
|
weight += 4;
|
|
26020
|
-
add([n], weight);
|
|
26405
|
+
add([n], weight, "title");
|
|
26021
26406
|
}
|
|
26022
26407
|
const EXACT_ENOUGH = 5;
|
|
26023
26408
|
const fuzzyTokens = tokens.filter((t) => t.length >= 3);
|
|
26024
26409
|
const wantFuzzy = fuzzy ?? (scores.size < EXACT_ENOUGH && fuzzyTokens.length > 0);
|
|
26025
26410
|
const fuzzyIds = new Set;
|
|
26026
26411
|
if (wantFuzzy && fuzzyTokens.length) {
|
|
26027
|
-
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");
|
|
26028
26413
|
for (const n of byFuzzy)
|
|
26029
26414
|
if (!scores.has(n.noteId))
|
|
26030
26415
|
fuzzyIds.add(n.noteId);
|
|
26031
|
-
add(byFuzzy, 0.5);
|
|
26416
|
+
add(byFuzzy, 0.5, "fuzzy");
|
|
26032
26417
|
}
|
|
26033
26418
|
const now = Date.now();
|
|
26034
26419
|
const recency = (n) => {
|
|
@@ -26036,15 +26421,23 @@ ${block}`), d);
|
|
|
26036
26421
|
return Number.isFinite(age) ? 0.9 * Math.pow(0.5, age / 14) : 0;
|
|
26037
26422
|
};
|
|
26038
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);
|
|
26039
|
-
const results = await Promise.all(ranked.map(async ({ note }, i) => {
|
|
26040
|
-
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));
|
|
26041
26426
|
return fuzzyIds.has(note.noteId) ? { ...row, fuzzy: true } : row;
|
|
26042
26427
|
}));
|
|
26043
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);
|
|
26044
26436
|
return txt3({
|
|
26045
26437
|
results,
|
|
26046
26438
|
...fuzzyCount ? { fuzzyMatches: `${fuzzyCount} result(s) came from the fuzzy pass (marked fuzzy: true) \u2014 treat them as leads to verify, not as answers.` } : {},
|
|
26047
|
-
...
|
|
26439
|
+
...failed ? { searchFailed: true } : {},
|
|
26440
|
+
...notices.length ? { note: notices.join(" ") } : {}
|
|
26048
26441
|
});
|
|
26049
26442
|
});
|
|
26050
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.`, {
|
|
@@ -26101,7 +26494,7 @@ ${block}`), d);
|
|
|
26101
26494
|
groups[kind] = [];
|
|
26102
26495
|
const relations = relationSnippet(n);
|
|
26103
26496
|
const idle = idleSince(n.dateModified);
|
|
26104
|
-
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);
|
|
26105
26498
|
if (stale)
|
|
26106
26499
|
staleCount++;
|
|
26107
26500
|
groups[kind].push({
|
|
@@ -26146,21 +26539,49 @@ ${block}`), d);
|
|
|
26146
26539
|
});
|
|
26147
26540
|
});
|
|
26148
26541
|
const READ_BUDGET = 80000;
|
|
26149
|
-
server.tool("read", `Several note bodies in one round trip: ids=[\u2026] (up to 10) \u2192 {notes:[{id, title, kind, content, relations}]}. Bodies are returned in order until about 80k characters; the rest come back under deferred[] with their sizes, to read in a second call. text=true returns readable plain text with the markup removed (headings, list items and table rows kept), smaller and easier to read \u2014 right for orienting, not for building a find= anchor. For one note prefer the kinded read
|
|
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.`, {
|
|
26150
26543
|
ids: arrayType(stringType()).min(1).max(10).describe("Note ids to read \u2014 one body per id, one round trip (cap 10)"),
|
|
26151
|
-
|
|
26152
|
-
|
|
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 }) => {
|
|
26153
26548
|
const fetched = await Promise.all(ids.map(async (id) => {
|
|
26154
26549
|
try {
|
|
26155
26550
|
const note = await trilium.getNote(id);
|
|
26156
26551
|
const contentResult = await trilium.getNoteContentResult(id, note.type);
|
|
26157
26552
|
const relations = relationSnippet(note);
|
|
26158
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
|
+
}
|
|
26159
26582
|
const content = text && typeof contentResult === "string" ? readableText(raw) : raw;
|
|
26160
26583
|
return {
|
|
26161
|
-
|
|
26162
|
-
title: note.title,
|
|
26163
|
-
kind: ownedLabel(note, "noteType") ?? undefined,
|
|
26584
|
+
...identity,
|
|
26164
26585
|
content,
|
|
26165
26586
|
...typeof contentResult === "string" ? {} : { contentEncoding: contentResult.encoding, mime: contentResult.mime },
|
|
26166
26587
|
...relations ? { relations } : {}
|
|
@@ -26184,7 +26605,7 @@ ${block}`), d);
|
|
|
26184
26605
|
return txt3({
|
|
26185
26606
|
count: notes.length,
|
|
26186
26607
|
notes,
|
|
26187
|
-
...deferred.length ? { deferred, hint: `${deferred.length} note(s) held back to keep this result under ~${READ_BUDGET / 1000}k characters. Read them with read(ids=[\u2026]) again${text ? "" : ", or pass text=true, which is smaller"}
|
|
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=.` } : {}
|
|
26188
26609
|
});
|
|
26189
26610
|
});
|
|
26190
26611
|
const reviseDomain = async (name, plan, dry, d) => {
|
|
@@ -26259,12 +26680,12 @@ ${block}`), d);
|
|
|
26259
26680
|
};
|
|
26260
26681
|
server.tool("revise", `Edit a note by id. A revision is taken first; "Last updated" lines are bumped on content writes.
|
|
26261
26682
|
|
|
26262
|
-
Modes: default append (a dated addendum \u2014 right only for records; a dated thread's append lands in today's child), mode=replace (whole body), section="<heading>" (replace that section's whole body, h2\u2192h3\u2192h4, occurrence= for repeats), section= + mode=before|after (insert a sibling block around the whole section), mode=prepend (top of the section body), mode=remove (delete the section). find="<exact stored text>" replaces every occurrence (nth= for one); edits=[{find, body}] applies several in one write. find= + within="tr" (or li, p, td, \u2026) acts on the element containing a short anchor: replace it, insert a row before/after it, or remove it, so a table row never needs its full stored markup. within= names the element acted on: within="tr" replaces the whole row (body is a full <tr>\u2026</tr>), within="td" one cell (body is a <td>\u2026</td>), and a plain find= inside a cell replaces only that text. Keep anchors short; quotes match in any form, and within= also matches an element by its visible text ("Phase 2 | Claude | Open" for a row).
|
|
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.
|
|
26263
26684
|
|
|
26264
|
-
Check the receipt: matched=false means a NEW section was written (available= lists real headings; strict=true refuses instead). A section replace swaps everything under the heading \u2014 use find= for anything smaller. find= matches stored HTML (tags literal), not rendered text, and is taken literally: a Windows path's backslash is one backslash, never doubled.`, {
|
|
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=).`, {
|
|
26265
26686
|
noteId: stringType().optional().describe("Note to update (omit only with domain=)"),
|
|
26266
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"),
|
|
26267
|
-
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"),
|
|
26268
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)."),
|
|
26269
26690
|
title: stringType().optional().describe("New title (normalized server-side)"),
|
|
26270
26691
|
section: stringType().optional().describe("Target a section by heading text (h2/h3/h4, in that order); omit for whole-note append/replace"),
|
|
@@ -26273,11 +26694,17 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
26273
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)"),
|
|
26274
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."),
|
|
26275
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"),
|
|
26276
|
-
within: enumType(WITHIN_TAGS).optional().describe('With find=: act on the <tag> element that contains the find text (a short unique anchor) \u2014 mode="replace" (default) swaps it for body=, "before"/"after" insert body= as a sibling (add a table row), "remove" deletes it'),
|
|
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'),
|
|
26277
26698
|
closure: objectType({
|
|
26278
|
-
thread: stringType().describe('
|
|
26279
|
-
body: stringType().describe("
|
|
26280
|
-
|
|
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"}'),
|
|
26281
26708
|
edits: arrayType(objectType({
|
|
26282
26709
|
find: stringType().describe("Exact raw string to replace"),
|
|
26283
26710
|
body: stringType().describe("Raw replacement string"),
|
|
@@ -26286,20 +26713,30 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
26286
26713
|
identity: stringType().optional().describe('Appending to a dated thread: the identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" (required)'),
|
|
26287
26714
|
icon: stringType().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name; normalized server-side'),
|
|
26288
26715
|
date: stringType().optional().describe("ISO date (default: today)")
|
|
26289
|
-
}, async ({ noteId: noteIdArg, domain, dryRun, body, title, section, occurrence, mode, find, nth, within, closure, edits, identity, icon, date, strict }) => {
|
|
26290
|
-
if (closure
|
|
26291
|
-
|
|
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
|
+
}
|
|
26292
26729
|
if (domain !== undefined) {
|
|
26293
26730
|
if (noteIdArg)
|
|
26294
26731
|
return err("conflicting_params", "Pass noteId= or domain=, not both.", "domain= edits every note in the domain; noteId= edits one.");
|
|
26295
26732
|
if (find === undefined && edits === undefined)
|
|
26296
26733
|
return err("missing_param", "domain= needs find= (with body=) or edits=.", 'e.g. revise(domain="myClerkBook", find="Code Priority Queue", body="Backlog")');
|
|
26297
|
-
if (within || section || title || mode || identity || icon)
|
|
26734
|
+
if (within || section || title || mode || identity || icon || append)
|
|
26298
26735
|
return err("conflicting_params", "domain= takes only find=/body=, edits=, nth= and dryRun=.", "Use noteId= for element, section, title or icon edits.");
|
|
26299
26736
|
return reviseDomain(domain, edits ?? [{ find, body: body ?? "", nth }], dryRun ?? true, checkedDate(date));
|
|
26300
26737
|
}
|
|
26301
|
-
if (dryRun !== undefined)
|
|
26302
|
-
return err("conflicting_params", "dryRun= applies only to revise(domain=); nothing was written.", "For one note, read it first (or outline()) and then revise without dryRun. diff(noteId) shows what a write changed.");
|
|
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.");
|
|
26303
26740
|
if (!noteIdArg)
|
|
26304
26741
|
return err("missing_param", "revise needs noteId= (or domain= for a domain-wide find/replace).", "Pass the id of the note to edit.");
|
|
26305
26742
|
const noteId = noteIdArg;
|
|
@@ -26307,6 +26744,12 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
26307
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.");
|
|
26308
26745
|
if (within && find === undefined)
|
|
26309
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
|
+
}
|
|
26310
26753
|
const d = checkedDate(date);
|
|
26311
26754
|
const note = await trilium.getNote(noteId);
|
|
26312
26755
|
const noteKind = labelOf2(note, "noteType");
|
|
@@ -26337,6 +26780,40 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
26337
26780
|
}
|
|
26338
26781
|
return { retitled: cleanTitle, cascaded };
|
|
26339
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
|
+
}
|
|
26340
26817
|
if (find !== undefined || edits !== undefined) {
|
|
26341
26818
|
if (find !== undefined && edits !== undefined)
|
|
26342
26819
|
return err("conflicting_params", "Pass either find= or edits=, not both.", "Fold the single find/body pair into the edits array.");
|
|
@@ -26395,31 +26872,89 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
26395
26872
|
date: d,
|
|
26396
26873
|
reason: res.reason,
|
|
26397
26874
|
hint,
|
|
26875
|
+
...dryRun === true ? { dryRun: true } : {},
|
|
26398
26876
|
...res.elements ? { elements: res.elements } : {},
|
|
26399
26877
|
...res.reason === "no_match" ? missContext(find, current) : {}
|
|
26400
26878
|
});
|
|
26401
26879
|
}
|
|
26402
|
-
if (res.html === current)
|
|
26880
|
+
if (res.html === current && dryRun !== true)
|
|
26403
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." });
|
|
26404
26882
|
let closureThread = null;
|
|
26405
26883
|
let closureBlock = "";
|
|
26884
|
+
let closureTarget = null;
|
|
26885
|
+
let closureCells = [];
|
|
26886
|
+
let closurePadded = 0;
|
|
26406
26887
|
if (closure) {
|
|
26407
|
-
|
|
26408
|
-
|
|
26409
|
-
|
|
26410
|
-
|
|
26411
|
-
|
|
26412
|
-
|
|
26413
|
-
|
|
26414
|
-
|
|
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>
|
|
26415
26924
|
${rendered}`;
|
|
26416
|
-
|
|
26417
|
-
|
|
26418
|
-
|
|
26419
|
-
|
|
26420
|
-
|
|
26421
|
-
|
|
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>
|
|
26422
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
|
+
});
|
|
26423
26958
|
}
|
|
26424
26959
|
await trilium.createRevision(noteId).catch(() => null);
|
|
26425
26960
|
const withinResult = sanitizeHtml(res.html);
|
|
@@ -26437,14 +26972,47 @@ ${closureHtml}`;
|
|
|
26437
26972
|
await trilium.updateLabelValue(closureThread.noteId, "updated", d);
|
|
26438
26973
|
if (labelOf2(closureThread, "status") === "dormant")
|
|
26439
26974
|
await trilium.updateLabelValue(closureThread.noteId, "status", "active");
|
|
26975
|
+
const closureHeading = leadingIdentityText(closureBlock);
|
|
26440
26976
|
closureReceipt = {
|
|
26441
26977
|
threadId: closureThread.noteId,
|
|
26442
26978
|
thread: closureThread.title,
|
|
26443
26979
|
entryId: entry.noteId,
|
|
26444
26980
|
action: entry.action,
|
|
26981
|
+
...entry.marker ? { entryMarker: entry.marker } : {},
|
|
26982
|
+
...closureHeading ? { runHeading: closureHeading } : {},
|
|
26445
26983
|
next: "Run consistency() on any fact the closed row asserted elsewhere, so no stale sentence survives it."
|
|
26446
26984
|
};
|
|
26447
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
|
+
}
|
|
26448
27016
|
return txt3({
|
|
26449
27017
|
ok: true,
|
|
26450
27018
|
noteId,
|
|
@@ -26468,11 +27036,12 @@ ${closureHtml}`;
|
|
|
26468
27036
|
const applied = applyFindEdit(working, e.find, e.body, e.nth);
|
|
26469
27037
|
if (!applied) {
|
|
26470
27038
|
const alreadyConsumed = consumed.has(e.find);
|
|
27039
|
+
const nthOvershot = !alreadyConsumed && e.nth !== undefined && working.includes(e.find);
|
|
26471
27040
|
results.push({
|
|
26472
27041
|
find: e.find,
|
|
26473
27042
|
replaced: 0,
|
|
26474
|
-
hint: missHint(e.find, current, alreadyConsumed),
|
|
26475
|
-
...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)
|
|
26476
27045
|
});
|
|
26477
27046
|
continue;
|
|
26478
27047
|
}
|
|
@@ -26488,7 +27057,7 @@ ${closureHtml}`;
|
|
|
26488
27057
|
mode: edits ? "edits" : "find-replace",
|
|
26489
27058
|
replaced: 0,
|
|
26490
27059
|
date: d,
|
|
26491
|
-
...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.` } : {
|
|
26492
27061
|
hint: results[0].hint,
|
|
26493
27062
|
...results[0].matchedUpTo ? { matchedUpTo: results[0].matchedUpTo } : {},
|
|
26494
27063
|
...results[0].storedNearby ? { storedNearby: results[0].storedNearby } : {}
|
|
@@ -26584,6 +27153,7 @@ ${closureHtml}`;
|
|
|
26584
27153
|
let finalContent = null;
|
|
26585
27154
|
let sectionMiss = {};
|
|
26586
27155
|
let threadEntryResult = null;
|
|
27156
|
+
let threadRunHeading = null;
|
|
26587
27157
|
if (body) {
|
|
26588
27158
|
const sanitized = renderBody(body);
|
|
26589
27159
|
const html = sanitized.html;
|
|
@@ -26634,6 +27204,7 @@ ${closureHtml}`;
|
|
|
26634
27204
|
return badIdentity;
|
|
26635
27205
|
const block = identity && !leadingIdentification(html) ? `<h3>${escapeHtml(identity)}</h3>
|
|
26636
27206
|
${html}` : html;
|
|
27207
|
+
threadRunHeading = leadingIdentityText(block);
|
|
26637
27208
|
threadEntryResult = await appendThreadEntry(noteId, block, d);
|
|
26638
27209
|
if (threadEntryResult.action === "already_written") {
|
|
26639
27210
|
return txt3({ ok: true, noteId, mode: "already_written", entryId: threadEntryResult.noteId, date: d });
|
|
@@ -26670,7 +27241,7 @@ ${block}`), d).html;
|
|
|
26670
27241
|
} : {},
|
|
26671
27242
|
...sectionMiss,
|
|
26672
27243
|
...sectionHint ? { hint: sectionHint } : {},
|
|
26673
|
-
...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` } : {},
|
|
26674
27245
|
...titled.retitled ? { retitled: titled.retitled } : {},
|
|
26675
27246
|
...titled.cascaded ? { domainCascade: `#domain updated on ${titled.cascaded} descendant note(s)` } : {},
|
|
26676
27247
|
...structuralFindings(noteKind, finalContent),
|
|
@@ -26942,7 +27513,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
|
|
|
26942
27513
|
}
|
|
26943
27514
|
}
|
|
26944
27515
|
});
|
|
26945
|
-
server.tool("consistency", `Does the brain agree with itself? pattern= is a regex with ONE capture group naming the value that should match across notes, e.g. "(\\\\d+) mailboxes"; the result groups every asserting note by value with agreement unanimous or DISAGREEMENT. subject="<fact in prose>" finds notes asserting about a subject however phrased. staleAfterDays=N also reports values held in exactly one note untouched N+ days. Matches stored HTML and tag-stripped text; escape backslashes; scope with domain=/kinds=. Records (sessions, diary, logs, dated thread entries) are skipped by default because they are never rewritten; includeRecords=true or a kinds= naming them brings them back. Scans every in-scope note (fast=true uses Trilium's lossy pre-filter). Matching ignores case unless caseSensitive=true (a leading (?i) is accepted). Without a capture group the whole match is the value compared. patterns=[\u2026] checks several facts in one call over one read of each note. Run it after correcting any fact recorded in more than one place.`, {
|
|
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.`, {
|
|
26946
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)."),
|
|
26947
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"),
|
|
26948
27519
|
caseSensitive: booleanType().optional().describe('Match case exactly (default false: "Blueprints" and "blueprints" both match)'),
|
|
@@ -27047,6 +27618,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
|
|
|
27047
27618
|
clauses.push(`#domain='${slugify(domain)}'`);
|
|
27048
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(() => []);
|
|
27049
27620
|
const scoped = notes.filter(inScope).slice(0, max);
|
|
27621
|
+
const noteValues = new Map;
|
|
27050
27622
|
const byValue = new Map;
|
|
27051
27623
|
const noCapture = [];
|
|
27052
27624
|
let hasCaptureGroup = false;
|
|
@@ -27079,15 +27651,19 @@ calling twice is safe. Use remove=true to delete an edge.`, {
|
|
|
27079
27651
|
const seen = new Set;
|
|
27080
27652
|
for (const value of [...raw.values, ...projected.values]) {
|
|
27081
27653
|
hasCaptureGroup = true;
|
|
27082
|
-
if (seen.has(value))
|
|
27083
|
-
continue;
|
|
27084
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) {
|
|
27085
27663
|
if (!byValue.has(value))
|
|
27086
27664
|
byValue.set(value, []);
|
|
27087
27665
|
byValue.get(value).push(stub);
|
|
27088
27666
|
}
|
|
27089
|
-
if ((raw.matched || projected.matched) && !seen.size)
|
|
27090
|
-
noCapture.push(stub);
|
|
27091
27667
|
}
|
|
27092
27668
|
if (!hasCaptureGroup) {
|
|
27093
27669
|
return txt3({
|
|
@@ -27103,7 +27679,18 @@ calling twice is safe. Use remove=true to delete an edge.`, {
|
|
|
27103
27679
|
}
|
|
27104
27680
|
const groups = [...byValue.entries()].map(([value, notes]) => ({ value, count: notes.length, notes })).sort((a, b) => b.count - a.count);
|
|
27105
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 })) : [];
|
|
27106
|
-
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;
|
|
27107
27694
|
return txt3({
|
|
27108
27695
|
mode: "consistency",
|
|
27109
27696
|
pattern,
|
|
@@ -27120,7 +27707,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
|
|
|
27120
27707
|
...staleAfterDays ? { staleAfterDays } : {},
|
|
27121
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.` } : {},
|
|
27122
27709
|
...noCapture.length ? { matchedWithoutValue: noCapture } : {},
|
|
27123
|
-
note: agrees ? groups.length === 0 ? "No note asserted a value for that pattern. That is evidence about the pattern, not about the brain \u2014 check it against a note you know contains the fact." : `All ${groups[0].count} note(s) agree on "${groups[0].value}".` : `${groups.length} DIFFERENT values are asserted across ${scoped.length} notes. Establish which is true from evidence, correct every note that disagrees, and wire ~corrects from the note that overturns the old claim \u2014 revising in place leaves no trace the wrong value was ever believed.`
|
|
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.`
|
|
27124
27711
|
});
|
|
27125
27712
|
}
|
|
27126
27713
|
});
|
|
@@ -27314,7 +27901,7 @@ calling twice is safe. Use remove=true to delete an edge.`, {
|
|
|
27314
27901
|
};
|
|
27315
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.
|
|
27316
27903
|
|
|
27317
|
-
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.`, {
|
|
27318
27905
|
assertion: stringType().optional().describe('The claim in plain words, e.g. "the parse pipeline runs before validation" \u2014 also the dedup key'),
|
|
27319
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"),
|
|
27320
27907
|
noteId: stringType().optional().describe("The note this claim is made in \u2014 wired ~derivedFrom so the claim and its source stay linked"),
|
|
@@ -27323,7 +27910,7 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
|
|
|
27323
27910
|
holds: booleanType().optional().describe("With claimId: did the check pass? false marks the claim broken and surfaces it in maintain()"),
|
|
27324
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"),
|
|
27325
27912
|
status: enumType(["holding", "broken", "lapsed", "all"]).optional().describe("List filter (default: all)"),
|
|
27326
|
-
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")
|
|
27327
27914
|
}, async ({ assertion, check, noteId, intervalDays, claimId, holds, evidence, status, retire }) => {
|
|
27328
27915
|
const cfg = b();
|
|
27329
27916
|
if (!cfg.root)
|
|
@@ -27349,7 +27936,7 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
|
|
|
27349
27936
|
const note = await trilium.getNote(claimId).catch(() => null);
|
|
27350
27937
|
if (!note || labelOf2(note, "noteType") !== "claim")
|
|
27351
27938
|
return err("not_found", `${claimId} is not a claim.`, "List them with claim() and no arguments.");
|
|
27352
|
-
if (retire) {
|
|
27939
|
+
if (retire && holds === undefined) {
|
|
27353
27940
|
await trilium.addLabel(claimId, "archived", "").catch(() => null);
|
|
27354
27941
|
return txt3({ ok: true, claimId, action: "retired", assertion: note.title });
|
|
27355
27942
|
}
|
|
@@ -27367,11 +27954,14 @@ assertion + check \u2192 register (deduped by assertion); claimId + holds + evid
|
|
|
27367
27954
|
await trilium.updateLabelValue(claimId, "verified", d).catch(() => null);
|
|
27368
27955
|
await trilium.updateLabelValue(claimId, "claimState", holds ? "holding" : "broken").catch(() => null);
|
|
27369
27956
|
await trilium.updateLabelValue(claimId, "updated", d).catch(() => null);
|
|
27957
|
+
if (retire)
|
|
27958
|
+
await trilium.addLabel(claimId, "archived", "").catch(() => null);
|
|
27370
27959
|
const refreshed = await trilium.getNote(claimId).catch(() => note);
|
|
27371
27960
|
return txt3({
|
|
27372
27961
|
ok: true,
|
|
27373
27962
|
action: holds ? "verified" : "marked-broken",
|
|
27374
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." } : {},
|
|
27375
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." }
|
|
27376
27966
|
});
|
|
27377
27967
|
}
|
|
@@ -27912,7 +28502,8 @@ and every thread day-child, in time order with its identification line.`, {
|
|
|
27912
28502
|
const todaySess = await trilium.searchNotes(`#noteType=session #created='${todayStr}'`, { ancestorNoteId: cfg.memory.sessions, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
|
|
27913
28503
|
if (todaySess.results[0]) {
|
|
27914
28504
|
const content = await trilium.getNoteContent(todaySess.results[0].noteId).catch(() => "");
|
|
27915
|
-
|
|
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);
|
|
27916
28507
|
}
|
|
27917
28508
|
const sessions = await trilium.searchNotes("#noteType=session", {
|
|
27918
28509
|
ancestorNoteId: cfg.memory.sessions,
|
|
@@ -27932,6 +28523,7 @@ and every thread day-child, in time order with its identification line.`, {
|
|
|
27932
28523
|
previousLog = { id: log.results[0].noteId, content: await trilium.getNoteContent(log.results[0].noteId).catch(() => "") };
|
|
27933
28524
|
}
|
|
27934
28525
|
let changes = [];
|
|
28526
|
+
let changesTruncated;
|
|
27935
28527
|
if (lastSession) {
|
|
27936
28528
|
try {
|
|
27937
28529
|
const history = await trilium.getNoteHistory(cfg.root);
|
|
@@ -27941,12 +28533,14 @@ and every thread day-child, in time order with its identification line.`, {
|
|
|
27941
28533
|
if (!prevEntry || h.current_isDeleted && !prevEntry.current_isDeleted)
|
|
27942
28534
|
deduped.set(h.noteId, h);
|
|
27943
28535
|
}
|
|
27944
|
-
|
|
28536
|
+
const capped = capChanges([...deduped.values()].map((h) => ({
|
|
27945
28537
|
id: h.noteId,
|
|
27946
28538
|
title: h.current_title,
|
|
27947
28539
|
changed: h.date.slice(0, 10),
|
|
27948
28540
|
...h.current_isDeleted ? { deleted: true } : {}
|
|
27949
|
-
}));
|
|
28541
|
+
})));
|
|
28542
|
+
changes = capped.changes;
|
|
28543
|
+
changesTruncated = capped.changesTruncated;
|
|
27950
28544
|
} catch {}
|
|
27951
28545
|
}
|
|
27952
28546
|
return txt3({
|
|
@@ -27955,6 +28549,7 @@ and every thread day-child, in time order with its identification line.`, {
|
|
|
27955
28549
|
lastSession,
|
|
27956
28550
|
previousLog,
|
|
27957
28551
|
changes: changes.length ? changes : undefined,
|
|
28552
|
+
...changesTruncated ? { changesTruncated } : {},
|
|
27958
28553
|
next: [
|
|
27959
28554
|
"Skim lastSession, previousLog and changes for evidenced outputs \u2014 never plausibility.",
|
|
27960
28555
|
"Present what moved in the first message."
|
|
@@ -28222,8 +28817,8 @@ and every thread day-child, in time order with its identification line.`, {
|
|
|
28222
28817
|
tool.handler = async (...args) => {
|
|
28223
28818
|
const result = await inner(...args);
|
|
28224
28819
|
if (!wroteSinceClose && wroteContent(result)) {
|
|
28225
|
-
await markStep("write").catch(() => null);
|
|
28226
28820
|
wroteSinceClose = true;
|
|
28821
|
+
await markWriteStep();
|
|
28227
28822
|
}
|
|
28228
28823
|
return result;
|
|
28229
28824
|
};
|
|
@@ -28236,7 +28831,7 @@ function wroteContent(result) {
|
|
|
28236
28831
|
return true;
|
|
28237
28832
|
try {
|
|
28238
28833
|
const r = JSON.parse(text);
|
|
28239
|
-
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)
|
|
28240
28835
|
return false;
|
|
28241
28836
|
if (r.replaced === 0 && !r.closure)
|
|
28242
28837
|
return false;
|
|
@@ -30205,7 +30800,7 @@ function createServer(origin = null) {
|
|
|
30205
30800
|
const s = new McpServer({
|
|
30206
30801
|
name: BRAND2.name,
|
|
30207
30802
|
title: BRAND2.name,
|
|
30208
|
-
version: "
|
|
30803
|
+
version: "13.0.0",
|
|
30209
30804
|
description: BRAND2.description,
|
|
30210
30805
|
websiteUrl: BRAND2.website,
|
|
30211
30806
|
icons: brandIcons(origin)
|