brainllm 12.7.1 → 12.8.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 +22 -12
- package/SECURITY.md +4 -2
- package/dist/index.js +670 -111
- package/manifest.json +4 -4
- package/package.json +5 -4
- package/server.json +5 -5
- package/skills/brainllm/SKILL.md +10 -8
- package/skills/brainllm/references/troubleshooting.md +4 -0
package/dist/index.js
CHANGED
|
@@ -21881,6 +21881,36 @@ function addendumIndex(html) {
|
|
|
21881
21881
|
}
|
|
21882
21882
|
return out;
|
|
21883
21883
|
}
|
|
21884
|
+
function addendumBlock(html, block) {
|
|
21885
|
+
const markerRe = /<h2(?:\s[^>]*)?>\s*((?:Addendum|Withdrawn|Recovered|Reopened)[^<]*)<\/h2>/gi;
|
|
21886
|
+
const hits = [...html.matchAll(markerRe)];
|
|
21887
|
+
const markers = hits.map((m) => decodeEntities(m[1]).replace(/\s+/g, " ").trim());
|
|
21888
|
+
const want = block.trim();
|
|
21889
|
+
let i = -1;
|
|
21890
|
+
if (/^-?\d+$/.test(want)) {
|
|
21891
|
+
const n = parseInt(want, 10);
|
|
21892
|
+
i = n < 0 ? hits.length + n : n - 1;
|
|
21893
|
+
} else {
|
|
21894
|
+
i = markers.findIndex((m) => m.endsWith(want) || m.includes(`\u2014 ${want}`) || m === want);
|
|
21895
|
+
}
|
|
21896
|
+
if (i < 0 || i >= hits.length)
|
|
21897
|
+
return { matched: false, available: markers };
|
|
21898
|
+
const from = hits[i].index;
|
|
21899
|
+
const to = i + 1 < hits.length ? hits[i + 1].index : html.length;
|
|
21900
|
+
const content = html.slice(from, to).trim();
|
|
21901
|
+
const idMatch = content.match(/<h3(?:\s[^>]*)?>([\s\S]*?)<\/h3>/i);
|
|
21902
|
+
const identity = idMatch ? decodeEntities(idMatch[1].replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim() : undefined;
|
|
21903
|
+
return { matched: true, marker: markers[i], ...identity ? { identity } : {}, content, position: i + 1, of: hits.length };
|
|
21904
|
+
}
|
|
21905
|
+
function readableText(html) {
|
|
21906
|
+
return decodeEntities(html.replace(/<(style|script)[\s\S]*?<\/\1>/gi, "").replace(/<h([1-6])[^>]*>/gi, (_, n) => `
|
|
21907
|
+
` + "#".repeat(Number(n)) + " ").replace(/<li[^>]*>/gi, `
|
|
21908
|
+
- `).replace(/<\/t[dh]>\s*<t[dh][^>]*>/gi, " | ").replace(/<(tr|p|div|br|blockquote|figure|table|ul|ol|hr)[^>]*>/gi, `
|
|
21909
|
+
`).replace(/<[^>]+>/g, "")).replace(/[ \t]+/g, " ").replace(/ *\n */g, `
|
|
21910
|
+
`).replace(/\n{3,}/g, `
|
|
21911
|
+
|
|
21912
|
+
`).trim();
|
|
21913
|
+
}
|
|
21884
21914
|
function nearestContext(source, needle, radius = 40) {
|
|
21885
21915
|
if (!source || needle.length < 4)
|
|
21886
21916
|
return null;
|
|
@@ -22258,6 +22288,74 @@ function tolerantFindRegex(find) {
|
|
|
22258
22288
|
function spansBlockBoundary(find) {
|
|
22259
22289
|
return /<\/[a-zA-Z][a-zA-Z0-9-]*\s*>\s*<[a-zA-Z]/.test(find);
|
|
22260
22290
|
}
|
|
22291
|
+
var INLINE_TAGS = "strong|em|b|i|u|s|del|ins|code|a|span|mark|sub|sup|kbd|small";
|
|
22292
|
+
var INLINE_TAG_RX = new RegExp(`<\\/?(?:${INLINE_TAGS})(?:\\s[^<>]*)?\\/?>`, "gi");
|
|
22293
|
+
var ENTITY_FORMS = {
|
|
22294
|
+
"&": "&(?:amp;)?",
|
|
22295
|
+
"<": "<",
|
|
22296
|
+
">": ">",
|
|
22297
|
+
'"': '(?:"|")',
|
|
22298
|
+
"'": "(?:'|'|')"
|
|
22299
|
+
};
|
|
22300
|
+
function inlineTolerantSpans(source, needle) {
|
|
22301
|
+
if (/<\/?(?!(?:strong|em|b|i|u|s|del|ins|code|a|span|mark|sub|sup|kbd|small)\b)[a-zA-Z]/.test(needle))
|
|
22302
|
+
return [];
|
|
22303
|
+
const text = decodeEntities(needle.replace(INLINE_TAG_RX, "")).trim();
|
|
22304
|
+
if (text.length < 2)
|
|
22305
|
+
return [];
|
|
22306
|
+
const gap = `(?:<\\/?(?:${INLINE_TAGS})(?:\\s[^<>]*)?\\/?>)*`;
|
|
22307
|
+
const esc = (c) => c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
22308
|
+
const parts = [];
|
|
22309
|
+
for (const ch of text.replace(/\s+/g, " ")) {
|
|
22310
|
+
parts.push(ch === " " ? "(?:\\s| | )+" : ENTITY_FORMS[ch] ?? esc(ch));
|
|
22311
|
+
}
|
|
22312
|
+
let rx;
|
|
22313
|
+
try {
|
|
22314
|
+
rx = new RegExp(parts.join(gap), "g");
|
|
22315
|
+
} catch {
|
|
22316
|
+
return [];
|
|
22317
|
+
}
|
|
22318
|
+
const out = [];
|
|
22319
|
+
for (const m of source.matchAll(rx)) {
|
|
22320
|
+
const span = balanceInline(source, m.index, m.index + m[0].length);
|
|
22321
|
+
if (span)
|
|
22322
|
+
out.push({ start: span.start, length: span.end - span.start });
|
|
22323
|
+
}
|
|
22324
|
+
return out.filter((s, i) => i === 0 || s.start >= out[i - 1].start + out[i - 1].length);
|
|
22325
|
+
}
|
|
22326
|
+
function balanceInline(src, start, end) {
|
|
22327
|
+
for (let guard = 0;guard < 8; guard++) {
|
|
22328
|
+
const stack = [];
|
|
22329
|
+
let strayClose = null;
|
|
22330
|
+
for (const t of src.slice(start, end).matchAll(INLINE_TAG_RX)) {
|
|
22331
|
+
const name = /^<\/?([a-z]+)/i.exec(t[0])[1].toLowerCase();
|
|
22332
|
+
if (t[0].endsWith("/>"))
|
|
22333
|
+
continue;
|
|
22334
|
+
if (t[0].startsWith("</")) {
|
|
22335
|
+
if (stack[stack.length - 1] === name)
|
|
22336
|
+
stack.pop();
|
|
22337
|
+
else if (!strayClose)
|
|
22338
|
+
strayClose = name;
|
|
22339
|
+
} else
|
|
22340
|
+
stack.push(name);
|
|
22341
|
+
}
|
|
22342
|
+
if (!strayClose && !stack.length)
|
|
22343
|
+
return { start, end };
|
|
22344
|
+
if (strayClose) {
|
|
22345
|
+
const before = new RegExp(`<${strayClose}(?:\\s[^<>]*)?>$`, "i").exec(src.slice(0, start));
|
|
22346
|
+
if (!before)
|
|
22347
|
+
return null;
|
|
22348
|
+
start -= before[0].length;
|
|
22349
|
+
continue;
|
|
22350
|
+
}
|
|
22351
|
+
const open = stack[stack.length - 1];
|
|
22352
|
+
const after = new RegExp(`^<\\/${open}\\s*>`, "i").exec(src.slice(end));
|
|
22353
|
+
if (!after)
|
|
22354
|
+
return null;
|
|
22355
|
+
end += after[0].length;
|
|
22356
|
+
}
|
|
22357
|
+
return null;
|
|
22358
|
+
}
|
|
22261
22359
|
function looksEntityEscaped(find) {
|
|
22262
22360
|
return !/<[a-zA-Z/]/.test(find) && /<|>/.test(find);
|
|
22263
22361
|
}
|
|
@@ -23136,8 +23234,10 @@ var TIMELESS_KINDS = new Set(["thread", "user", "information", "domain"]);
|
|
|
23136
23234
|
var DATED_PROSE_LIMIT = 4;
|
|
23137
23235
|
var MONTHS = "Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|June?|July?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?";
|
|
23138
23236
|
var DATED_REF = new RegExp(`\\b20\\d\\d-\\d\\d-\\d\\d\\b|\\b\\d{1,2} (?:${MONTHS}) 20\\d\\d\\b|\\b(?:${MONTHS}) \\d{1,2},? 20\\d\\d\\b`, "g");
|
|
23139
|
-
function datedReferences(html) {
|
|
23140
|
-
|
|
23237
|
+
function datedReferences(html, opts = {}) {
|
|
23238
|
+
const source = opts.register ? html.replace(/<table[\s\S]*?<\/table>/gi, " ") : html;
|
|
23239
|
+
const spaced = source.replace(/<\/?t[dh](?=[\s>])[^>]*>/gi, " ");
|
|
23240
|
+
return (toText(spaced, Number.MAX_SAFE_INTEGER).match(DATED_REF) ?? []).length;
|
|
23141
23241
|
}
|
|
23142
23242
|
var STRUCTURE_LINT_LIMIT = 40;
|
|
23143
23243
|
var SECTION_EDIT_RISK_COUNT = 25;
|
|
@@ -23402,9 +23502,10 @@ async function sweep(trilium, cfg, opts = {}) {
|
|
|
23402
23502
|
const kindHere = ownedLabel(n, "noteType");
|
|
23403
23503
|
const isCurrentState = kindHere === "information" && /^current state$/i.test(n.title.trim());
|
|
23404
23504
|
if (kindHere && TIMELESS_KINDS.has(kindHere) && !isCurrentState) {
|
|
23405
|
-
const
|
|
23505
|
+
const register = n.attributes.some((a) => a.noteId === n.noteId && a.type === "label" && a.name === "register");
|
|
23506
|
+
const dated = datedReferences(content, { register });
|
|
23406
23507
|
if (dated >= DATED_PROSE_LIMIT) {
|
|
23407
|
-
report.flagged.push(`dated prose: ${n.title} [${n.noteId}] \u2014 ${dated} dated references in a timeless ${kindHere} note. ` + `Keep what is true regardless of date; move state to the domain's Current State and history or decisions to a thread entry.`);
|
|
23508
|
+
report.flagged.push(`dated prose: ${n.title} [${n.noteId}] \u2014 ${dated} dated references in a timeless ${kindHere} note${register ? " (outside its register tables)" : ""}. ` + `Keep what is true regardless of date; move state to the domain's Current State and history or decisions to a thread entry.` + (register ? "" : ` If the dates sit in a register's rows as evidence (a backlog, a defects list), label(noteId, "register", value="") exempts its tables.`));
|
|
23408
23509
|
}
|
|
23409
23510
|
}
|
|
23410
23511
|
const lintKind = ownedLabel(n, "noteType");
|
|
@@ -23942,8 +24043,10 @@ function blockDiff(before, after, pad = 1, cap = 40) {
|
|
|
23942
24043
|
const midA = a.slice(head, a.length - tail);
|
|
23943
24044
|
const midB = b.slice(head, b.length - tail);
|
|
23944
24045
|
const lcs = changedLines(midA, midB);
|
|
23945
|
-
const
|
|
23946
|
-
const
|
|
24046
|
+
const meaningful = (arr) => arr.filter((l) => !/^<\/[a-zA-Z0-9]+>$/.test(l.trim()));
|
|
24047
|
+
const removed = meaningful(lcs ? lcs.removed : midA);
|
|
24048
|
+
const added = meaningful(lcs ? lcs.added : midB);
|
|
24049
|
+
const tables = tableDiff(before, after);
|
|
23947
24050
|
const bound = (arr) => arr.length > cap ? [...arr.slice(0, cap), `\u2026 ${arr.length - cap} more line(s)`] : arr;
|
|
23948
24051
|
const delta = after.length - before.length;
|
|
23949
24052
|
return {
|
|
@@ -23954,6 +24057,7 @@ function blockDiff(before, after, pad = 1, cap = 40) {
|
|
|
23954
24057
|
removed: bound(removed),
|
|
23955
24058
|
added: bound(added),
|
|
23956
24059
|
contextAfter: bound(a.slice(a.length - tail, a.length - tail + pad)),
|
|
24060
|
+
...tables ? { tables } : {},
|
|
23957
24061
|
summary: `${lcs ? `${lcs.hunks} change(s): ` : ""}${removed.length} block(s) removed, ${added.length} added, ${delta >= 0 ? "+" : ""}${delta} characters.`
|
|
23958
24062
|
};
|
|
23959
24063
|
}
|
|
@@ -23991,6 +24095,57 @@ function changedLines(a, b, budget = 4000000) {
|
|
|
23991
24095
|
}
|
|
23992
24096
|
return { removed, added, hunks };
|
|
23993
24097
|
}
|
|
24098
|
+
function tableRows2(html) {
|
|
24099
|
+
const rows = [];
|
|
24100
|
+
for (const tr of html.matchAll(/<tr(?=[\s>])[^>]*>([\s\S]*?)<\/tr>/gi)) {
|
|
24101
|
+
const cells = [...tr[1].matchAll(/<t[dh](?=[\s>])[^>]*>([\s\S]*?)<\/t[dh]>/gi)].map((c) => c[1].replace(/<[^>]+>/g, "").replace(/ /g, " ").replace(/&/g, "&").replace(/\s+/g, " ").trim());
|
|
24102
|
+
rows.push(cells);
|
|
24103
|
+
}
|
|
24104
|
+
return rows;
|
|
24105
|
+
}
|
|
24106
|
+
function tableDiff(before, after, cap = 30) {
|
|
24107
|
+
const key = (r) => r.join("\x00");
|
|
24108
|
+
const a = tableRows2(before);
|
|
24109
|
+
const b = tableRows2(after);
|
|
24110
|
+
const inB = new Map;
|
|
24111
|
+
for (const r of b)
|
|
24112
|
+
inB.set(key(r), (inB.get(key(r)) ?? 0) + 1);
|
|
24113
|
+
const inA = new Map;
|
|
24114
|
+
for (const r of a)
|
|
24115
|
+
inA.set(key(r), (inA.get(key(r)) ?? 0) + 1);
|
|
24116
|
+
const onlyIn = (rows, other) => {
|
|
24117
|
+
const left = new Map(other);
|
|
24118
|
+
return rows.filter((r) => {
|
|
24119
|
+
const n = left.get(key(r)) ?? 0;
|
|
24120
|
+
if (n > 0) {
|
|
24121
|
+
left.set(key(r), n - 1);
|
|
24122
|
+
return false;
|
|
24123
|
+
}
|
|
24124
|
+
return true;
|
|
24125
|
+
});
|
|
24126
|
+
};
|
|
24127
|
+
const gone = onlyIn(a, inB);
|
|
24128
|
+
const fresh = onlyIn(b, inA);
|
|
24129
|
+
if (!gone.length && !fresh.length)
|
|
24130
|
+
return null;
|
|
24131
|
+
const changedCells = [];
|
|
24132
|
+
const rowsRemoved = [];
|
|
24133
|
+
const unpaired = [...fresh];
|
|
24134
|
+
for (const r of gone) {
|
|
24135
|
+
const i = unpaired.findIndex((x) => x[0] === r[0] && x.length === r.length);
|
|
24136
|
+
if (i < 0) {
|
|
24137
|
+
rowsRemoved.push(r.join(" | "));
|
|
24138
|
+
continue;
|
|
24139
|
+
}
|
|
24140
|
+
const [pair] = unpaired.splice(i, 1);
|
|
24141
|
+
r.forEach((cell, col) => {
|
|
24142
|
+
if (cell !== pair[col])
|
|
24143
|
+
changedCells.push({ row: r[0].slice(0, 80), column: col + 1, before: cell, after: pair[col] });
|
|
24144
|
+
});
|
|
24145
|
+
}
|
|
24146
|
+
const clip = (arr) => arr.slice(0, cap);
|
|
24147
|
+
return { changedCells: clip(changedCells), rowsAdded: clip(unpaired.map((r) => r.join(" | "))), rowsRemoved: clip(rowsRemoved) };
|
|
24148
|
+
}
|
|
23994
24149
|
|
|
23995
24150
|
// src/elements.ts
|
|
23996
24151
|
var WITHIN_TAGS = ["tr", "td", "th", "li", "p", "blockquote", "figure", "table", "ul", "ol"];
|
|
@@ -24082,6 +24237,9 @@ async function skim(trilium, ancestorNoteId, opts = {}) {
|
|
|
24082
24237
|
};
|
|
24083
24238
|
}));
|
|
24084
24239
|
}
|
|
24240
|
+
var blockParam = {
|
|
24241
|
+
block: stringType().optional().describe('Read one addendum block of a record instead of the whole note: its marker time ("14:05") or 1-based position ("-1" = newest)')
|
|
24242
|
+
};
|
|
24085
24243
|
async function readFull(trilium, id, opts = {}) {
|
|
24086
24244
|
const note = await trilium.getNote(id);
|
|
24087
24245
|
const contentResult = await trilium.getNoteContentResult(id, note.type).catch(() => "");
|
|
@@ -24094,6 +24252,27 @@ async function readFull(trilium, id, opts = {}) {
|
|
|
24094
24252
|
return { ...base, content: contentResult.content, contentEncoding: "base64", mime: contentResult.mime };
|
|
24095
24253
|
}
|
|
24096
24254
|
const content = contentResult;
|
|
24255
|
+
if (opts.block) {
|
|
24256
|
+
const found = addendumBlock(content, opts.block);
|
|
24257
|
+
if (!found.matched) {
|
|
24258
|
+
return {
|
|
24259
|
+
...base,
|
|
24260
|
+
content: "",
|
|
24261
|
+
block: opts.block,
|
|
24262
|
+
matched: false,
|
|
24263
|
+
available: found.available,
|
|
24264
|
+
hint: found.available.length ? `No block "${opts.block}". available= lists this record's block markers, oldest first.` : "This note has no addendum blocks; read it without block=."
|
|
24265
|
+
};
|
|
24266
|
+
}
|
|
24267
|
+
return {
|
|
24268
|
+
...base,
|
|
24269
|
+
content: found.content,
|
|
24270
|
+
block: found.marker,
|
|
24271
|
+
matched: true,
|
|
24272
|
+
...found.identity ? { identity: found.identity } : {},
|
|
24273
|
+
position: `${found.position} of ${found.of}`
|
|
24274
|
+
};
|
|
24275
|
+
}
|
|
24097
24276
|
if (!opts.section) {
|
|
24098
24277
|
return {
|
|
24099
24278
|
...base,
|
|
@@ -24165,18 +24344,19 @@ function registerMasterTools(server, trilium, brainRef) {
|
|
|
24165
24344
|
// src/tools-llm.ts
|
|
24166
24345
|
function registerLlmTools(server, trilium, brainRef) {
|
|
24167
24346
|
const b = () => brainRef.config;
|
|
24168
|
-
server.tool("llm", `Read an LLM singleton \u2014 responsibilities, protocols or selfcorrection \u2014 or a diary entry by id. Self-correction holds general rules learned from mistakes; read it before work that has gone wrong before (audits, large edits, completeness claims). section="<heading>" reads one section.`, {
|
|
24347
|
+
server.tool("llm", `Read an LLM singleton \u2014 responsibilities, protocols or selfcorrection \u2014 or a diary entry by id (block="14:05" or "-1" reads one of its addendum blocks). Self-correction holds general rules learned from mistakes; read it before work that has gone wrong before (audits, large edits, completeness claims). section="<heading>" reads one section.`, {
|
|
24169
24348
|
which: enumType(["responsibilities", "protocols", "selfcorrection", "diary"]),
|
|
24170
24349
|
id: stringType().optional().describe("diary only: the entry id from llm_recall (noteId is accepted as an alias)"),
|
|
24171
24350
|
noteId: stringType().optional().describe("Alias of id"),
|
|
24172
24351
|
section: stringType().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole note"),
|
|
24173
|
-
occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)")
|
|
24174
|
-
|
|
24352
|
+
occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)"),
|
|
24353
|
+
...blockParam
|
|
24354
|
+
}, async ({ which, id, noteId: idAlias, section, occurrence, block }) => {
|
|
24175
24355
|
if (which === "diary") {
|
|
24176
24356
|
const entry = id ?? idAlias;
|
|
24177
24357
|
if (!entry)
|
|
24178
24358
|
throw new Error("Reading a diary entry needs its id \u2014 use llm_recall to find one.");
|
|
24179
|
-
return txt(await readFull(trilium, entry, { section, occurrence }));
|
|
24359
|
+
return txt(await readFull(trilium, entry, { section, occurrence, block }));
|
|
24180
24360
|
}
|
|
24181
24361
|
const noteId = b().llm[which];
|
|
24182
24362
|
if (!noteId) {
|
|
@@ -24212,20 +24392,22 @@ index passes ~24k characters, unless limit= says otherwise; with the total count
|
|
|
24212
24392
|
date="yyyy-mm-dd" resolves straight to one day. A collection thread returns its
|
|
24213
24393
|
Context plus its titled entries (alphabetical, with a lead). Read a child with memory(<child id>).
|
|
24214
24394
|
section="<heading>" reads one section. index=true on a dated thread returns only child ids,
|
|
24215
|
-
dates and block markers (no book body, no leads): the cheap answer to "what is the newest entry"
|
|
24395
|
+
dates and block markers (no book body, no leads): the cheap answer to "what is the newest entry".
|
|
24396
|
+
block="14:05" (or "-1" for the newest) on a session or day entry returns that one addendum block.`, {
|
|
24216
24397
|
...idParams,
|
|
24217
24398
|
date: stringType().optional().describe("Thread books only: resolve directly to this day's child note (yyyy-mm-dd)"),
|
|
24218
24399
|
limit: numberType().int().positive().optional().describe("Dated threads: how many of the newest day-children to index (default 30)"),
|
|
24219
24400
|
index: booleanType().optional().describe("Dated threads: ids, dates and block markers only \u2014 no book body, no block leads"),
|
|
24220
24401
|
section: stringType().optional().describe("Read only this heading's section (h2/h3/h4), instead of the whole note"),
|
|
24221
|
-
occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)")
|
|
24222
|
-
|
|
24402
|
+
occurrence: numberType().int().positive().optional().describe("section=: which same-text heading, 1-based (default: the first)"),
|
|
24403
|
+
...blockParam
|
|
24404
|
+
}, async ({ id: idArg, noteId, date, limit, index, section, occurrence, block }) => {
|
|
24223
24405
|
const id = idArg ?? noteId;
|
|
24224
24406
|
if (!id)
|
|
24225
24407
|
return missingId("memory");
|
|
24226
24408
|
const note = await trilium.getNote(id).catch(() => null);
|
|
24227
24409
|
if (!note || labelOf(note, "noteType") !== "thread")
|
|
24228
|
-
return txt(await readFull(trilium, id, { section, occurrence }));
|
|
24410
|
+
return txt(await readFull(trilium, id, { section, occurrence, block }));
|
|
24229
24411
|
if (isCollectionThread(note)) {
|
|
24230
24412
|
const kids = await trilium.searchNotes("#noteType=threadEntry", { ancestorNoteId: id, fastSearch: true, limit: 500 }).catch(() => ({ results: [] }));
|
|
24231
24413
|
const entries = await Promise.all(kids.results.filter((c) => c.parentNoteIds.includes(id)).sort((a, z) => a.title.localeCompare(z.title)).map(async (c) => ({
|
|
@@ -24240,7 +24422,7 @@ dates and block markers (no book body, no leads): the cheap answer to "what is t
|
|
|
24240
24422
|
if (date) {
|
|
24241
24423
|
const child = await trilium.searchNotes(`#noteType=threadEntry #created='${date}'`, { ancestorNoteId: id, fastSearch: true, limit: 1 }).catch(() => ({ results: [] }));
|
|
24242
24424
|
if (child.results[0])
|
|
24243
|
-
return txt(await readFull(trilium, child.results[0].noteId, { section, occurrence }));
|
|
24425
|
+
return txt(await readFull(trilium, child.results[0].noteId, { section, occurrence, block }));
|
|
24244
24426
|
return txt({ id, title: note.title, kind: "thread", note: `No entry for ${date}.` });
|
|
24245
24427
|
}
|
|
24246
24428
|
const shown = limit ?? MEMORY_INDEX_DEFAULT;
|
|
@@ -24372,6 +24554,35 @@ var txt3 = (obj) => ({
|
|
|
24372
24554
|
content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }]
|
|
24373
24555
|
});
|
|
24374
24556
|
var today = () => localToday();
|
|
24557
|
+
function applyFindEdit(source, needle, replacement, index) {
|
|
24558
|
+
const spans = (hits, matchMode) => {
|
|
24559
|
+
if (index === undefined) {
|
|
24560
|
+
let out = "";
|
|
24561
|
+
let cursor = 0;
|
|
24562
|
+
for (const h of hits) {
|
|
24563
|
+
out += source.slice(cursor, h.start) + replacement;
|
|
24564
|
+
cursor = h.start + h.length;
|
|
24565
|
+
}
|
|
24566
|
+
return { html: out + source.slice(cursor), count: hits.length, matchMode };
|
|
24567
|
+
}
|
|
24568
|
+
const hit = hits[index - 1];
|
|
24569
|
+
if (!hit)
|
|
24570
|
+
return null;
|
|
24571
|
+
return { html: source.slice(0, hit.start) + replacement + source.slice(hit.start + hit.length), count: 1, matchMode };
|
|
24572
|
+
};
|
|
24573
|
+
const exact = [];
|
|
24574
|
+
for (let at = source.indexOf(needle);at !== -1; at = source.indexOf(needle, at + needle.length)) {
|
|
24575
|
+
exact.push({ start: at, length: needle.length });
|
|
24576
|
+
}
|
|
24577
|
+
if (exact.length)
|
|
24578
|
+
return spans(exact, "exact");
|
|
24579
|
+
const rx = tolerantFindRegex(needle);
|
|
24580
|
+
const tolerant = rx ? [...source.matchAll(rx)].map((m) => ({ start: m.index, length: m[0].length })) : [];
|
|
24581
|
+
if (tolerant.length)
|
|
24582
|
+
return spans(tolerant, "attribute-tolerant");
|
|
24583
|
+
const inline = inlineTolerantSpans(source, needle);
|
|
24584
|
+
return inline.length ? spans(inline, "inline-tolerant") : null;
|
|
24585
|
+
}
|
|
24375
24586
|
var err = (code, detail, hint) => txt3({ error: code, detail, ...hint ? { hint } : {} });
|
|
24376
24587
|
var labelOf2 = (n, name) => n.attributes.find((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === name)?.value;
|
|
24377
24588
|
var hasLabel = (n, name) => n.attributes.some((a) => isOwnedAttribute(n, a) && a.type === "label" && a.name === name);
|
|
@@ -24540,7 +24751,7 @@ ${block}` }));
|
|
|
24540
24751
|
function collectionAppendRefusal(book) {
|
|
24541
24752
|
return err("collection_thread", `"${book.title}" is a collection thread \u2014 it holds one titled entry per item, not dated appends.`, `Add an entry: remember(kind="threadEntry", thread="${book.noteId}", title, body). Change one: revise(<entry id>, section=/find=/mode=\u2026) \u2014 memory("${book.noteId}") lists them.`);
|
|
24542
24753
|
}
|
|
24543
|
-
server.tool("start", `
|
|
24754
|
+
server.tool("start", `Opens a BrainLLM session: runs the lite maintenance sweep, creates today's diary and session notes if they do not exist yet, and returns the date and weekday, the user's stored preferences and protocols in full, the other singletons as section headings with a preview (depth="full" inlines everything), today's diary and session ids, active and dormant threads with idle ages, the previous session's summary, notes changed since, and newDay on the first session of a day.`, {
|
|
24544
24755
|
depth: enumType(["digest", "full"]).optional().describe('Singleton detail: "digest" (default \u2014 section headings + preview + size) or "full" (every singleton inline; token-heavy)')
|
|
24545
24756
|
}, async ({ depth }) => {
|
|
24546
24757
|
const cfg = b();
|
|
@@ -24633,9 +24844,9 @@ ${block}` }));
|
|
|
24633
24844
|
hygiene: { scanned: hygiene.scanned, fixed: hygiene.fixed.length, transitions: hygiene.transitions, flagged: hygiene.flagged, ...hygiene.suppressed ? { suppressed: hygiene.suppressed } : {} }
|
|
24634
24845
|
});
|
|
24635
24846
|
});
|
|
24636
|
-
server.tool("session", `
|
|
24847
|
+
server.tool("session", `Pre-close review. Returns the six singletons as {id, lastModified} stubs (full=true inlines them), today's diary as {id, blocks, size}, the maintained notes changed today with their write counts, runs the lite maintenance sweep, and returns pending= (what each remaining close step has to do), audit= (do the singletons agree, and do the LLM singletons still serve the master ones) and next[]. Records the session step of the close gate. Idempotent.
|
|
24637
24848
|
|
|
24638
|
-
|
|
24849
|
+
The close sequence next[] lays out: singleton updates from the session, then addendum(), maintain(), remarks(), diary() and close(). close() checks that addendum, maintain, remarks and diary ran, in the order session \u2192 remarks \u2192 diary. scope="agent" omits the singleton updates for scoped or autonomous runs.`, {
|
|
24639
24850
|
date: stringType().optional().describe("ISO date YYYY-MM-DD (default: today)"),
|
|
24640
24851
|
full: booleanType().optional().describe("Inline every singleton's content and the diary body (default: stubs only)"),
|
|
24641
24852
|
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")`),
|
|
@@ -24693,6 +24904,12 @@ Protocol: 1. update master singletons with what the session taught about the use
|
|
|
24693
24904
|
selfcorrection
|
|
24694
24905
|
};
|
|
24695
24906
|
const touchedToday = Object.keys(singletonStubs).filter((k) => singletonStubs[k]?.lastModified === d);
|
|
24907
|
+
const cutoff = `${d} 00:00`;
|
|
24908
|
+
const changedToday = await trilium.searchNotes(`#noteType note.dateModified >= '${cutoff}'`, { ancestorNoteId: cfg.root, limit: 100, orderBy: "dateModified", orderDirection: "desc" }).then((r) => r.results.filter((n) => !isRecordNote(n))).catch(() => null);
|
|
24909
|
+
const changedNotes = changedToday ? await Promise.all(changedToday.slice(0, 20).map(async (n) => {
|
|
24910
|
+
const revisions = await trilium.getNoteRevisions(n.noteId).catch(() => []);
|
|
24911
|
+
return { id: n.noteId, title: n.title, kind: ownedLabel(n, "noteType") ?? "", writes: revisionsSince(revisions, cutoff).length };
|
|
24912
|
+
})) : null;
|
|
24696
24913
|
const pending = {
|
|
24697
24914
|
addendums: pendingAddendums === null ? "unknown" : pendingAddendums,
|
|
24698
24915
|
maintenanceFlags: hygiene?.flagged.length ?? 0,
|
|
@@ -24710,6 +24927,12 @@ Protocol: 1. update master singletons with what the session taught about the use
|
|
|
24710
24927
|
diary: diaryEntry,
|
|
24711
24928
|
maintenance: hygiene ? { scanned: hygiene.scanned, fixed: hygiene.fixed.length, transitions: hygiene.transitions, flagged: hygiene.flagged, ...hygiene.suppressed ? { suppressed: hygiene.suppressed } : {} } : "skipped",
|
|
24712
24929
|
pending,
|
|
24930
|
+
changedToday: changedNotes ? {
|
|
24931
|
+
notes: changedToday.length,
|
|
24932
|
+
...changedToday.length > changedNotes.length ? { shown: changedNotes.length } : {},
|
|
24933
|
+
list: changedNotes,
|
|
24934
|
+
check: changedNotes.length ? 'Which measured figures did these writes change? Each must now agree everywhere it is recorded: diff(since="today") shows the changes, consistency() finds other notes still asserting the old value.' : "No maintained note changed today."
|
|
24935
|
+
} : "unavailable",
|
|
24713
24936
|
audit: scoped ? undefined : {
|
|
24714
24937
|
consistency: "Read every singleton and check for ambiguity, internal contradiction, or claims that disagree across them. Fix what you find with revise() BEFORE close(), so the log records a brain that already agrees with itself.",
|
|
24715
24938
|
alignment: "Then check correlation, not just agreement: do responsibilities and protocols actually serve what biography, goals and preferences describe? A protocol can be perfectly consistent and still be serving a goal that has moved.",
|
|
@@ -24724,12 +24947,12 @@ Protocol: 1. update master singletons with what the session taught about the use
|
|
|
24724
24947
|
scoped ? `Call addendum() \u2014 fold only what is in your lane; leaving out-of-scope addendums for the next interactive session is correct, and the call itself satisfies the gate. (${pending.addendums} note(s) currently carry addendum markers.)` : pending.addendums === 0 ? "Call addendum() \u2014 0 notes currently carry addendum markers, so this should come back clean; the call still satisfies the gate." : `Call addendum() \u2014 ${pending.addendums} note(s) carry addendum markers; fold each into its section.`,
|
|
24725
24948
|
scoped ? "Call maintain() \u2014 audit brain hygiene; pass domain= to keep the flags in your lane." : pending.maintenanceFlags === 0 ? "Call maintain() \u2014 the lite sweep just ran clean, so this is a formality unless you want deep=true." : `Call maintain() \u2014 the lite sweep already flagged ${pending.maintenanceFlags} item(s), listed above.`,
|
|
24726
24949
|
"Call remarks() \u2014 get the diary cues: your experience, opinions, and existence this session, plus BrainLLM remarks.",
|
|
24727
|
-
"Call diary() \u2014 write the day's
|
|
24950
|
+
"Call diary() \u2014 write the day's record with the cues in hand; the gate counts it only after remarks().",
|
|
24728
24951
|
"Call close() \u2014 commit the session log (mandatory, last). Refuses until every step ran and session \u2192 remarks \u2192 diary held; pass force=true only when a skipped step genuinely has nothing to log."
|
|
24729
24952
|
]
|
|
24730
24953
|
});
|
|
24731
24954
|
});
|
|
24732
|
-
server.tool("remarks", `
|
|
24955
|
+
server.tool("remarks", `Returns writing prompts for the diary: questions about how the session went and about BrainLLM itself (problems, usability, ideas). Records the remarks step of the close gate, which close() checks came after session() and before diary(). Writes no note content.`, {}, async () => {
|
|
24733
24956
|
const cfg = b();
|
|
24734
24957
|
if (!cfg.root)
|
|
24735
24958
|
return txt3({ status: "uninitialized", action: "Run bootstrap first." });
|
|
@@ -24754,7 +24977,7 @@ Protocol: 1. update master singletons with what the session taught about the use
|
|
|
24754
24977
|
]
|
|
24755
24978
|
});
|
|
24756
24979
|
});
|
|
24757
|
-
server.tool("close", `
|
|
24980
|
+
server.tool("close", `Writes the session log, normally the last call of a session. Refuses unless session(), addendum(), maintain(), remarks() and diary() ran and session \u2192 remarks \u2192 diary held (force=true bypasses a step with genuinely nothing to do; bypassed steps are reported). identity= is required. Writes a timestamped block to today's [yyyy-mm-dd] session note (title= becomes its heading), regenerates the daily log, backs up the database and resets the gate. continuing=true is a second close the same day.`, {
|
|
24758
24981
|
summary: stringType().describe("What happened this session \u2014 factual, concise prose"),
|
|
24759
24982
|
title: stringType().optional().describe("Short session title \u2014 appears as an <h2> heading above Summary"),
|
|
24760
24983
|
identity: stringType().describe(`Identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" \u2014 rendered as the block's h3 (required)`),
|
|
@@ -24881,13 +25104,78 @@ ${contentBlock}` }));
|
|
|
24881
25104
|
const backupName = name ?? rollingBackupName(d);
|
|
24882
25105
|
try {
|
|
24883
25106
|
await trilium.createBackup(backupName);
|
|
24884
|
-
|
|
25107
|
+
const named = name && name !== rollingBackupName(d);
|
|
25108
|
+
if (named) {
|
|
25109
|
+
const cfg = b();
|
|
25110
|
+
(cfg.backups ??= {})[backupName] = d;
|
|
25111
|
+
try {
|
|
25112
|
+
saveConfig(cfg);
|
|
25113
|
+
} catch {}
|
|
25114
|
+
}
|
|
25115
|
+
const kept = Object.keys(b().backups ?? {}).length;
|
|
25116
|
+
return txt3({
|
|
25117
|
+
ok: true,
|
|
25118
|
+
backup: backupName,
|
|
25119
|
+
backupStatus: "completed",
|
|
25120
|
+
date: d,
|
|
25121
|
+
...named ? { namedBackups: kept, note: `A named backup stays on Trilium's volume until deleted there. ${kept} named backup(s) recorded; health() reports them.` } : {}
|
|
25122
|
+
});
|
|
24885
25123
|
} catch (error) {
|
|
24886
25124
|
return txt3({ ok: false, backup: backupName, backupStatus: "failed", error: error instanceof Error ? error.message : String(error), date: d });
|
|
24887
25125
|
}
|
|
24888
25126
|
});
|
|
24889
|
-
|
|
24890
|
-
|
|
25127
|
+
const SIZE_BANDS = [
|
|
25128
|
+
{ min: 1e7, max: 50000000 },
|
|
25129
|
+
{ min: 1e6, max: 1e7 },
|
|
25130
|
+
{ min: 1e5, max: 1e6 },
|
|
25131
|
+
{ min: 1e4, max: 1e5 }
|
|
25132
|
+
];
|
|
25133
|
+
const NAMED_BACKUP_WARN = 6;
|
|
25134
|
+
server.tool("health", `The brain's storage and runtime health: Trilium's version, an estimate of the database's size (content, attachments and revisions), the heaviest notes with their revision counts, and the named backups this server has taken. Each backup is a full database copy kept on Trilium's volume, which ETAPI cannot list, so growth there is flagged here rather than discovered when the disk fills. Read-only.`, {}, async () => {
|
|
25135
|
+
const cfg = b();
|
|
25136
|
+
const info = await trilium.getAppInfo().catch(() => null);
|
|
25137
|
+
const count = (q) => trilium.searchNotes(q, { limit: 1e5, fastSearch: true, includeArchivedNotes: true }).then((r) => r.results.length).catch(() => null);
|
|
25138
|
+
const bandCounts = await Promise.all(SIZE_BANDS.map((band) => count(`note.contentAndAttachmentsAndRevisionsSize >= ${band.min} AND note.contentAndAttachmentsAndRevisionsSize < ${band.max}`)));
|
|
25139
|
+
const typed = await count("#noteType");
|
|
25140
|
+
let estimate = 0;
|
|
25141
|
+
SIZE_BANDS.forEach((band, i) => {
|
|
25142
|
+
estimate += (bandCounts[i] ?? 0) * Math.sqrt(band.min * band.max);
|
|
25143
|
+
});
|
|
25144
|
+
const heavy = await trilium.searchNotes("note.contentAndAttachmentsAndRevisionsSize >= 1000000", {
|
|
25145
|
+
limit: 5,
|
|
25146
|
+
orderBy: "contentAndAttachmentsAndRevisionsSize",
|
|
25147
|
+
orderDirection: "desc",
|
|
25148
|
+
includeArchivedNotes: true
|
|
25149
|
+
}).then((r) => r.results).catch(() => []);
|
|
25150
|
+
const heaviest = await Promise.all(heavy.map(async (n) => {
|
|
25151
|
+
const revisions = await trilium.getNoteRevisions(n.noteId).catch(() => []);
|
|
25152
|
+
const revisionChars = revisions.reduce((sum, r) => sum + (r.contentLength ?? 0), 0);
|
|
25153
|
+
return { id: n.noteId, title: n.title, revisions: revisions.length, revisionChars };
|
|
25154
|
+
}));
|
|
25155
|
+
const named = Object.entries(cfg.backups ?? {}).sort((x, y) => x[1].localeCompare(y[1]));
|
|
25156
|
+
const mb = (n) => `${(n / 1e6).toFixed(1)} MB`;
|
|
25157
|
+
const flags = [];
|
|
25158
|
+
if (named.length >= NAMED_BACKUP_WARN)
|
|
25159
|
+
flags.push(`${named.length} named backups recorded, each a full database copy (~${mb(estimate)}) kept until deleted on Trilium's volume. Delete the ones no longer needed there.`);
|
|
25160
|
+
for (const h of heaviest) {
|
|
25161
|
+
if (h.revisions >= 200)
|
|
25162
|
+
flags.push(`${h.title} [${h.id}] carries ${h.revisions} revisions (${mb(h.revisionChars)}). Revision history is most of its weight; Trilium's revision-snapshot settings or a split bound it.`);
|
|
25163
|
+
}
|
|
25164
|
+
return txt3({
|
|
25165
|
+
trilium: info ? { appVersion: info.appVersion, dbVersion: info.dbVersion, buildDate: info.buildDate } : { error: "app-info unreachable" },
|
|
25166
|
+
notes: { typed: typed ?? undefined, bySize: SIZE_BANDS.map((band, i) => ({ atLeast: mb(band.min), below: mb(band.max), notes: bandCounts[i] })) },
|
|
25167
|
+
estimatedSize: `${mb(estimate)} (content, attachments and revisions of notes over 10 kB; an estimate from size bands, not a measurement)`,
|
|
25168
|
+
heaviest,
|
|
25169
|
+
backups: {
|
|
25170
|
+
rollingSlots: "7 (brainllm-mon \u2026 brainllm-sun), overwritten weekly by close()",
|
|
25171
|
+
named: named.map(([name, date]) => ({ name, date })),
|
|
25172
|
+
note: "Only backups taken through this server are listed. Each is roughly the size of the database."
|
|
25173
|
+
},
|
|
25174
|
+
...flags.length ? { flags } : { flags: [], status: "No growth findings." }
|
|
25175
|
+
});
|
|
25176
|
+
});
|
|
25177
|
+
server.tool("diary", `Appends a timestamped block to today's diary note: the assistant's own first-person account of the session, followed by notes on BrainLLM. identity= is required. The close gate counts the diary only when its latest block was written after session() and remarks().`, {
|
|
25178
|
+
body: stringType().describe("The diary text, in the first person"),
|
|
24891
25179
|
identity: stringType().describe(`Identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" \u2014 rendered as the block's h3 (required)`),
|
|
24892
25180
|
icon: stringType().optional().describe("Display icon for the day's entry \u2014 a boxicons class or bare name; normalized server-side"),
|
|
24893
25181
|
date: stringType().optional().describe("ISO date YYYY-MM-DD (default: today)")
|
|
@@ -25508,19 +25796,23 @@ ${block}`), d);
|
|
|
25508
25796
|
...total === 0 && !knowledgeDomain ? { note: `No content found for "${slug}". Create a Knowledge domain with remember(kind="information", domain="${name}") or tag notes with topics=["${slug}"].` } : {}
|
|
25509
25797
|
});
|
|
25510
25798
|
});
|
|
25511
|
-
|
|
25512
|
-
|
|
25513
|
-
|
|
25514
|
-
|
|
25799
|
+
const READ_BUDGET = 80000;
|
|
25800
|
+
server.tool("read", `Several note bodies in one round trip: ids=[\u2026] (up to 10) \u2192 {notes:[{id, title, kind, content, relations}]}. Bodies are returned in order until about 80k characters; the rest come back under deferred[] with their sizes, to read in a second call. text=true returns readable plain text with the markup removed (headings, list items and table rows kept), smaller and easier to read \u2014 right for orienting, not for building a find= anchor. For one note prefer the kinded read; for a huge note prefer a section= read.`, {
|
|
25801
|
+
ids: arrayType(stringType()).min(1).max(10).describe("Note ids to read \u2014 one body per id, one round trip (cap 10)"),
|
|
25802
|
+
text: booleanType().optional().describe("Return readable plain text instead of stored HTML (default false)")
|
|
25803
|
+
}, async ({ ids, text }) => {
|
|
25804
|
+
const fetched = await Promise.all(ids.map(async (id) => {
|
|
25515
25805
|
try {
|
|
25516
25806
|
const note = await trilium.getNote(id);
|
|
25517
25807
|
const contentResult = await trilium.getNoteContentResult(id, note.type);
|
|
25518
25808
|
const relations = relationSnippet(note);
|
|
25809
|
+
const raw = typeof contentResult === "string" ? contentResult : contentResult.content;
|
|
25810
|
+
const content = text && typeof contentResult === "string" ? readableText(raw) : raw;
|
|
25519
25811
|
return {
|
|
25520
25812
|
id,
|
|
25521
25813
|
title: note.title,
|
|
25522
25814
|
kind: ownedLabel(note, "noteType") ?? undefined,
|
|
25523
|
-
content
|
|
25815
|
+
content,
|
|
25524
25816
|
...typeof contentResult === "string" ? {} : { contentEncoding: contentResult.encoding, mime: contentResult.mime },
|
|
25525
25817
|
...relations ? { relations } : {}
|
|
25526
25818
|
};
|
|
@@ -25528,14 +25820,102 @@ ${block}`), d);
|
|
|
25528
25820
|
return { id, missing: true };
|
|
25529
25821
|
}
|
|
25530
25822
|
}));
|
|
25531
|
-
|
|
25823
|
+
const notes = [];
|
|
25824
|
+
const deferred = [];
|
|
25825
|
+
let used = 0;
|
|
25826
|
+
for (const n of fetched) {
|
|
25827
|
+
const size = "content" in n ? n.content.length : 0;
|
|
25828
|
+
if ("content" in n && notes.length && used + size > READ_BUDGET) {
|
|
25829
|
+
deferred.push({ id: n.id, title: n.title, size });
|
|
25830
|
+
continue;
|
|
25831
|
+
}
|
|
25832
|
+
notes.push(n);
|
|
25833
|
+
used += size;
|
|
25834
|
+
}
|
|
25835
|
+
return txt3({
|
|
25836
|
+
count: notes.length,
|
|
25837
|
+
notes,
|
|
25838
|
+
...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"}.` } : {}
|
|
25839
|
+
});
|
|
25532
25840
|
});
|
|
25841
|
+
const reviseDomain = async (name, plan, dry, d) => {
|
|
25842
|
+
if (plan.some((e) => !e.find))
|
|
25843
|
+
return err("missing_param", "Every find must be non-empty.");
|
|
25844
|
+
const cfg = b();
|
|
25845
|
+
const slug = slugify(name);
|
|
25846
|
+
const CAP = 300;
|
|
25847
|
+
const search = (q) => trilium.searchNotes(q, { ancestorNoteId: cfg.root, limit: CAP, fastSearch: true }).then((r) => r.results).catch(() => []);
|
|
25848
|
+
const [byDomain, byTopic] = await Promise.all([search(`#domain='${slug}'`), search(`#topic='${slug}'`)]);
|
|
25849
|
+
const RECORDS = new Set(["session", "diary", "log", "threadEntry", "domain"]);
|
|
25850
|
+
const seen = new Set;
|
|
25851
|
+
const targets = [...byDomain, ...byTopic].filter((n) => {
|
|
25852
|
+
if (seen.has(n.noteId))
|
|
25853
|
+
return false;
|
|
25854
|
+
seen.add(n.noteId);
|
|
25855
|
+
const kind = ownedLabel(n, "noteType");
|
|
25856
|
+
return !!kind && !RECORDS.has(kind) && !isContainer(cfg, n.noteId) && !hasLabel(n, "archived");
|
|
25857
|
+
});
|
|
25858
|
+
if (!targets.length)
|
|
25859
|
+
return txt3({ ok: true, domain: name, slug, scanned: 0, note: `No maintained notes carry #domain or #topic "${slug}".` });
|
|
25860
|
+
const changes = [];
|
|
25861
|
+
let total = 0;
|
|
25862
|
+
for (const n of targets) {
|
|
25863
|
+
let content;
|
|
25864
|
+
try {
|
|
25865
|
+
content = await trilium.getNoteContent(n.noteId);
|
|
25866
|
+
} catch {
|
|
25867
|
+
continue;
|
|
25868
|
+
}
|
|
25869
|
+
let working = content;
|
|
25870
|
+
let count = 0;
|
|
25871
|
+
const previews = [];
|
|
25872
|
+
for (const e of plan) {
|
|
25873
|
+
const applied = applyFindEdit(working, e.find, e.body, e.nth);
|
|
25874
|
+
if (!applied)
|
|
25875
|
+
continue;
|
|
25876
|
+
if (previews.length < 3) {
|
|
25877
|
+
const plain = toText(working, Number.MAX_SAFE_INTEGER);
|
|
25878
|
+
const at = plain.indexOf(toText(e.find, Number.MAX_SAFE_INTEGER).slice(0, 40));
|
|
25879
|
+
previews.push(at >= 0 ? `\u2026${plain.slice(Math.max(0, at - 50), at + 90)}\u2026` : toText(e.find, 120));
|
|
25880
|
+
}
|
|
25881
|
+
working = applied.html;
|
|
25882
|
+
count += applied.count;
|
|
25883
|
+
}
|
|
25884
|
+
if (!count || working === content)
|
|
25885
|
+
continue;
|
|
25886
|
+
total += count;
|
|
25887
|
+
const row = { id: n.noteId, title: n.title, replaced: count, previews };
|
|
25888
|
+
if (!dry) {
|
|
25889
|
+
await trilium.createRevision(n.noteId).catch(() => null);
|
|
25890
|
+
const clean = sanitizeHtml(working);
|
|
25891
|
+
await trilium.updateNoteContent(n.noteId, bumpLastUpdated(clean.html, d).html);
|
|
25892
|
+
await trilium.updateLabelValue(n.noteId, "updated", d);
|
|
25893
|
+
row.written = true;
|
|
25894
|
+
}
|
|
25895
|
+
changes.push(row);
|
|
25896
|
+
}
|
|
25897
|
+
return txt3({
|
|
25898
|
+
ok: true,
|
|
25899
|
+
domain: name,
|
|
25900
|
+
slug,
|
|
25901
|
+
dryRun: dry,
|
|
25902
|
+
scanned: targets.length,
|
|
25903
|
+
notesMatched: changes.length,
|
|
25904
|
+
replaced: total,
|
|
25905
|
+
date: d,
|
|
25906
|
+
...targets.length >= CAP ? { truncated: `A query hit its ${CAP}-note cap; some notes in the domain were not scanned.` } : {},
|
|
25907
|
+
changes,
|
|
25908
|
+
next: dry ? changes.length ? "Nothing was written. Re-run with dryRun=false to apply exactly these changes." : "No note matched; nothing would change." : "Written, with a revision taken first on each note. Run consistency() on the old and new wording to confirm nothing outside the domain still asserts the old form."
|
|
25909
|
+
});
|
|
25910
|
+
};
|
|
25533
25911
|
server.tool("revise", `Edit a note by id. A revision is taken first; "Last updated" lines are bumped on content writes.
|
|
25534
25912
|
|
|
25535
25913
|
Modes: default append (a dated addendum \u2014 right only for records; a dated thread's append lands in today's child), mode=replace (whole body), section="<heading>" (replace that section's whole body, h2\u2192h3\u2192h4, occurrence= for repeats), section= + mode=before|after (insert a sibling block around the whole section), mode=prepend (top of the section body), mode=remove (delete the section). find="<exact stored text>" replaces every occurrence (nth= for one); edits=[{find, body}] applies several in one write. find= + within="tr" (or li, p, td, \u2026) acts on the element containing a short anchor: replace it, insert a row before/after it, or remove it, so a table row never needs its full stored markup. A replacement equal to its match writes nothing and reports unchanged. title= composes with every mode; retitling a domain book cascades its #domain slug.
|
|
25536
25914
|
|
|
25537
25915
|
Check the receipt: matched=false means a NEW section was written (available= lists real headings; strict=true refuses instead). A section replace swaps everything under the heading \u2014 use find= for anything smaller. find= matches stored HTML (tags literal), not rendered text, and is taken literally: a Windows path's backslash is one backslash, never doubled.`, {
|
|
25538
|
-
noteId: stringType().describe("Note to update"),
|
|
25916
|
+
noteId: stringType().optional().describe("Note to update (omit only with domain=)"),
|
|
25917
|
+
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"),
|
|
25918
|
+
dryRun: booleanType().optional().describe("domain=: preview the matches without writing (default true). Re-run with dryRun=false to apply"),
|
|
25539
25919
|
body: stringType().optional().describe("Content to add/replace: plain text, markdown, or HTML. Send real tags: entity-escaped markup (<p>) is decoded and reported in sanitized[]. With find=, the raw replacement string (no conversion)."),
|
|
25540
25920
|
title: stringType().optional().describe("New title (normalized server-side)"),
|
|
25541
25921
|
section: stringType().optional().describe("Target a section by heading text (h2/h3/h4, in that order); omit for whole-note append/replace"),
|
|
@@ -25553,7 +25933,21 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
25553
25933
|
identity: stringType().optional().describe('Appending to a dated thread: the identification line "LLM \xB7 environment \xB7 agent/mode [\xB7 Run N]" (required)'),
|
|
25554
25934
|
icon: stringType().optional().describe('Display icon \u2014 a boxicons class ("bx bx-brain") or a bare name; normalized server-side'),
|
|
25555
25935
|
date: stringType().optional().describe("ISO date (default: today)")
|
|
25556
|
-
}, async ({ noteId, body, title, section, occurrence, mode, find, nth, within, edits, identity, icon, date, strict }) => {
|
|
25936
|
+
}, async ({ noteId: noteIdArg, domain, dryRun, body, title, section, occurrence, mode, find, nth, within, edits, identity, icon, date, strict }) => {
|
|
25937
|
+
if (domain !== undefined) {
|
|
25938
|
+
if (noteIdArg)
|
|
25939
|
+
return err("conflicting_params", "Pass noteId= or domain=, not both.", "domain= edits every note in the domain; noteId= edits one.");
|
|
25940
|
+
if (find === undefined && edits === undefined)
|
|
25941
|
+
return err("missing_param", "domain= needs find= (with body=) or edits=.", 'e.g. revise(domain="myClerkBook", find="Code Priority Queue", body="Backlog")');
|
|
25942
|
+
if (within || section || title || mode || identity || icon)
|
|
25943
|
+
return err("conflicting_params", "domain= takes only find=/body=, edits=, nth= and dryRun=.", "Use noteId= for element, section, title or icon edits.");
|
|
25944
|
+
return reviseDomain(domain, edits ?? [{ find, body: body ?? "", nth }], dryRun ?? true, checkedDate(date));
|
|
25945
|
+
}
|
|
25946
|
+
if (dryRun !== undefined)
|
|
25947
|
+
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.");
|
|
25948
|
+
if (!noteIdArg)
|
|
25949
|
+
return err("missing_param", "revise needs noteId= (or domain= for a domain-wide find/replace).", "Pass the id of the note to edit.");
|
|
25950
|
+
const noteId = noteIdArg;
|
|
25557
25951
|
if (isContainer(b(), noteId))
|
|
25558
25952
|
return err("protected_note", `Note ${noteId} is a container \u2014 its content cannot be edited directly.`, "Use remember() to write to singletons, or specify a content note id.");
|
|
25559
25953
|
if (within && find === undefined)
|
|
@@ -25600,38 +25994,6 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
25600
25994
|
if (e.body === undefined && !within)
|
|
25601
25995
|
return err("missing_param", `edits[${i}] requires body as the replacement string.`, 'Call revise(noteId, find="<exact text>", body="<replacement>").');
|
|
25602
25996
|
}
|
|
25603
|
-
const applyEdit = (source, needle, replacement, index) => {
|
|
25604
|
-
const spans = (hits, matchMode) => {
|
|
25605
|
-
if (index === undefined) {
|
|
25606
|
-
let out = "";
|
|
25607
|
-
let cursor = 0;
|
|
25608
|
-
for (const h of hits) {
|
|
25609
|
-
out += source.slice(cursor, h.start) + replacement;
|
|
25610
|
-
cursor = h.start + h.length;
|
|
25611
|
-
}
|
|
25612
|
-
return { html: out + source.slice(cursor), count: hits.length, matchMode };
|
|
25613
|
-
}
|
|
25614
|
-
const hit = hits[index - 1];
|
|
25615
|
-
if (!hit)
|
|
25616
|
-
return null;
|
|
25617
|
-
return {
|
|
25618
|
-
html: source.slice(0, hit.start) + replacement + source.slice(hit.start + hit.length),
|
|
25619
|
-
count: 1,
|
|
25620
|
-
matchMode
|
|
25621
|
-
};
|
|
25622
|
-
};
|
|
25623
|
-
const exact = [];
|
|
25624
|
-
for (let at = source.indexOf(needle);at !== -1; at = source.indexOf(needle, at + needle.length)) {
|
|
25625
|
-
exact.push({ start: at, length: needle.length });
|
|
25626
|
-
}
|
|
25627
|
-
if (exact.length)
|
|
25628
|
-
return spans(exact, "exact");
|
|
25629
|
-
const rx = tolerantFindRegex(needle);
|
|
25630
|
-
if (!rx)
|
|
25631
|
-
return null;
|
|
25632
|
-
const tolerant = [...source.matchAll(rx)].map((m) => ({ start: m.index, length: m[0].length }));
|
|
25633
|
-
return tolerant.length ? spans(tolerant, "attribute-tolerant") : null;
|
|
25634
|
-
};
|
|
25635
25997
|
const missHint = (needle, source, alreadyConsumed) => {
|
|
25636
25998
|
if (alreadyConsumed)
|
|
25637
25999
|
return `Not found \u2014 but an EARLIER edit in this same edits= array already replaced this exact string. The array is applied in order against one body, so the second pass had nothing left to match. This is the expected result, not a failure.`;
|
|
@@ -25639,7 +26001,7 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
25639
26001
|
return `Not found \u2014 the search string carries escaped markup (<\u2026>) while note bodies store real tags. Pass the tag literally, e.g. "<h3>Typography</h3>".`;
|
|
25640
26002
|
if (spansBlockBoundary(needle))
|
|
25641
26003
|
return `Not found \u2014 this string spans an element boundary (a closing tag followed by an opening one). Anchor the find INSIDE a single element instead, or target the heading directly with section= (with mode="before"/"after" to insert around it).`;
|
|
25642
|
-
return `Not found in the note body (exact
|
|
26004
|
+
return `Not found in the note body (exact, attribute-tolerant or ignoring inline tags) \u2014 already replaced on a retry, or the text genuinely differs.`;
|
|
25643
26005
|
};
|
|
25644
26006
|
const missContext = (needle, source) => {
|
|
25645
26007
|
const near = nearestContext(source, needle);
|
|
@@ -25658,7 +26020,8 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
25658
26020
|
for (let at = current.indexOf(find);at !== -1; at = current.indexOf(find, at + find.length))
|
|
25659
26021
|
exact.push({ start: at, length: find.length });
|
|
25660
26022
|
const rx = exact.length ? null : tolerantFindRegex(find);
|
|
25661
|
-
const
|
|
26023
|
+
const tolerantHits = rx ? [...current.matchAll(rx)].map((m) => ({ start: m.index, length: m[0].length })) : [];
|
|
26024
|
+
const hits = exact.length ? exact : tolerantHits.length ? tolerantHits : inlineTolerantSpans(current, find);
|
|
25662
26025
|
const res = editWithin(current, hits, within, action, body ?? "", nth);
|
|
25663
26026
|
if (!res.ok) {
|
|
25664
26027
|
const hint = res.reason === "no_match" ? missHint(find, current, false) : res.reason === "no_element" ? `The anchor matched ${res.matches} time(s), but never inside a <${within}>. Check the tag, or drop within= to edit the text itself.` : `The anchor sits in ${res.matches} different <${within}> elements. Lengthen it until it is unique, or pass nth= (1-based, in document order) \u2014 elements[] previews them.`;
|
|
@@ -25704,7 +26067,7 @@ Check the receipt: matched=false means a NEW section was written (available= lis
|
|
|
25704
26067
|
let total = 0;
|
|
25705
26068
|
const consumed = new Set;
|
|
25706
26069
|
for (const e of plan) {
|
|
25707
|
-
const applied =
|
|
26070
|
+
const applied = applyFindEdit(working, e.find, e.body, e.nth);
|
|
25708
26071
|
if (!applied) {
|
|
25709
26072
|
const alreadyConsumed = consumed.has(e.find);
|
|
25710
26073
|
results.push({
|
|
@@ -26045,7 +26408,7 @@ ${result.html}`);
|
|
|
26045
26408
|
...warnings.length ? { sanitized: warnings } : {}
|
|
26046
26409
|
});
|
|
26047
26410
|
});
|
|
26048
|
-
server.tool("label", `Set or remove one label (remove=true). Refused on containers. noteType cannot be changed or removed, but can be set on an untyped note to repair it. status must be one of ${Statuses.join(" | ")}; domain and topic are slugged. Bumps updated unless you are setting it.`, {
|
|
26411
|
+
server.tool("label", `Set or remove one label (remove=true). Refused on containers. noteType cannot be changed or removed, but can be set on an untyped note to repair it. status must be one of ${Statuses.join(" | ")}; domain and topic are slugged. register (value "") marks a register \u2014 a maintained table whose rows carry evidence dates \u2014 so maintain's dated-prose lint skips its tables. Bumps updated unless you are setting it.`, {
|
|
26049
26412
|
noteId: stringType().describe("Note to edit"),
|
|
26050
26413
|
name: stringType().describe("Label name, no # prefix (e.g. status, domain, topic, created)"),
|
|
26051
26414
|
value: stringType().optional().describe("New value \u2014 required unless remove=true"),
|
|
@@ -27401,6 +27764,7 @@ var TOOL_ANNOTATIONS = {
|
|
|
27401
27764
|
consistency: READ,
|
|
27402
27765
|
assembly: READ,
|
|
27403
27766
|
diff: READ,
|
|
27767
|
+
health: READ,
|
|
27404
27768
|
master: READ,
|
|
27405
27769
|
master_recall: READ,
|
|
27406
27770
|
llm: READ,
|
|
@@ -27419,11 +27783,11 @@ var TOOL_ANNOTATIONS = {
|
|
|
27419
27783
|
claim: APPEND,
|
|
27420
27784
|
remember: WRITE,
|
|
27421
27785
|
diary: APPEND,
|
|
27422
|
-
revise:
|
|
27786
|
+
revise: DESTRUCTIVE,
|
|
27423
27787
|
split: WRITE,
|
|
27424
27788
|
close: APPEND,
|
|
27425
|
-
connect:
|
|
27426
|
-
label:
|
|
27789
|
+
connect: DESTRUCTIVE,
|
|
27790
|
+
label: DESTRUCTIVE,
|
|
27427
27791
|
attach: WRITE,
|
|
27428
27792
|
backup: APPEND,
|
|
27429
27793
|
bootstrap: WRITE,
|
|
@@ -27449,16 +27813,16 @@ var TOOL_ANNOTATIONS = {
|
|
|
27449
27813
|
get_year_note: WRITE,
|
|
27450
27814
|
get_inbox_note: WRITE,
|
|
27451
27815
|
create_note: APPEND,
|
|
27452
|
-
patch_note:
|
|
27453
|
-
update_note_content:
|
|
27816
|
+
patch_note: DESTRUCTIVE,
|
|
27817
|
+
update_note_content: DESTRUCTIVE,
|
|
27454
27818
|
clone_note: APPEND,
|
|
27455
27819
|
move_note: WRITE,
|
|
27456
27820
|
undelete_note: WRITE,
|
|
27457
27821
|
add_label: WRITE,
|
|
27458
27822
|
add_relation: WRITE,
|
|
27459
|
-
update_attribute:
|
|
27823
|
+
update_attribute: DESTRUCTIVE,
|
|
27460
27824
|
create_attachment: APPEND,
|
|
27461
|
-
update_attachment:
|
|
27825
|
+
update_attachment: DESTRUCTIVE,
|
|
27462
27826
|
create_revision: APPEND,
|
|
27463
27827
|
create_backup: APPEND,
|
|
27464
27828
|
delete_note: DESTRUCTIVE,
|
|
@@ -27466,6 +27830,86 @@ var TOOL_ANNOTATIONS = {
|
|
|
27466
27830
|
delete_branch: DESTRUCTIVE,
|
|
27467
27831
|
delete_attachment: DESTRUCTIVE
|
|
27468
27832
|
};
|
|
27833
|
+
var TOOL_TITLES = {
|
|
27834
|
+
start: "Open the brain for this session",
|
|
27835
|
+
session: "Review the session before closing",
|
|
27836
|
+
remarks: "Get diary prompts",
|
|
27837
|
+
close: "Close the session and write its log",
|
|
27838
|
+
backup: "Back up the brain",
|
|
27839
|
+
health: "Check storage and runtime health",
|
|
27840
|
+
diary: "Write the daily diary",
|
|
27841
|
+
remember: "Remember something",
|
|
27842
|
+
recall: "Search the brain",
|
|
27843
|
+
domain: "Read everything about an area",
|
|
27844
|
+
read: "Read several notes",
|
|
27845
|
+
revise: "Edit a note",
|
|
27846
|
+
resolve: "Resolve a thread",
|
|
27847
|
+
split: "Split a note into two",
|
|
27848
|
+
withdraw: "Reopen a thread",
|
|
27849
|
+
label: "Set or remove a label",
|
|
27850
|
+
connect: "Link two notes",
|
|
27851
|
+
explore: "Explore a note's links",
|
|
27852
|
+
consistency: "Check the brain agrees with itself",
|
|
27853
|
+
outline: "Show a note's headings",
|
|
27854
|
+
inspect: "Inspect a note's raw form",
|
|
27855
|
+
claim: "Register or verify a claim",
|
|
27856
|
+
diff: "Show what changed",
|
|
27857
|
+
attach: "Attach a file to a note",
|
|
27858
|
+
detach: "Remove an attachment",
|
|
27859
|
+
addendum: "Find notes with pending addenda",
|
|
27860
|
+
maintain: "Run brain maintenance",
|
|
27861
|
+
forget: "Archive or delete a note",
|
|
27862
|
+
recover: "Restore an archived note",
|
|
27863
|
+
template: "Show a note kind's template",
|
|
27864
|
+
graph: "Render the relation graph",
|
|
27865
|
+
day: "Summarise the day",
|
|
27866
|
+
brain: "List the brain's inventory",
|
|
27867
|
+
assembly: "List what the brain holds",
|
|
27868
|
+
bootstrap: "Create or refresh the brain's structure",
|
|
27869
|
+
master: "Read a note about the user",
|
|
27870
|
+
master_recall: "Skim notes about the user",
|
|
27871
|
+
llm: "Read the model's operating notes",
|
|
27872
|
+
llm_recall: "Skim the model's operating notes",
|
|
27873
|
+
memory: "Read a thread or session",
|
|
27874
|
+
memory_recall: "Skim threads and sessions",
|
|
27875
|
+
knowledge: "Read a knowledge note",
|
|
27876
|
+
knowledge_recall: "Skim knowledge",
|
|
27877
|
+
insights: "Read a day's change log",
|
|
27878
|
+
insights_recall: "Skim insights",
|
|
27879
|
+
get_note: "Get note metadata (raw)",
|
|
27880
|
+
get_note_content: "Get note content (raw)",
|
|
27881
|
+
get_attachments: "List attachments (raw)",
|
|
27882
|
+
get_attachment_content: "Get attachment content (raw)",
|
|
27883
|
+
get_attribute: "Get an attribute (raw)",
|
|
27884
|
+
get_branch: "Get a branch (raw)",
|
|
27885
|
+
get_revisions: "List revisions (raw)",
|
|
27886
|
+
get_revision_content: "Get revision content (raw)",
|
|
27887
|
+
search_notes: "Search notes (raw query)",
|
|
27888
|
+
note_history: "List recent changes (raw)",
|
|
27889
|
+
get_app_info: "Get Trilium version (raw)",
|
|
27890
|
+
get_day_note: "Get or create a day note (raw)",
|
|
27891
|
+
get_week_note: "Get or create a week note (raw)",
|
|
27892
|
+
get_month_note: "Get or create a month note (raw)",
|
|
27893
|
+
get_year_note: "Get or create a year note (raw)",
|
|
27894
|
+
get_inbox_note: "Get or create the inbox note (raw)",
|
|
27895
|
+
create_note: "Create a note (raw)",
|
|
27896
|
+
patch_note: "Change note properties (raw)",
|
|
27897
|
+
update_note_content: "Replace note content (raw)",
|
|
27898
|
+
clone_note: "Clone a note (raw)",
|
|
27899
|
+
move_note: "Move a note (raw)",
|
|
27900
|
+
undelete_note: "Undelete a note (raw)",
|
|
27901
|
+
add_label: "Add a label (raw)",
|
|
27902
|
+
add_relation: "Add a relation (raw)",
|
|
27903
|
+
update_attribute: "Update an attribute (raw)",
|
|
27904
|
+
create_attachment: "Create an attachment (raw)",
|
|
27905
|
+
update_attachment: "Update an attachment (raw)",
|
|
27906
|
+
create_revision: "Snapshot a revision (raw)",
|
|
27907
|
+
create_backup: "Create a backup (raw)",
|
|
27908
|
+
delete_note: "Delete a note (raw)",
|
|
27909
|
+
delete_attribute: "Delete an attribute (raw)",
|
|
27910
|
+
delete_branch: "Delete a branch (raw)",
|
|
27911
|
+
delete_attachment: "Delete an attachment (raw)"
|
|
27912
|
+
};
|
|
27469
27913
|
function applyToolAnnotations(server) {
|
|
27470
27914
|
const registry = server._registeredTools;
|
|
27471
27915
|
if (!registry)
|
|
@@ -27475,11 +27919,15 @@ function applyToolAnnotations(server) {
|
|
|
27475
27919
|
for (const [name, tool] of Object.entries(registry)) {
|
|
27476
27920
|
const hints = TOOL_ANNOTATIONS[name];
|
|
27477
27921
|
if (!hints) {
|
|
27478
|
-
|
|
27922
|
+
const fallback = TOOL_TITLES[name] ?? name;
|
|
27923
|
+
tool.annotations = { ...tool.annotations, ...APPEND, title: fallback };
|
|
27924
|
+
tool.title = fallback;
|
|
27479
27925
|
unclassified.push(name);
|
|
27480
27926
|
continue;
|
|
27481
27927
|
}
|
|
27482
|
-
|
|
27928
|
+
const title = TOOL_TITLES[name] ?? name;
|
|
27929
|
+
tool.annotations = { ...tool.annotations, ...hints, title };
|
|
27930
|
+
tool.title = title;
|
|
27483
27931
|
annotated++;
|
|
27484
27932
|
}
|
|
27485
27933
|
return { annotated, unclassified };
|
|
@@ -27971,6 +28419,93 @@ note.dateModified >= 'YYYY-MM-DD', AND/OR. Unscoped unless ancestorNoteId is giv
|
|
|
27971
28419
|
});
|
|
27972
28420
|
}
|
|
27973
28421
|
|
|
28422
|
+
// src/cap.ts
|
|
28423
|
+
var RESULT_CHAR_CAP = 140000;
|
|
28424
|
+
function capResult(result, tool, cap = RESULT_CHAR_CAP) {
|
|
28425
|
+
const blocks = result?.content;
|
|
28426
|
+
if (!Array.isArray(blocks))
|
|
28427
|
+
return result;
|
|
28428
|
+
const total = blocks.reduce((n, b) => n + (b.type === "text" ? b.text?.length ?? 0 : 0), 0);
|
|
28429
|
+
if (total <= cap)
|
|
28430
|
+
return result;
|
|
28431
|
+
let budget = cap;
|
|
28432
|
+
const content = blocks.map((b) => {
|
|
28433
|
+
if (b.type !== "text" || !b.text)
|
|
28434
|
+
return b;
|
|
28435
|
+
if (budget <= 0)
|
|
28436
|
+
return { ...b, text: "" };
|
|
28437
|
+
const text = b.text.length > budget ? b.text.slice(0, budget) : b.text;
|
|
28438
|
+
budget -= text.length;
|
|
28439
|
+
return { ...b, text };
|
|
28440
|
+
});
|
|
28441
|
+
const note = `
|
|
28442
|
+
|
|
28443
|
+
[BrainLLM: ${tool}() returned ${total.toLocaleString("en-US")} characters, cut at ${cap.toLocaleString("en-US")} to fit the client's limit. ` + `Narrow the read: section= for one heading, block= for one addendum, limit= for fewer entries, or fewer ids=.]`;
|
|
28444
|
+
content.push({ type: "text", text: note });
|
|
28445
|
+
return { ...result, content };
|
|
28446
|
+
}
|
|
28447
|
+
function capToolResults(server, cap = RESULT_CHAR_CAP) {
|
|
28448
|
+
const registry = server._registeredTools;
|
|
28449
|
+
if (!registry)
|
|
28450
|
+
return 0;
|
|
28451
|
+
let wrapped = 0;
|
|
28452
|
+
for (const [name, tool] of Object.entries(registry)) {
|
|
28453
|
+
const inner = tool.handler;
|
|
28454
|
+
if (typeof inner !== "function")
|
|
28455
|
+
continue;
|
|
28456
|
+
tool.handler = async (...args) => capResult(await inner(...args), name, cap);
|
|
28457
|
+
wrapped++;
|
|
28458
|
+
}
|
|
28459
|
+
return wrapped;
|
|
28460
|
+
}
|
|
28461
|
+
|
|
28462
|
+
// src/brand.ts
|
|
28463
|
+
var BRAND2 = {
|
|
28464
|
+
name: "BrainLLM",
|
|
28465
|
+
tagline: "Give Claude a memory that survives the session",
|
|
28466
|
+
description: "Self-hosted memory for LLMs in TriliumNext, structured by the server and editable by you.",
|
|
28467
|
+
website: "https://miisodev.github.io/BrainLLM/",
|
|
28468
|
+
repository: "https://github.com/miisodev/BrainLLM",
|
|
28469
|
+
publisher: "miisodev",
|
|
28470
|
+
colors: { ground: "#0a0a0f", accent: "#f59e0b", nodeOff: "#3f3f46" }
|
|
28471
|
+
};
|
|
28472
|
+
var MARK_GRID = [
|
|
28473
|
+
true,
|
|
28474
|
+
false,
|
|
28475
|
+
true,
|
|
28476
|
+
true,
|
|
28477
|
+
true,
|
|
28478
|
+
false,
|
|
28479
|
+
false,
|
|
28480
|
+
true,
|
|
28481
|
+
true
|
|
28482
|
+
];
|
|
28483
|
+
function markSvg(size = 22) {
|
|
28484
|
+
const dots = MARK_GRID.map((lit, i) => {
|
|
28485
|
+
const cx = 164 + i % 3 * 92;
|
|
28486
|
+
const cy = 164 + Math.floor(i / 3) * 92;
|
|
28487
|
+
return `<circle cx="${cx}" cy="${cy}" r="38" fill="${lit ? BRAND2.colors.accent : BRAND2.colors.nodeOff}"/>`;
|
|
28488
|
+
}).join("");
|
|
28489
|
+
return `<svg class="brand-mark" width="${size}" height="${size}" viewBox="110 110 292 292" aria-hidden="true">${dots}</svg>`;
|
|
28490
|
+
}
|
|
28491
|
+
var SERVER_INSTRUCTIONS = [
|
|
28492
|
+
`${BRAND2.name} is the user's persistent memory, stored in their own TriliumNext instance. Every note is one the user can open, read and correct.`,
|
|
28493
|
+
"The brain has five areas: Master (about the user), LLM (the assistant's operating notes and daily diary), Memory (threads and session logs), Knowledge (domains of sourced notes) and Insights (daily change logs and the relation graph).",
|
|
28494
|
+
"A session typically opens with start(), which returns the user's stored preferences and protocols and what changed since the last session, and ends with session() and close(), which write the session log. remember() and revise() write; recall(), domain() and the surface reads (master, llm, memory, knowledge, insights) read.",
|
|
28495
|
+
"The server owns placement, labels, deduplication, dates and backups, so writes need content, not bookkeeping."
|
|
28496
|
+
].join(`
|
|
28497
|
+
|
|
28498
|
+
`);
|
|
28499
|
+
function brandIcons(origin) {
|
|
28500
|
+
const REPO_RAW = "https://raw.githubusercontent.com/miisodev/BrainLLM/main/public";
|
|
28501
|
+
const at = (file) => origin ? `${origin}/${file}` : `${REPO_RAW}/${file}`;
|
|
28502
|
+
return [
|
|
28503
|
+
{ src: at("icon-128.png"), mimeType: "image/png", sizes: ["128x128"] },
|
|
28504
|
+
{ src: at("icon-512.png"), mimeType: "image/png", sizes: ["512x512"] },
|
|
28505
|
+
{ src: origin ? `${origin}/icon.svg` : `${REPO_RAW}/BrainLLM.svg`, mimeType: "image/svg+xml", sizes: ["any"] }
|
|
28506
|
+
];
|
|
28507
|
+
}
|
|
28508
|
+
|
|
27974
28509
|
// src/sse.ts
|
|
27975
28510
|
import { randomUUID } from "crypto";
|
|
27976
28511
|
var KEEPALIVE_MS = 30000;
|
|
@@ -28754,9 +29289,7 @@ var PAGE_CSS = `
|
|
|
28754
29289
|
border:1px solid var(--border); border-radius:16px; padding:32px;
|
|
28755
29290
|
}
|
|
28756
29291
|
.brand { display:flex; align-items:center; gap:10px; margin-bottom:26px; }
|
|
28757
|
-
.brand-mark { display:
|
|
28758
|
-
.brand-mark i { width:4px; height:4px; border-radius:50%; background:var(--accent); display:block; }
|
|
28759
|
-
.brand-mark i.off { background:#3f3f46; }
|
|
29292
|
+
.brand-mark { display:block; flex:none; }
|
|
28760
29293
|
.brand-name { font-family:var(--font-display); font-size:19px; font-weight:700; letter-spacing:-.475px; }
|
|
28761
29294
|
h1 { font-family:var(--font-display); font-size:24px; font-weight:700; letter-spacing:-.02em; line-height:1.2; margin:0 0 8px; }
|
|
28762
29295
|
p { margin:0 0 20px; color:var(--text-muted); font-size:14.5px; }
|
|
@@ -28788,6 +29321,12 @@ var PAGE_CSS = `
|
|
|
28788
29321
|
.approve:hover { transform:translateY(-1px); box-shadow:0 0 56px rgba(245,158,11,.42); }
|
|
28789
29322
|
.deny { background:transparent; color:var(--text); border-color:var(--border-strong); font-weight:500; }
|
|
28790
29323
|
.deny:hover { background:rgba(255,255,255,.04); }
|
|
29324
|
+
.redirect { margin-top:-8px; }
|
|
29325
|
+
.warn {
|
|
29326
|
+
background:rgba(245,158,11,.08); border:1px solid rgba(245,158,11,.3); color:#fcd34d;
|
|
29327
|
+
padding:11px 14px; border-radius:9px; font-size:14px; margin-bottom:18px;
|
|
29328
|
+
}
|
|
29329
|
+
a { color:var(--accent); }
|
|
28791
29330
|
.err {
|
|
28792
29331
|
background:rgba(239,68,68,.1); border:1px solid rgba(239,68,68,.3); color:#fca5a5;
|
|
28793
29332
|
padding:11px 14px; border-radius:9px; font-size:14px; margin-bottom:18px;
|
|
@@ -28796,7 +29335,7 @@ var PAGE_CSS = `
|
|
|
28796
29335
|
.foot a { color:inherit; }
|
|
28797
29336
|
@media (prefers-reduced-motion: reduce) { button { transition:none; } .approve:hover { transform:none; } }
|
|
28798
29337
|
`;
|
|
28799
|
-
var BRAND_MARK =
|
|
29338
|
+
var BRAND_MARK = markSvg(22);
|
|
28800
29339
|
var shell = (title, body, head = "") => `<!doctype html>
|
|
28801
29340
|
<html lang="en"><head><meta charset="utf-8">
|
|
28802
29341
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
@@ -28806,13 +29345,13 @@ var shell = (title, body, head = "") => `<!doctype html>
|
|
|
28806
29345
|
<style>${PAGE_CSS}</style></head>
|
|
28807
29346
|
<body>${body}</body></html>`;
|
|
28808
29347
|
function landingPage(base, oauthOn, sseOn) {
|
|
28809
|
-
return shell(
|
|
28810
|
-
<div class="brand">${BRAND_MARK}<span class="brand-name"
|
|
29348
|
+
return shell(`${BRAND2.name} - ${BRAND2.tagline}`, `<div class="card">
|
|
29349
|
+
<div class="brand">${BRAND_MARK}<span class="brand-name">${BRAND2.name}</span></div>
|
|
28811
29350
|
<h1>A brain is listening</h1>
|
|
28812
|
-
<p>This is a <strong
|
|
28813
|
-
<p>Point an MCP client at <span class="host">${esc2(base)}/mcp</span>${sseOn ?
|
|
29351
|
+
<p>This is a <strong>${BRAND2.name}</strong> server: ${esc2(BRAND2.description.charAt(0).toLowerCase() + BRAND2.description.slice(1))}</p>
|
|
29352
|
+
<p>Point an MCP client at <span class="host">${esc2(base)}/mcp</span>${sseOn ? `, or, for clients that only speak the legacy SSE transport, <span class="host">${esc2(base)}/sse</span>` : ""}.</p>
|
|
28814
29353
|
<p>${oauthOn ? `Authentication: OAuth 2.1 with owner consent, or a static bearer token (<span class="host">MCP_AUTH_TOKEN</span>). Both work against the same brain.` : `Authentication: a static bearer token (<span class="host">MCP_AUTH_TOKEN</span>).`}</p>
|
|
28815
|
-
<p class="foot">Health: <span class="host">/health</span> \xB7 Everything else is MCP
|
|
29354
|
+
<p class="foot">Health: <span class="host">/health</span> \xB7 Everything else is MCP \xB7 About: <span class="host">${esc2(BRAND2.website.replace(/^https:\/\//, "").replace(/\/$/, ""))}</span></p>
|
|
28816
29355
|
</div>`);
|
|
28817
29356
|
}
|
|
28818
29357
|
var PAGE_SECURITY_DIRECTIVES = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; img-src 'self' data:";
|
|
@@ -28820,7 +29359,7 @@ function pageContentSecurityPolicy() {
|
|
|
28820
29359
|
return `${PAGE_SECURITY_DIRECTIVES}; form-action 'self'; style-src 'unsafe-inline'`;
|
|
28821
29360
|
}
|
|
28822
29361
|
function consentResponse(transaction, redirectUri, clientHost, verifiedHost, error, status = 200) {
|
|
28823
|
-
return new Response(consentPage({ transaction }, clientHost, error, verifiedHost), {
|
|
29362
|
+
return new Response(consentPage({ transaction }, clientHost, error, verifiedHost, redirectUri), {
|
|
28824
29363
|
status,
|
|
28825
29364
|
headers: {
|
|
28826
29365
|
"Content-Type": "text/html; charset=utf-8",
|
|
@@ -28829,13 +29368,28 @@ function consentResponse(transaction, redirectUri, clientHost, verifiedHost, err
|
|
|
28829
29368
|
}
|
|
28830
29369
|
});
|
|
28831
29370
|
}
|
|
28832
|
-
function
|
|
29371
|
+
function redirectTarget(redirectUri) {
|
|
29372
|
+
if (!redirectUri)
|
|
29373
|
+
return null;
|
|
29374
|
+
try {
|
|
29375
|
+
const u = new URL(redirectUri);
|
|
29376
|
+
const host = u.hostname.replace(/^\[|\]$/g, "");
|
|
29377
|
+
const loopback = host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
29378
|
+
return { host: u.port ? `${u.hostname}:${u.port}` : u.hostname, loopback };
|
|
29379
|
+
} catch {
|
|
29380
|
+
return null;
|
|
29381
|
+
}
|
|
29382
|
+
}
|
|
29383
|
+
function consentPage(params, clientHost, error, verifiedHost = true, redirectUri) {
|
|
29384
|
+
const target = redirectTarget(redirectUri);
|
|
28833
29385
|
const hidden = Object.entries(params).map(([k, v]) => `<input type="hidden" name="${esc2(k)}" value="${esc2(v)}">`).join("");
|
|
28834
29386
|
const foot = verifiedHost ? "Only the host above is shown, and it is the one DNS and TLS vouch for." : "The name above is self-reported by the application \u2014 your password is what authorizes access.";
|
|
28835
29387
|
return shell("Authorize access to your brain", `<form class="card" method="POST" action="/authorize">
|
|
28836
|
-
<div class="brand">${BRAND_MARK}<span class="brand-name"
|
|
29388
|
+
<div class="brand">${BRAND_MARK}<span class="brand-name">${BRAND2.name}</span></div>
|
|
28837
29389
|
<h1>Authorize access</h1>
|
|
28838
29390
|
<p><span class="host">${esc2(clientHost)}</span> is requesting access to your brain.</p>
|
|
29391
|
+
${target ? `<p class="redirect">Approving sends you back to <span class="host">${esc2(target.host)}</span>.</p>` : ""}
|
|
29392
|
+
${target?.loopback ? `<div class="warn">That address is this computer. Approve only if you just started this sign-in from an app on this machine, such as Claude Code.</div>` : ""}
|
|
28839
29393
|
${error ? `<div class="err">${esc2(error)}</div>` : ""}
|
|
28840
29394
|
<div class="scope"><span class="dot"></span><span>Read and write everything in your brain \u2014 memories, threads, knowledge and your diary.</span></div>
|
|
28841
29395
|
<label for="pw">Owner password</label>
|
|
@@ -28860,7 +29414,7 @@ function handoff(uri, params, heading, message) {
|
|
|
28860
29414
|
u.searchParams.set(k, v);
|
|
28861
29415
|
const href = esc2(u.href);
|
|
28862
29416
|
return new Response(shell(heading, `<div class="card">
|
|
28863
|
-
<div class="brand">${BRAND_MARK}<span class="brand-name"
|
|
29417
|
+
<div class="brand">${BRAND_MARK}<span class="brand-name">${BRAND2.name}</span></div>
|
|
28864
29418
|
<h1>${esc2(heading)}</h1>
|
|
28865
29419
|
<p>${esc2(message)}</p>
|
|
28866
29420
|
<p class="foot"><a href="${href}">Continue</a> if this page does not move on by itself.</p>
|
|
@@ -28875,7 +29429,7 @@ function handoff(uri, params, heading, message) {
|
|
|
28875
29429
|
});
|
|
28876
29430
|
}
|
|
28877
29431
|
var htmlError = (message, status = 400) => new Response(shell("Authorization error", `<div class="card">
|
|
28878
|
-
<div class="brand">${BRAND_MARK}<span class="brand-name"
|
|
29432
|
+
<div class="brand">${BRAND_MARK}<span class="brand-name">${BRAND2.name}</span></div>
|
|
28879
29433
|
<h1>Authorization error</h1>
|
|
28880
29434
|
<div class="err">${esc2(message)}</div>
|
|
28881
29435
|
<p class="foot">Nothing was authorized. Close this window and start again from your client.</p>
|
|
@@ -28982,12 +29536,15 @@ var tokenError = (error, description, status = 400) => new Response(JSON.stringi
|
|
|
28982
29536
|
status,
|
|
28983
29537
|
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }
|
|
28984
29538
|
});
|
|
29539
|
+
function refreshKey(token) {
|
|
29540
|
+
return "sha256:" + createHash("sha256").update(token).digest("hex");
|
|
29541
|
+
}
|
|
28985
29542
|
function issueTokens(base, clientId, resource, scope) {
|
|
28986
29543
|
const store = loadStore();
|
|
28987
29544
|
const now = Math.floor(Date.now() / 1000);
|
|
28988
29545
|
const accessToken = signJwt({ iss: base, aud: resource, sub: "owner", scope, iat: now, exp: now + ACCESS_TOKEN_TTL_S }, signingSecret());
|
|
28989
29546
|
const refreshToken = randomBytes(32).toString("base64url");
|
|
28990
|
-
store.refresh[refreshToken] = { clientId, resource, scope, expiresAt: Date.now() + REFRESH_TOKEN_TTL_MS };
|
|
29547
|
+
store.refresh[refreshKey(refreshToken)] = { clientId, resource, scope, expiresAt: Date.now() + REFRESH_TOKEN_TTL_MS };
|
|
28991
29548
|
saveStore();
|
|
28992
29549
|
return {
|
|
28993
29550
|
access_token: accessToken,
|
|
@@ -29035,10 +29592,11 @@ async function handleToken(req, base) {
|
|
|
29035
29592
|
}
|
|
29036
29593
|
if (grantType === "refresh_token") {
|
|
29037
29594
|
const supplied = get("refresh_token");
|
|
29038
|
-
const
|
|
29039
|
-
|
|
29595
|
+
const key = supplied && store.refresh[refreshKey(supplied)] ? refreshKey(supplied) : supplied;
|
|
29596
|
+
const record = key ? store.refresh[key] : undefined;
|
|
29597
|
+
if (!record || !key)
|
|
29040
29598
|
return tokenError("invalid_grant", "Unknown or already-rotated refresh token.");
|
|
29041
|
-
delete store.refresh[
|
|
29599
|
+
delete store.refresh[key];
|
|
29042
29600
|
saveStore();
|
|
29043
29601
|
if (record.expiresAt < Date.now())
|
|
29044
29602
|
return tokenError("invalid_grant", "Refresh token expired.");
|
|
@@ -29128,22 +29686,15 @@ if (port && !authToken && !oauthEnabled() && !allowUnauthenticatedHttp) {
|
|
|
29128
29686
|
console.error("HTTP mode requires MCP_AUTH_TOKEN or BRAINLLM_OWNER_PASSWORD. Set BRAINLLM_ALLOW_UNAUTHENTICATED_HTTP=true only for a trusted, isolated network.");
|
|
29129
29687
|
process.exit(1);
|
|
29130
29688
|
}
|
|
29131
|
-
var REPO_RAW = "https://raw.githubusercontent.com/miisodev/BrainLLM/main/public";
|
|
29132
|
-
function brandingIcons(origin) {
|
|
29133
|
-
const at = (file) => origin ? `${origin}/${file}` : `${REPO_RAW}/${file}`;
|
|
29134
|
-
return [
|
|
29135
|
-
{ src: at("icon-128.png"), mimeType: "image/png", sizes: ["128x128"] },
|
|
29136
|
-
{ src: at("icon-512.png"), mimeType: "image/png", sizes: ["512x512"] },
|
|
29137
|
-
{ src: origin ? `${origin}/icon.svg` : `${REPO_RAW}/BrainLLM.svg`, mimeType: "image/svg+xml", sizes: ["any"] }
|
|
29138
|
-
];
|
|
29139
|
-
}
|
|
29140
29689
|
function createServer(origin = null) {
|
|
29141
29690
|
const s = new McpServer({
|
|
29142
|
-
name:
|
|
29143
|
-
title:
|
|
29144
|
-
version: "12.
|
|
29145
|
-
|
|
29146
|
-
|
|
29691
|
+
name: BRAND2.name,
|
|
29692
|
+
title: BRAND2.name,
|
|
29693
|
+
version: "12.8.0",
|
|
29694
|
+
description: BRAND2.description,
|
|
29695
|
+
websiteUrl: BRAND2.website,
|
|
29696
|
+
icons: brandIcons(origin)
|
|
29697
|
+
}, { instructions: SERVER_INSTRUCTIONS });
|
|
29147
29698
|
registerTools(s, trilium, brainRef);
|
|
29148
29699
|
if (mode === "full")
|
|
29149
29700
|
registerAdvancedTools(s, trilium, brainRef);
|
|
@@ -29155,6 +29706,7 @@ function createServer(origin = null) {
|
|
|
29155
29706
|
if (unclassified.length) {
|
|
29156
29707
|
console.error(`[brainllm] Unclassified tools, treated as writes: ${unclassified.join(", ")}`);
|
|
29157
29708
|
}
|
|
29709
|
+
capToolResults(s);
|
|
29158
29710
|
const serialized = serializeWrites(s);
|
|
29159
29711
|
if (serialized.serialized) {
|
|
29160
29712
|
console.error(`[brainllm] Write serialization: ${serialized.serialized} write handler(s) queued, ${serialized.readsUntouched} read(s) untouched`);
|
|
@@ -29191,7 +29743,8 @@ if (port) {
|
|
|
29191
29743
|
authorize: new FixedWindowRateLimiter(30, 15 * 60000),
|
|
29192
29744
|
token: new FixedWindowRateLimiter(120, 15 * 60000),
|
|
29193
29745
|
register: new FixedWindowRateLimiter(30, 60 * 60000),
|
|
29194
|
-
transport: new FixedWindowRateLimiter(600, 15 * 60000)
|
|
29746
|
+
transport: new FixedWindowRateLimiter(600, 15 * 60000),
|
|
29747
|
+
transportIp: new FixedWindowRateLimiter(3000, 15 * 60000)
|
|
29195
29748
|
};
|
|
29196
29749
|
const rateLimited = (limiter, key) => {
|
|
29197
29750
|
const decision = limiter.allow(key);
|
|
@@ -29230,6 +29783,7 @@ if (port) {
|
|
|
29230
29783
|
Bun.serve({
|
|
29231
29784
|
port,
|
|
29232
29785
|
maxRequestBodySize: 50 * 1024 * 1024,
|
|
29786
|
+
idleTimeout: 240,
|
|
29233
29787
|
async fetch(req, server) {
|
|
29234
29788
|
const url = new URL(req.url);
|
|
29235
29789
|
const requestKey = clientKey(req, server);
|
|
@@ -29304,7 +29858,12 @@ if (port) {
|
|
|
29304
29858
|
if (!isMcpEndpoint && url.pathname !== "/sse" && url.pathname !== "/messages") {
|
|
29305
29859
|
return withCors(new Response("Not Found", { status: 404 }));
|
|
29306
29860
|
}
|
|
29307
|
-
const
|
|
29861
|
+
const ipLimit = rateLimited(rateLimiters.transportIp, requestKey);
|
|
29862
|
+
if (ipLimit)
|
|
29863
|
+
return ipLimit;
|
|
29864
|
+
const bearer = req.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
29865
|
+
const transportKey = bearer ? `token:${new Bun.CryptoHasher("sha256").update(bearer).digest("hex").slice(0, 32)}` : requestKey;
|
|
29866
|
+
const transportLimit = rateLimited(rateLimiters.transport, transportKey);
|
|
29308
29867
|
if (transportLimit)
|
|
29309
29868
|
return transportLimit;
|
|
29310
29869
|
const gate = () => {
|