@appsoftwareltd/etherpk-mcp 0.8.4 → 0.8.5
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 -0
- package/dist/main.js +661 -120
- package/dist/main.js.map +1 -1
- package/package.json +3 -3
package/dist/main.js
CHANGED
|
@@ -34,7 +34,7 @@ import { request } from "node:https";
|
|
|
34
34
|
import { BlockList, isIP } from "node:net";
|
|
35
35
|
var package_default = {
|
|
36
36
|
name: "@appsoftwareltd/etherpk-mcp",
|
|
37
|
-
version: "0.8.
|
|
37
|
+
version: "0.8.5",
|
|
38
38
|
license: "Elastic-2.0",
|
|
39
39
|
description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
|
|
40
40
|
type: "module",
|
|
@@ -2149,19 +2149,31 @@ function flatten(roots) {
|
|
|
2149
2149
|
roots.forEach((root, i) => walk(root, null, i));
|
|
2150
2150
|
return rows;
|
|
2151
2151
|
}
|
|
2152
|
-
/**
|
|
2153
|
-
|
|
2152
|
+
/**
|
|
2153
|
+
* A document's blocks and links, each link tagged with the block holding it, and which block
|
|
2154
|
+
* each source line belongs to (the deepest whose own range covers it). The one reading of a
|
|
2155
|
+
* document's outline that the index rows and {@link blockConceptChain} share.
|
|
2156
|
+
*/
|
|
2157
|
+
function blocksAndLinks(text) {
|
|
2154
2158
|
const blocks = flatten(parseBlocks(text));
|
|
2155
2159
|
const lineToBlock = /* @__PURE__ */ new Map();
|
|
2156
2160
|
for (const b of blocks) for (let line = b.startLine; line <= b.endLine; line++) lineToBlock.set(line, b.localId);
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2161
|
+
return {
|
|
2162
|
+
blocks,
|
|
2163
|
+
links: wikilinkOccurrencesInSource(text).map((occ) => ({
|
|
2164
|
+
concept: occ.concept,
|
|
2165
|
+
line: occ.line,
|
|
2166
|
+
lineText: occ.lineText,
|
|
2167
|
+
matchStart: occ.matchStart,
|
|
2168
|
+
matchEnd: occ.matchEnd,
|
|
2169
|
+
blockLocalId: lineToBlock.get(occ.line) ?? null
|
|
2170
|
+
})),
|
|
2171
|
+
lineToBlock
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
/** Derive the index rows for a single document's text. Pure. */
|
|
2175
|
+
function deriveDoc(text) {
|
|
2176
|
+
const { blocks, links } = blocksAndLinks(text);
|
|
2165
2177
|
const candidateTasks = blocks.filter((b) => b.kind === "task");
|
|
2166
2178
|
const fenced = candidateTasks.length === 0 ? () => false : fencedLines(text);
|
|
2167
2179
|
const taskBlocks = candidateTasks.filter((b) => !fenced(b.startLine));
|
|
@@ -2198,6 +2210,17 @@ function deriveDoc(text) {
|
|
|
2198
2210
|
*/
|
|
2199
2211
|
function deriveTaskConcepts(blocks, links, taskBlocks) {
|
|
2200
2212
|
if (taskBlocks.length === 0) return [];
|
|
2213
|
+
const byBlock = linksByBlock(links);
|
|
2214
|
+
if (byBlock.size === 0) return [];
|
|
2215
|
+
const rows = [];
|
|
2216
|
+
for (const task of taskBlocks) for (const { concept } of conceptChain(blocks, byBlock, task.localId)) rows.push({
|
|
2217
|
+
blockLocalId: task.localId,
|
|
2218
|
+
concept
|
|
2219
|
+
});
|
|
2220
|
+
return rows;
|
|
2221
|
+
}
|
|
2222
|
+
/** The concepts linked on each block, in the order they are written, by block. */
|
|
2223
|
+
function linksByBlock(links) {
|
|
2201
2224
|
const byBlock = /* @__PURE__ */ new Map();
|
|
2202
2225
|
for (const link of links) {
|
|
2203
2226
|
if (link.blockLocalId === null) continue;
|
|
@@ -2205,21 +2228,26 @@ function deriveTaskConcepts(blocks, links, taskBlocks) {
|
|
|
2205
2228
|
if (existing) existing.push(link.concept);
|
|
2206
2229
|
else byBlock.set(link.blockLocalId, [link.concept]);
|
|
2207
2230
|
}
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2231
|
+
return byBlock;
|
|
2232
|
+
}
|
|
2233
|
+
/**
|
|
2234
|
+
* The walk under a [[Task Concept]] (ADR 0051): the concepts linked on block `from`, then on each
|
|
2235
|
+
* ancestor up to the root, each named once where it is nearest, whatever its case.
|
|
2236
|
+
*/
|
|
2237
|
+
function conceptChain(blocks, byBlock, from) {
|
|
2238
|
+
const out = [];
|
|
2239
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2240
|
+
let depth = 0;
|
|
2241
|
+
for (let id = from; id !== null; id = blocks[id]?.parentId ?? null, depth++) for (const concept of byBlock.get(id) ?? []) {
|
|
2242
|
+
const key = conceptKey$1(concept);
|
|
2243
|
+
if (seen.has(key)) continue;
|
|
2244
|
+
seen.add(key);
|
|
2245
|
+
out.push({
|
|
2246
|
+
concept,
|
|
2247
|
+
depth
|
|
2248
|
+
});
|
|
2221
2249
|
}
|
|
2222
|
-
return
|
|
2250
|
+
return out;
|
|
2223
2251
|
}
|
|
2224
2252
|
/**
|
|
2225
2253
|
* The references a document makes BY ITS NAME: every wikilink nested in the concept string, at
|
|
@@ -2244,6 +2272,14 @@ function deriveTitleLinks(concept) {
|
|
|
2244
2272
|
matchEnd: occ.matchEnd
|
|
2245
2273
|
}));
|
|
2246
2274
|
}
|
|
2275
|
+
/**
|
|
2276
|
+
* The [[Task Concept]]s every task in a document has, whatever its outline (ADR 0051): the
|
|
2277
|
+
* document's own concept, then the [[Scope]]s in its name, outermost first. The index writes these
|
|
2278
|
+
* for every task, and `/kanban` offers them after the block's own (ADR 0113).
|
|
2279
|
+
*/
|
|
2280
|
+
function documentTaskConcepts(concept) {
|
|
2281
|
+
return [concept, ...deriveTitleLinks(concept).map((link) => link.concept)];
|
|
2282
|
+
}
|
|
2247
2283
|
/** The ancestor labels of a block, root-first (the outline-chain breadcrumb). */
|
|
2248
2284
|
function ancestorChain(blocks, localId) {
|
|
2249
2285
|
const chain = [];
|
|
@@ -2743,7 +2779,7 @@ function createSchema(db) {
|
|
|
2743
2779
|
db.exec(SCHEMA$1);
|
|
2744
2780
|
db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('active_generation', 1)");
|
|
2745
2781
|
db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('revision', 0)");
|
|
2746
|
-
db.exec(`PRAGMA user_version =
|
|
2782
|
+
db.exec(`PRAGMA user_version = 15`);
|
|
2747
2783
|
}
|
|
2748
2784
|
function activeIndexGeneration(db) {
|
|
2749
2785
|
return db.all("SELECT value FROM index_metadata WHERE key = 'active_generation'")[0]?.value ?? 1;
|
|
@@ -2764,7 +2800,7 @@ function advanceIndexRevision(db) {
|
|
|
2764
2800
|
*/
|
|
2765
2801
|
function isUsableIndex(db) {
|
|
2766
2802
|
try {
|
|
2767
|
-
return db.all("PRAGMA user_version")[0]?.user_version ===
|
|
2803
|
+
return db.all("PRAGMA user_version")[0]?.user_version === 15;
|
|
2768
2804
|
} catch {
|
|
2769
2805
|
return false;
|
|
2770
2806
|
}
|
|
@@ -2941,13 +2977,17 @@ function insertProperties(db, pageId, doc) {
|
|
|
2941
2977
|
* the document's identity. Without that row, filtering to a project page returns nothing for
|
|
2942
2978
|
* the tasks written directly on it — the case the "Filter to <active tab>" shortcut exists for.
|
|
2943
2979
|
*
|
|
2944
|
-
*
|
|
2945
|
-
*
|
|
2946
|
-
*
|
|
2980
|
+
* The [[Scope]]s in the document's name sit on that root too (ADR 0051, amended 2026-09-29), so
|
|
2981
|
+
* the tasks on `[[Acme]] Website` answer to Acme. They come from the same scan that makes such a
|
|
2982
|
+
* page a Backlink of its scope (ADR 0083), so a page gives its tasks to exactly the concepts it
|
|
2983
|
+
* is a title reference of.
|
|
2984
|
+
*
|
|
2985
|
+
* Only the names the document is written with go in, never an alias of them: the query resolves
|
|
2986
|
+
* a filtered name to its page and matches on every name that page answers to, so an alias row
|
|
2987
|
+
* here would be redundant now and stale after a rename.
|
|
2947
2988
|
*/
|
|
2948
2989
|
function insertTaskConcepts(db, pageId, doc, tasks, taskConcepts) {
|
|
2949
2990
|
if (tasks.length === 0) return;
|
|
2950
|
-
const ownKey = conceptKey$1(doc.concept);
|
|
2951
2991
|
const written = /* @__PURE__ */ new Set();
|
|
2952
2992
|
const write = (blockLocalId, key) => {
|
|
2953
2993
|
const seen = `${blockLocalId} ${key}`;
|
|
@@ -2959,7 +2999,8 @@ function insertTaskConcepts(db, pageId, doc, tasks, taskConcepts) {
|
|
|
2959
2999
|
key
|
|
2960
3000
|
]);
|
|
2961
3001
|
};
|
|
2962
|
-
|
|
3002
|
+
const rootKeys = documentTaskConcepts(doc.concept).map(conceptKey$1);
|
|
3003
|
+
for (const t of tasks) for (const key of rootKeys) write(t.blockLocalId, key);
|
|
2963
3004
|
for (const tc of taskConcepts) write(tc.blockLocalId, conceptKey$1(tc.concept));
|
|
2964
3005
|
}
|
|
2965
3006
|
/**
|
|
@@ -3844,6 +3885,15 @@ var ORDER_SQL = {
|
|
|
3844
3885
|
due: `(t.due IS NULL), t.due ASC, (t.priority IS NULL), t.priority ASC`
|
|
3845
3886
|
};
|
|
3846
3887
|
/**
|
|
3888
|
+
* What decides a tie the grouping leaves: the document's name, then the task's line, which a
|
|
3889
|
+
* person can see and which survive a rebuild. A folder graph re-derives the whole graph with
|
|
3890
|
+
* fresh page ids on any file change, so ordering by page id let equal tasks swap places in the
|
|
3891
|
+
* [[Tasks View]] and on a [[Kanban Board]] (ADR 0113) between one refresh and the next.
|
|
3892
|
+
* `page_id, block_local_id` come last only to keep the order total, so paging cannot repeat or
|
|
3893
|
+
* skip.
|
|
3894
|
+
*/
|
|
3895
|
+
var TIE_BREAK_SQL = "p.concept_key ASC, t.line ASC, t.page_id ASC, t.block_local_id ASC";
|
|
3896
|
+
/**
|
|
3847
3897
|
* The WHERE clause and parameters shared by {@link tasksMatching} and its count, so the two
|
|
3848
3898
|
* can never drift into answering different questions about the same filter.
|
|
3849
3899
|
*
|
|
@@ -3912,7 +3962,7 @@ function tasksMatching(db, query, offset, limit) {
|
|
|
3912
3962
|
t.priority, t.waiting, t.doing, t.cancelled, t.due, t.completion, t.scheduled
|
|
3913
3963
|
FROM tasks t JOIN pages p ON p.id = t.page_id
|
|
3914
3964
|
WHERE ${predicate.where}
|
|
3915
|
-
ORDER BY ${ORDER_SQL[query.groupBy]},
|
|
3965
|
+
ORDER BY ${ORDER_SQL[query.groupBy]}, ${TIE_BREAK_SQL}
|
|
3916
3966
|
LIMIT ? OFFSET ?`, [
|
|
3917
3967
|
...predicate.params,
|
|
3918
3968
|
limit + 1,
|
|
@@ -3924,30 +3974,35 @@ function tasksMatching(db, query, offset, limit) {
|
|
|
3924
3974
|
hits: [],
|
|
3925
3975
|
hasMore: false
|
|
3926
3976
|
};
|
|
3927
|
-
const
|
|
3977
|
+
const ancestry = ancestryFor(db, page);
|
|
3928
3978
|
return {
|
|
3929
|
-
hits: page.map((row) =>
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3979
|
+
hits: page.map((row) => {
|
|
3980
|
+
const found = ancestry.get(row.page_id)?.get(row.block_local_id);
|
|
3981
|
+
return {
|
|
3982
|
+
...toHit(row),
|
|
3983
|
+
breadcrumb: found?.breadcrumb ?? [],
|
|
3984
|
+
parentTask: found?.parentTask ?? null
|
|
3985
|
+
};
|
|
3986
|
+
}),
|
|
3933
3987
|
hasMore
|
|
3934
3988
|
};
|
|
3935
3989
|
}
|
|
3936
3990
|
/**
|
|
3937
|
-
* Ancestor labels for a whole page of tasks
|
|
3938
|
-
* chains walked in JS. The same shape searchText uses, and for
|
|
3939
|
-
* SQL walk per row would be one query per task.
|
|
3991
|
+
* Ancestor labels for a whole page of tasks, and each task's nearest task ancestor: one read for
|
|
3992
|
+
* every document involved, then the chains walked in JS. The same shape searchText uses, and for
|
|
3993
|
+
* the same reason - a recursive SQL walk per row would be one query per task.
|
|
3940
3994
|
*/
|
|
3941
|
-
function
|
|
3995
|
+
function ancestryFor(db, rows) {
|
|
3942
3996
|
const ids = [...new Set(rows.map((row) => row.page_id))];
|
|
3943
3997
|
const placeholders = ids.map(() => "?").join(",");
|
|
3944
|
-
const blocks = db.all(`SELECT page_id, local_id, parent_local_id, label FROM blocks WHERE page_id IN (${placeholders})`, ids);
|
|
3998
|
+
const blocks = db.all(`SELECT page_id, local_id, parent_local_id, kind, label FROM blocks WHERE page_id IN (${placeholders})`, ids);
|
|
3945
3999
|
const byPage = /* @__PURE__ */ new Map();
|
|
3946
4000
|
for (const block of blocks) {
|
|
3947
4001
|
let page = byPage.get(block.page_id);
|
|
3948
4002
|
if (!page) byPage.set(block.page_id, page = /* @__PURE__ */ new Map());
|
|
3949
4003
|
page.set(block.local_id, {
|
|
3950
4004
|
parent_local_id: block.parent_local_id,
|
|
4005
|
+
kind: block.kind,
|
|
3951
4006
|
label: block.label
|
|
3952
4007
|
});
|
|
3953
4008
|
}
|
|
@@ -3957,10 +4012,24 @@ function breadcrumbsFor(db, rows) {
|
|
|
3957
4012
|
if (!page) continue;
|
|
3958
4013
|
let chains = out.get(row.page_id);
|
|
3959
4014
|
if (!chains) out.set(row.page_id, chains = /* @__PURE__ */ new Map());
|
|
3960
|
-
chains.set(row.block_local_id,
|
|
4015
|
+
chains.set(row.block_local_id, {
|
|
4016
|
+
breadcrumb: searchBreadcrumb(page, row.block_local_id),
|
|
4017
|
+
parentTask: nearestTask(page, row.block_local_id)
|
|
4018
|
+
});
|
|
3961
4019
|
}
|
|
3962
4020
|
return out;
|
|
3963
4021
|
}
|
|
4022
|
+
/** The label of the nearest ancestor of `localId` that is a task, or null. */
|
|
4023
|
+
function nearestTask(blocks, localId) {
|
|
4024
|
+
let parent = blocks.get(localId)?.parent_local_id ?? null;
|
|
4025
|
+
for (let guard = 0; parent !== null && guard <= blocks.size; guard++) {
|
|
4026
|
+
const block = blocks.get(parent);
|
|
4027
|
+
if (!block) break;
|
|
4028
|
+
if (block.kind === "task") return block.label;
|
|
4029
|
+
parent = block.parent_local_id;
|
|
4030
|
+
}
|
|
4031
|
+
return null;
|
|
4032
|
+
}
|
|
3964
4033
|
/** How many tasks match. Uncapped: this counts an indexed join, not a text index. */
|
|
3965
4034
|
function tasksMatchingCount(db, query) {
|
|
3966
4035
|
const predicate = taskPredicate(db, query);
|
|
@@ -4271,7 +4340,7 @@ function cacheRoot(env) {
|
|
|
4271
4340
|
* recovered from a joined path (see there).
|
|
4272
4341
|
*/
|
|
4273
4342
|
var CACHE_FILE_NAME = `local-cache.v${CACHE_DB_VERSION}.bin`;
|
|
4274
|
-
var INDEX_FILE_NAME = `index.
|
|
4343
|
+
var INDEX_FILE_NAME = `index.v15.sqlite`;
|
|
4275
4344
|
var VECTORS_FILE_NAME = `vectors.v1.sqlite`;
|
|
4276
4345
|
function cacheFile(dir) {
|
|
4277
4346
|
return join(dir, CACHE_FILE_NAME);
|
|
@@ -10779,6 +10848,27 @@ function dayIsNotAPageName(day) {
|
|
|
10779
10848
|
return `“${day.trim()}” is a date, and a date is the name of that day's journal entry, so a page cannot be called that. Choose a different name.`;
|
|
10780
10849
|
}
|
|
10781
10850
|
//#endregion
|
|
10851
|
+
//#region ../client/src/lib/document/line-endings.ts
|
|
10852
|
+
/** `text` with every `\r\n` and every lone `\r` written as `\n`; `text` itself when it holds no `\r`. */
|
|
10853
|
+
function lineFeedsOnly(text) {
|
|
10854
|
+
return text.includes("\r") ? text.replace(/\r\n?/g, "\n") : text;
|
|
10855
|
+
}
|
|
10856
|
+
/**
|
|
10857
|
+
* The changes that turn `text` into {@link lineFeedsOnly}`(text)`, in document order and each
|
|
10858
|
+
* against `text` as it is: one per `\r`, deleting it before a `\n` and replacing it with `\n`
|
|
10859
|
+
* anywhere else. For a writer that must change a shared text in place rather than replace it,
|
|
10860
|
+
* applying them from the last to the first.
|
|
10861
|
+
*/
|
|
10862
|
+
function lineFeedChanges(text) {
|
|
10863
|
+
const changes = [];
|
|
10864
|
+
for (let at = text.indexOf("\r"); at !== -1; at = text.indexOf("\r", at + 1)) changes.push({
|
|
10865
|
+
from: at,
|
|
10866
|
+
to: at + 1,
|
|
10867
|
+
insert: text[at + 1] === "\n" ? "" : "\n"
|
|
10868
|
+
});
|
|
10869
|
+
return changes;
|
|
10870
|
+
}
|
|
10871
|
+
//#endregion
|
|
10782
10872
|
//#region ../client/src/lib/document/wikilink/rename.ts
|
|
10783
10873
|
/**
|
|
10784
10874
|
* Rewriting [[Wikilink]] targets across a document's source, for the rewrite arm of a page
|
|
@@ -11421,8 +11511,9 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11421
11511
|
}
|
|
11422
11512
|
return entries;
|
|
11423
11513
|
}
|
|
11424
|
-
|
|
11425
|
-
|
|
11514
|
+
/** Tell an open document's subscribers its text, all but `except` (the editor that made the edit). */
|
|
11515
|
+
function notify(doc, text, except) {
|
|
11516
|
+
for (const listener of doc.listeners) if (listener !== except) listener(text);
|
|
11426
11517
|
}
|
|
11427
11518
|
/** The frontmatter block of `text`, given its parsed body (`''` when there is none). */
|
|
11428
11519
|
function headOf(text, body) {
|
|
@@ -11660,7 +11751,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11660
11751
|
return doc.target;
|
|
11661
11752
|
},
|
|
11662
11753
|
getText: () => doc.buffer,
|
|
11663
|
-
applyChange(change, origin = "editor") {
|
|
11754
|
+
applyChange(change, origin = "editor", editor) {
|
|
11664
11755
|
doc.buffer = applyTextChange(doc.buffer, change);
|
|
11665
11756
|
doc.dirty = true;
|
|
11666
11757
|
if (doc.removed) {
|
|
@@ -11669,6 +11760,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11669
11760
|
}
|
|
11670
11761
|
doc.save.call();
|
|
11671
11762
|
if (origin === "external") notify(doc, doc.buffer);
|
|
11763
|
+
else if (editor) notify(doc, doc.buffer, editor);
|
|
11672
11764
|
},
|
|
11673
11765
|
subscribe(listener) {
|
|
11674
11766
|
doc.listeners.add(listener);
|
|
@@ -11676,12 +11768,12 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11676
11768
|
}
|
|
11677
11769
|
};
|
|
11678
11770
|
doc.ready = adapter.read(doc.subdir, doc.fileName).then((content) => {
|
|
11679
|
-
doc.buffer = content.text;
|
|
11771
|
+
doc.buffer = lineFeedsOnly(content.text);
|
|
11680
11772
|
doc.baseText = content.text;
|
|
11681
11773
|
doc.lastModified = content.lastModified;
|
|
11682
11774
|
doc.size = content.size;
|
|
11683
11775
|
doc.loaded = true;
|
|
11684
|
-
notify(doc,
|
|
11776
|
+
notify(doc, doc.buffer);
|
|
11685
11777
|
}).catch(async (error) => {
|
|
11686
11778
|
if (await adapter.exists(doc.subdir, doc.fileName).catch(() => true)) doc.loadError = error;
|
|
11687
11779
|
else doc.loaded = true;
|
|
@@ -11699,7 +11791,14 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11699
11791
|
return;
|
|
11700
11792
|
}
|
|
11701
11793
|
if (doc.loaded && entry.lastModified === doc.lastModified && entry.size === doc.size) return;
|
|
11702
|
-
|
|
11794
|
+
followDisk(doc, await adapter.read(doc.subdir, doc.fileName));
|
|
11795
|
+
}
|
|
11796
|
+
/**
|
|
11797
|
+
* Bring an open document in line with its file as just read (`reconcileDecision`): nothing
|
|
11798
|
+
* but the stamps when the file is what the buffer last synced with, a reload of a clean buffer,
|
|
11799
|
+
* a conflict over a dirty one.
|
|
11800
|
+
*/
|
|
11801
|
+
function followDisk(doc, { text: diskText, lastModified, size }) {
|
|
11703
11802
|
const decision = reconcileDecision({
|
|
11704
11803
|
dirty: doc.dirty,
|
|
11705
11804
|
baseText: doc.baseText,
|
|
@@ -11713,14 +11812,14 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11713
11812
|
return;
|
|
11714
11813
|
}
|
|
11715
11814
|
if (decision === "reload") {
|
|
11716
|
-
doc.buffer = diskText;
|
|
11815
|
+
doc.buffer = lineFeedsOnly(diskText);
|
|
11717
11816
|
doc.baseText = diskText;
|
|
11718
11817
|
doc.lastModified = lastModified;
|
|
11719
11818
|
doc.size = size;
|
|
11720
11819
|
doc.loaded = true;
|
|
11721
11820
|
doc.loadError = null;
|
|
11722
11821
|
doc.dirty = false;
|
|
11723
|
-
notify(doc,
|
|
11822
|
+
notify(doc, doc.buffer);
|
|
11724
11823
|
emitChange({ concept: conceptOf(doc) });
|
|
11725
11824
|
return;
|
|
11726
11825
|
}
|
|
@@ -11732,6 +11831,53 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11732
11831
|
doc.save.cancel();
|
|
11733
11832
|
onConflict?.(doc.conflict);
|
|
11734
11833
|
}
|
|
11834
|
+
/** A file's content, or null when it does not exist; any other failure rejects. */
|
|
11835
|
+
async function readIfPresent(subdir, fileName) {
|
|
11836
|
+
try {
|
|
11837
|
+
return await adapter.read(subdir, fileName);
|
|
11838
|
+
} catch (error) {
|
|
11839
|
+
if (await adapter.exists(subdir, fileName).catch(() => true)) throw error;
|
|
11840
|
+
return null;
|
|
11841
|
+
}
|
|
11842
|
+
}
|
|
11843
|
+
/**
|
|
11844
|
+
* A Formatting Scan's fix to an open document (ADR 0109): through the buffer, so the editor
|
|
11845
|
+
* showing it is told, then saved now. The file is read first: an edit made outside the app
|
|
11846
|
+
* since the buffer last synced is followed, a reload or a conflict over unsaved typing, and
|
|
11847
|
+
* the fix is refused rather than saved over it.
|
|
11848
|
+
*/
|
|
11849
|
+
async function spliceOpenDocument(doc, expected, splices) {
|
|
11850
|
+
await doc.ready;
|
|
11851
|
+
await settled(doc);
|
|
11852
|
+
if (doc.removed) return "gone";
|
|
11853
|
+
if (doc.conflict) return "changed";
|
|
11854
|
+
const disk = await readIfPresent(doc.subdir, doc.fileName);
|
|
11855
|
+
if (disk === null || doc.removed) return "gone";
|
|
11856
|
+
if (doc.saving || doc.conflict) return "changed";
|
|
11857
|
+
followDisk(doc, disk);
|
|
11858
|
+
if (doc.conflict || !doc.loaded) return "changed";
|
|
11859
|
+
if (containsCipherFence(doc.buffer)) return "protected";
|
|
11860
|
+
if (doc.buffer !== expected) return "changed";
|
|
11861
|
+
for (let i = splices.length - 1; i >= 0; i--) doc.handle.applyChange(splices[i], "external");
|
|
11862
|
+
await saveNow(doc);
|
|
11863
|
+
return "written";
|
|
11864
|
+
}
|
|
11865
|
+
/** A Formatting Scan's fix to a document nobody has open: read, compare, write (ADR 0109). */
|
|
11866
|
+
async function spliceClosedDocument(entry, expected, splices) {
|
|
11867
|
+
const read = await readIfPresent(entry.subdir, entry.fileName);
|
|
11868
|
+
if (read === null) return "gone";
|
|
11869
|
+
const opened = open.get(entry.key);
|
|
11870
|
+
if (opened) return spliceOpenDocument(opened, expected, splices);
|
|
11871
|
+
if (containsCipherFence(read.text)) return "protected";
|
|
11872
|
+
if (read.text !== expected) return "changed";
|
|
11873
|
+
let text = read.text;
|
|
11874
|
+
for (let i = splices.length - 1; i >= 0; i--) text = applyTextChange(text, splices[i]);
|
|
11875
|
+
const written = await adapter.write(entry.subdir, entry.fileName, text);
|
|
11876
|
+
entry.lastModified = written.lastModified;
|
|
11877
|
+
entry.size = written.size;
|
|
11878
|
+
emitChange({ concept: entry.concept });
|
|
11879
|
+
return "written";
|
|
11880
|
+
}
|
|
11735
11881
|
return {
|
|
11736
11882
|
open(target) {
|
|
11737
11883
|
return openDocNamed(target).handle;
|
|
@@ -11786,6 +11932,22 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11786
11932
|
emitRegistryOnly();
|
|
11787
11933
|
}
|
|
11788
11934
|
},
|
|
11935
|
+
async documentText(target) {
|
|
11936
|
+
const entry = registry.get(conceptKey(target));
|
|
11937
|
+
if (!entry) return null;
|
|
11938
|
+
const doc = open.get(entry.key);
|
|
11939
|
+
if (doc) {
|
|
11940
|
+
await doc.ready;
|
|
11941
|
+
if (doc.loaded) return doc.buffer;
|
|
11942
|
+
}
|
|
11943
|
+
return (await readIfPresent(entry.subdir, entry.fileName))?.text ?? null;
|
|
11944
|
+
},
|
|
11945
|
+
async spliceIfUnchanged(target, expected, splices) {
|
|
11946
|
+
const entry = registry.get(conceptKey(target));
|
|
11947
|
+
if (!entry) return "gone";
|
|
11948
|
+
const doc = open.get(entry.key);
|
|
11949
|
+
return doc ? spliceOpenDocument(doc, expected, splices) : spliceClosedDocument(entry, expected, splices);
|
|
11950
|
+
},
|
|
11789
11951
|
onChange(listener) {
|
|
11790
11952
|
changed.add(listener);
|
|
11791
11953
|
return () => changed.delete(listener);
|
|
@@ -11841,13 +12003,13 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11841
12003
|
doc.loadError = null;
|
|
11842
12004
|
if (choice === "take-disk") {
|
|
11843
12005
|
const { text, lastModified, size } = await adapter.read(doc.subdir, doc.fileName);
|
|
11844
|
-
doc.buffer = text;
|
|
12006
|
+
doc.buffer = lineFeedsOnly(text);
|
|
11845
12007
|
doc.baseText = text;
|
|
11846
12008
|
doc.lastModified = lastModified;
|
|
11847
12009
|
doc.size = size;
|
|
11848
12010
|
doc.dirty = false;
|
|
11849
12011
|
doc.conflict = null;
|
|
11850
|
-
notify(doc,
|
|
12012
|
+
notify(doc, doc.buffer);
|
|
11851
12013
|
} else {
|
|
11852
12014
|
const res = await adapter.write(doc.subdir, doc.fileName, doc.buffer);
|
|
11853
12015
|
doc.baseText = doc.buffer;
|
|
@@ -12479,7 +12641,17 @@ function createServerDocumentStore(graph, options) {
|
|
|
12479
12641
|
},
|
|
12480
12642
|
getYText(target) {
|
|
12481
12643
|
const docId = docIdFor(target);
|
|
12482
|
-
|
|
12644
|
+
if (!docId) return void 0;
|
|
12645
|
+
const ytext = graph.docSync(docId).doc.getText("content");
|
|
12646
|
+
const changes = lineFeedChanges(ytext.toString());
|
|
12647
|
+
if (changes.length > 0) ytext.doc.transact(() => {
|
|
12648
|
+
for (let i = changes.length - 1; i >= 0; i--) {
|
|
12649
|
+
const change = changes[i];
|
|
12650
|
+
ytext.delete(change.from, change.to - change.from);
|
|
12651
|
+
if (change.insert) ytext.insert(change.from, change.insert);
|
|
12652
|
+
}
|
|
12653
|
+
}, STORE);
|
|
12654
|
+
return ytext;
|
|
12483
12655
|
},
|
|
12484
12656
|
async whenReady(target) {
|
|
12485
12657
|
const docId = docIdFor(target);
|
|
@@ -12781,6 +12953,28 @@ function createServerDocumentStore(graph, options) {
|
|
|
12781
12953
|
registry.set(docId, updated);
|
|
12782
12954
|
writeBackIdentity(docId);
|
|
12783
12955
|
},
|
|
12956
|
+
async spliceIfUnchanged(docId, expected, splices, spliceOptions) {
|
|
12957
|
+
if (!registry.has(docId)) return "gone";
|
|
12958
|
+
const materialised = await materialise([docId], spliceOptions?.timeoutMs ?? COLD_CONTENT_TIMEOUT_MS);
|
|
12959
|
+
try {
|
|
12960
|
+
if (!registry.has(docId)) return "gone";
|
|
12961
|
+
if (materialised.unconfirmed.length > 0) return "unconfirmed";
|
|
12962
|
+
const ytext = graph.docSync(docId).doc.getText("content");
|
|
12963
|
+
const text = ytext.toString();
|
|
12964
|
+
if (containsCipherFence(text)) return "protected";
|
|
12965
|
+
if (text !== expected) return "changed";
|
|
12966
|
+
ytext.doc.transact(() => {
|
|
12967
|
+
for (let i = splices.length - 1; i >= 0; i--) {
|
|
12968
|
+
const splice = splices[i];
|
|
12969
|
+
if (splice.to > splice.from) ytext.delete(splice.from, splice.to - splice.from);
|
|
12970
|
+
if (splice.insert) ytext.insert(splice.from, splice.insert);
|
|
12971
|
+
}
|
|
12972
|
+
}, STORE);
|
|
12973
|
+
return "written";
|
|
12974
|
+
} finally {
|
|
12975
|
+
materialised.release();
|
|
12976
|
+
}
|
|
12977
|
+
},
|
|
12784
12978
|
async reconcile() {},
|
|
12785
12979
|
async resolveConflict() {},
|
|
12786
12980
|
async createJournal(date, body = "") {
|
|
@@ -13507,6 +13701,7 @@ function assembleHeadlessGraph(parts) {
|
|
|
13507
13701
|
};
|
|
13508
13702
|
return {
|
|
13509
13703
|
graphId: parts.graphId,
|
|
13704
|
+
clientUrl: parts.clientUrl ?? null,
|
|
13510
13705
|
name: parts.name,
|
|
13511
13706
|
store: parts.store,
|
|
13512
13707
|
index,
|
|
@@ -13634,6 +13829,7 @@ async function openHeadlessGraph(deps) {
|
|
|
13634
13829
|
}
|
|
13635
13830
|
return assembleHeadlessGraph({
|
|
13636
13831
|
graphId: deps.graphId,
|
|
13832
|
+
clientUrl: deps.clientUrl ?? null,
|
|
13637
13833
|
name: sync.getMeta().name ?? deps.graphId,
|
|
13638
13834
|
accessLoss: () => sync.accessLoss(),
|
|
13639
13835
|
store: documents,
|
|
@@ -17167,6 +17363,243 @@ async function writeDownload(directory, name, bytes) {
|
|
|
17167
17363
|
}
|
|
17168
17364
|
throw new Error(`${directory} already holds a thousand files named like ${safe}.`);
|
|
17169
17365
|
}
|
|
17366
|
+
//#endregion
|
|
17367
|
+
//#region ../client/src/lib/navigation/document-url.ts
|
|
17368
|
+
/** Encode a concept for the `[...concept]` rest param: per-segment, `/` kept. */
|
|
17369
|
+
function encodeConceptPath(concept) {
|
|
17370
|
+
return concept.split("/").map(encodeURIComponent).join("/");
|
|
17371
|
+
}
|
|
17372
|
+
/** The Document URL for a concept within a graph. */
|
|
17373
|
+
function documentUrl(graphId, concept) {
|
|
17374
|
+
return `${graphUrl(graphId)}/d/${encodeConceptPath(concept)}`;
|
|
17375
|
+
}
|
|
17376
|
+
/** The bare workspace URL for a graph. */
|
|
17377
|
+
function graphUrl(graphId) {
|
|
17378
|
+
return `/g/${encodeURIComponent(graphId)}`;
|
|
17379
|
+
}
|
|
17380
|
+
/**
|
|
17381
|
+
* The words that identify a task while its tags change: the text after the checkbox and the tag
|
|
17382
|
+
* run, whitespace collapsed and Unicode NFC applied. A task written as tags alone is its tags.
|
|
17383
|
+
*/
|
|
17384
|
+
function taskWords(label) {
|
|
17385
|
+
return (parseTaskTags(label).text || label).normalize("NFC").replace(/\s+/g, " ").trim();
|
|
17386
|
+
}
|
|
17387
|
+
/** The fingerprint of a task's words, from its label (tag run included or not). */
|
|
17388
|
+
async function taskFingerprint(label) {
|
|
17389
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(taskWords(label)));
|
|
17390
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 12);
|
|
17391
|
+
}
|
|
17392
|
+
/** The reference's address: the Document URL with the task in its fragment, which a browser never sends. */
|
|
17393
|
+
function taskReferenceUrl(reference, origin = "") {
|
|
17394
|
+
return `${origin}${documentUrl(reference.graphId, reference.document)}#task=${reference.line}-${reference.fingerprint}`;
|
|
17395
|
+
}
|
|
17396
|
+
var REFERENCE = /\/g\/([^/?#\s]+)\/d\/([^?#\s]+?)(?:\?[^#\s]*)?#task=(\d+)-([0-9a-f]{12})(?![0-9a-f])/;
|
|
17397
|
+
/**
|
|
17398
|
+
* The reference in `input`: the two lines as copied, the address alone, with any origin or none,
|
|
17399
|
+
* or null when `input` holds none.
|
|
17400
|
+
*/
|
|
17401
|
+
function parseTaskReference(input) {
|
|
17402
|
+
const match = REFERENCE.exec(input);
|
|
17403
|
+
if (!match) return null;
|
|
17404
|
+
const [, graph, path, line, fingerprint] = match;
|
|
17405
|
+
try {
|
|
17406
|
+
return {
|
|
17407
|
+
graphId: decodeURIComponent(graph),
|
|
17408
|
+
document: path.split("/").map(decodeURIComponent).join("/"),
|
|
17409
|
+
line: Number(line),
|
|
17410
|
+
fingerprint
|
|
17411
|
+
};
|
|
17412
|
+
} catch {
|
|
17413
|
+
return null;
|
|
17414
|
+
}
|
|
17415
|
+
}
|
|
17416
|
+
/**
|
|
17417
|
+
* Find the task a reference names. First the named document's line, when the words there match;
|
|
17418
|
+
* then the one task in that document with those words; then the one task in the graph. None is
|
|
17419
|
+
* `task_not_found`, more than one `task_ambiguous`, since acting on a guess could change the
|
|
17420
|
+
* wrong task. A document that no longer answers to its name goes straight to the graph.
|
|
17421
|
+
*/
|
|
17422
|
+
async function resolveTaskReference(reference, sources) {
|
|
17423
|
+
if (sources.graphId !== null && sources.graphId !== reference.graphId) return {
|
|
17424
|
+
ok: false,
|
|
17425
|
+
code: "other_graph",
|
|
17426
|
+
servedGraphId: sources.graphId
|
|
17427
|
+
};
|
|
17428
|
+
const named = sources.resolveDocument(reference.document);
|
|
17429
|
+
if (named !== null) {
|
|
17430
|
+
const body = await sources.readBody(named);
|
|
17431
|
+
if (body !== null) {
|
|
17432
|
+
const matches = await taskLinesWith(body.split("\n"), reference.fingerprint);
|
|
17433
|
+
if (matches.includes(reference.line)) return {
|
|
17434
|
+
ok: true,
|
|
17435
|
+
document: named,
|
|
17436
|
+
line: reference.line,
|
|
17437
|
+
body,
|
|
17438
|
+
foundBy: "at_line"
|
|
17439
|
+
};
|
|
17440
|
+
if (matches.length === 1) return {
|
|
17441
|
+
ok: true,
|
|
17442
|
+
document: named,
|
|
17443
|
+
line: matches[0],
|
|
17444
|
+
body,
|
|
17445
|
+
foundBy: "moved"
|
|
17446
|
+
};
|
|
17447
|
+
if (matches.length > 1) return {
|
|
17448
|
+
ok: false,
|
|
17449
|
+
code: "task_ambiguous",
|
|
17450
|
+
candidates: matches.map((line) => ({
|
|
17451
|
+
document: named,
|
|
17452
|
+
line
|
|
17453
|
+
}))
|
|
17454
|
+
};
|
|
17455
|
+
}
|
|
17456
|
+
}
|
|
17457
|
+
const found = [];
|
|
17458
|
+
const looked = new Set(named === null ? [] : [named.toLowerCase()]);
|
|
17459
|
+
for (const task of await sources.allTasks()) {
|
|
17460
|
+
if (looked.has(task.document.toLowerCase())) continue;
|
|
17461
|
+
if (await taskFingerprint(task.label) !== reference.fingerprint) continue;
|
|
17462
|
+
looked.add(task.document.toLowerCase());
|
|
17463
|
+
const body = await sources.readBody(task.document);
|
|
17464
|
+
if (body === null) continue;
|
|
17465
|
+
for (const line of await taskLinesWith(body.split("\n"), reference.fingerprint)) found.push({
|
|
17466
|
+
document: task.document,
|
|
17467
|
+
line,
|
|
17468
|
+
body
|
|
17469
|
+
});
|
|
17470
|
+
}
|
|
17471
|
+
if (found.length === 1) return {
|
|
17472
|
+
ok: true,
|
|
17473
|
+
...found[0],
|
|
17474
|
+
foundBy: "other_document"
|
|
17475
|
+
};
|
|
17476
|
+
if (found.length > 1) return {
|
|
17477
|
+
ok: false,
|
|
17478
|
+
code: "task_ambiguous",
|
|
17479
|
+
candidates: found.map(({ document, line }) => ({
|
|
17480
|
+
document,
|
|
17481
|
+
line
|
|
17482
|
+
}))
|
|
17483
|
+
};
|
|
17484
|
+
return {
|
|
17485
|
+
ok: false,
|
|
17486
|
+
code: "task_not_found"
|
|
17487
|
+
};
|
|
17488
|
+
}
|
|
17489
|
+
/** How many tasks `everyIndexedTask` asks the index for at a time. */
|
|
17490
|
+
var INDEX_PAGE = 500;
|
|
17491
|
+
/** Every task the index knows, in pages, as {@link TaskReferenceSources.allTasks} wants them. */
|
|
17492
|
+
async function everyIndexedTask(index, today) {
|
|
17493
|
+
const query = {
|
|
17494
|
+
concept: null,
|
|
17495
|
+
statuses: TASK_STATUSES,
|
|
17496
|
+
priorities: TASK_PRIORITY_FILTERS,
|
|
17497
|
+
due: "any",
|
|
17498
|
+
groupBy: "document",
|
|
17499
|
+
today
|
|
17500
|
+
};
|
|
17501
|
+
const out = [];
|
|
17502
|
+
for (let offset = 0;; offset += INDEX_PAGE) {
|
|
17503
|
+
const page = await index.tasks(query, offset, INDEX_PAGE);
|
|
17504
|
+
for (const hit of page.hits) out.push({
|
|
17505
|
+
document: hit.concept,
|
|
17506
|
+
line: hit.line,
|
|
17507
|
+
label: hit.text
|
|
17508
|
+
});
|
|
17509
|
+
if (!page.hasMore) return out;
|
|
17510
|
+
}
|
|
17511
|
+
}
|
|
17512
|
+
/** The lines of `lines` that hold a task with this fingerprint, never one inside a code fence. */
|
|
17513
|
+
async function taskLinesWith(lines, fingerprint) {
|
|
17514
|
+
const inFence = /* @__PURE__ */ new Set();
|
|
17515
|
+
for (const block of fencedBlocks(lines)) for (let i = block.start; i <= block.end; i++) inFence.add(i);
|
|
17516
|
+
const out = [];
|
|
17517
|
+
for (let i = 0; i < lines.length; i++) {
|
|
17518
|
+
if (inFence.has(i) || parseTaskLine(lines[i]) === null) continue;
|
|
17519
|
+
if (await taskFingerprint(bulletLabel(lines[i])) === fingerprint) out.push(i);
|
|
17520
|
+
}
|
|
17521
|
+
return out;
|
|
17522
|
+
}
|
|
17523
|
+
//#endregion
|
|
17524
|
+
//#region ../client/src/lib/document/view/minimal-replacement.ts
|
|
17525
|
+
/**
|
|
17526
|
+
* The smallest single replacement turning `text` into `next`: the shared prefix and suffix
|
|
17527
|
+
* trimmed away. Null when the two are equal.
|
|
17528
|
+
*
|
|
17529
|
+
* Used wherever a whole new text arrives for a document that is being edited - a heal, an
|
|
17530
|
+
* external write reaching an open editor - so that the change is the change and nothing more
|
|
17531
|
+
* (ADR 0066): on a synced graph a whole-document replace is one enormous CRDT operation that
|
|
17532
|
+
* clobbers everyone else's concurrent edits, and in any editor it throws the caret to the end.
|
|
17533
|
+
*/
|
|
17534
|
+
function minimalReplacement(text, next) {
|
|
17535
|
+
if (next === text) return null;
|
|
17536
|
+
let prefix = 0;
|
|
17537
|
+
while (prefix < text.length && prefix < next.length && text[prefix] === next[prefix]) prefix++;
|
|
17538
|
+
let suffix = 0;
|
|
17539
|
+
while (suffix < text.length - prefix && suffix < next.length - prefix && text[text.length - 1 - suffix] === next[next.length - 1 - suffix]) suffix++;
|
|
17540
|
+
return {
|
|
17541
|
+
from: prefix,
|
|
17542
|
+
to: text.length - suffix,
|
|
17543
|
+
insert: next.slice(prefix, next.length - suffix)
|
|
17544
|
+
};
|
|
17545
|
+
}
|
|
17546
|
+
//#endregion
|
|
17547
|
+
//#region ../client/src/lib/document/task-write.ts
|
|
17548
|
+
var MARKER = /^(\s*-\s+\[)[ xX](\])\s?/;
|
|
17549
|
+
/**
|
|
17550
|
+
* `line` with `changes` made, or null when `line` is not a task.
|
|
17551
|
+
*
|
|
17552
|
+
* A changed tag run is written in the fixed order the `#` helper writes (`serialiseTags`), for
|
|
17553
|
+
* the reason `applyTaskTag` gives: the run is a set, and a predictable order is worth more than
|
|
17554
|
+
* one that carries no meaning. A task already as asked comes back untouched, so a move that
|
|
17555
|
+
* changes nothing reorders nothing either.
|
|
17556
|
+
*/
|
|
17557
|
+
function taskLineWith(line, changes) {
|
|
17558
|
+
const parsed = parseTaskLine(line);
|
|
17559
|
+
const marker = MARKER.exec(line);
|
|
17560
|
+
if (!parsed || !marker) return null;
|
|
17561
|
+
const { done: wasDone, ...current } = parsed;
|
|
17562
|
+
const tags = { ...current };
|
|
17563
|
+
let done = wasDone;
|
|
17564
|
+
switch (changes.status) {
|
|
17565
|
+
case "open":
|
|
17566
|
+
done = false;
|
|
17567
|
+
tags.waiting = tags.doing = tags.cancelled = false;
|
|
17568
|
+
break;
|
|
17569
|
+
case "doing":
|
|
17570
|
+
done = false;
|
|
17571
|
+
tags.doing = true;
|
|
17572
|
+
tags.waiting = tags.cancelled = false;
|
|
17573
|
+
break;
|
|
17574
|
+
case "waiting":
|
|
17575
|
+
done = false;
|
|
17576
|
+
tags.waiting = true;
|
|
17577
|
+
tags.doing = tags.cancelled = false;
|
|
17578
|
+
break;
|
|
17579
|
+
case "done":
|
|
17580
|
+
done = true;
|
|
17581
|
+
tags.waiting = tags.doing = tags.cancelled = false;
|
|
17582
|
+
break;
|
|
17583
|
+
case "cancelled":
|
|
17584
|
+
done = true;
|
|
17585
|
+
tags.cancelled = true;
|
|
17586
|
+
tags.waiting = tags.doing = false;
|
|
17587
|
+
break;
|
|
17588
|
+
case void 0: break;
|
|
17589
|
+
}
|
|
17590
|
+
if (changes.priority !== void 0) tags.priority = changes.priority;
|
|
17591
|
+
if (changes.due !== void 0) tags.due = changes.due;
|
|
17592
|
+
if (changes.scheduled !== void 0) tags.scheduled = changes.scheduled;
|
|
17593
|
+
if (done === wasDone && sameTags(tags, current)) return line;
|
|
17594
|
+
return [
|
|
17595
|
+
`${marker[1]}${done ? "x" : " "}${marker[2]}`,
|
|
17596
|
+
...serialiseTags(tags),
|
|
17597
|
+
tags.text
|
|
17598
|
+
].filter((part) => part !== "").join(" ");
|
|
17599
|
+
}
|
|
17600
|
+
function sameTags(a, b) {
|
|
17601
|
+
return a.priority === b.priority && a.waiting === b.waiting && a.doing === b.doing && a.cancelled === b.cancelled && a.due === b.due && a.scheduled === b.scheduled && a.completion === b.completion;
|
|
17602
|
+
}
|
|
17170
17603
|
/** UTF-16 units of document text returned before `truncated` is set. */
|
|
17171
17604
|
var READ_TEXT_CAP = 2e5;
|
|
17172
17605
|
/** A refusal the agent can act on: a stable code first, a sentence second. */
|
|
@@ -17445,7 +17878,7 @@ async function tasks(graph, args = {}) {
|
|
|
17445
17878
|
today: todayISO()
|
|
17446
17879
|
}, offset, limit);
|
|
17447
17880
|
return {
|
|
17448
|
-
tasks: result.hits.map((hit) => ({
|
|
17881
|
+
tasks: await Promise.all(result.hits.map(async (hit) => ({
|
|
17449
17882
|
concept: hit.concept,
|
|
17450
17883
|
kind: hit.kind,
|
|
17451
17884
|
line: hit.line,
|
|
@@ -17458,12 +17891,20 @@ async function tasks(graph, args = {}) {
|
|
|
17458
17891
|
due: hit.due,
|
|
17459
17892
|
scheduled: hit.scheduled,
|
|
17460
17893
|
completion: hit.completion,
|
|
17461
|
-
breadcrumb: hit.breadcrumb
|
|
17462
|
-
|
|
17894
|
+
breadcrumb: hit.breadcrumb,
|
|
17895
|
+
reference: await referenceFor(graph, hit.concept, hit.line, hit.text)
|
|
17896
|
+
}))),
|
|
17463
17897
|
total: result.total,
|
|
17464
17898
|
hasMore: result.hasMore
|
|
17465
17899
|
};
|
|
17466
17900
|
}
|
|
17901
|
+
/**
|
|
17902
|
+
* Text an agent sent, in the form EtherPK writes: line feeds only (ADR 0112), then the
|
|
17903
|
+
* two-space Indent Unit (ADR 0067). Line endings go first so the normaliser reads whole lines.
|
|
17904
|
+
*/
|
|
17905
|
+
function asWritten(text) {
|
|
17906
|
+
return normaliseIndentUnit(lineFeedsOnly(text));
|
|
17907
|
+
}
|
|
17467
17908
|
async function editDocument(graph, args) {
|
|
17468
17909
|
if (args.old === "") throw new ToolError("invalid_argument", "old must not be empty.");
|
|
17469
17910
|
await graph.store.refresh();
|
|
@@ -17473,7 +17914,7 @@ async function editDocument(graph, args) {
|
|
|
17473
17914
|
const first = text.indexOf(args.old);
|
|
17474
17915
|
if (first === -1) throw new ToolError("no_match", `"${identity.concept}" does not contain the text to replace. Read the document again - it may have changed.`);
|
|
17475
17916
|
if (text.indexOf(args.old, first + 1) !== -1) throw new ToolError("ambiguous_match", `The text to replace occurs more than once in "${identity.concept}". Include more surrounding text so it matches exactly once.`);
|
|
17476
|
-
const insert =
|
|
17917
|
+
const insert = asWritten(args.new);
|
|
17477
17918
|
graph.store.open(identity.concept).applyChange({
|
|
17478
17919
|
from: first,
|
|
17479
17920
|
to: first + args.old.length,
|
|
@@ -17487,7 +17928,7 @@ async function editDocument(graph, args) {
|
|
|
17487
17928
|
};
|
|
17488
17929
|
}
|
|
17489
17930
|
async function appendDocument(graph, args) {
|
|
17490
|
-
const text =
|
|
17931
|
+
const text = asWritten(args.text);
|
|
17491
17932
|
if (text.trim() === "") throw new ToolError("invalid_argument", "text must not be empty.");
|
|
17492
17933
|
const target = args.concept.trim() === "today" ? todayISO() : args.concept.trim();
|
|
17493
17934
|
await graph.store.refresh();
|
|
@@ -17521,7 +17962,7 @@ async function createPage(graph, args) {
|
|
|
17521
17962
|
if (isJournalConcept(title)) throw new ToolError("invalid_argument", `"${title}" is a calendar day, so it names a journal entry - use append_document to write to it.`);
|
|
17522
17963
|
await graph.store.refresh();
|
|
17523
17964
|
if (resolveIdentity(graph, title)) throw new ToolError("already_exists", `A document already answers to "${title}". Use edit_document or append_document instead.`);
|
|
17524
|
-
await graph.store.createPage(title,
|
|
17965
|
+
await graph.store.createPage(title, asWritten(args.text ?? ""));
|
|
17525
17966
|
if (args.frontmatter && Object.keys(args.frontmatter).length > 0) {
|
|
17526
17967
|
await graph.store.whenReady(title);
|
|
17527
17968
|
const raw = graph.store.openRaw(title).getText();
|
|
@@ -17885,22 +18326,25 @@ async function readDocuments(graph, args) {
|
|
|
17885
18326
|
}
|
|
17886
18327
|
return { documents };
|
|
17887
18328
|
}
|
|
17888
|
-
/** The task line rebuilt with these tags and checkbox: indent and marker kept, the tag run in the fixed order. */
|
|
17889
|
-
function taskLineWith(line, done, tags) {
|
|
17890
|
-
const marker = /^(\s*-\s+\[)[ xX](\])\s?/.exec(line);
|
|
17891
|
-
if (!marker) throw new Error("not a task line");
|
|
17892
|
-
const run = serialiseTags(tags);
|
|
17893
|
-
return `${marker[1]}${done ? "x" : " "}${marker[2]} ${[...run, tags.text].join(" ")}`.replace(/\s+$/, "");
|
|
17894
|
-
}
|
|
17895
18329
|
/**
|
|
17896
18330
|
* Change one task's status, priority or dates, by document and line (ADR 0032's grammar).
|
|
17897
|
-
*
|
|
17898
|
-
*
|
|
17899
|
-
*
|
|
17900
|
-
* the
|
|
18331
|
+
* The line is rewritten by the writer a Kanban Board move uses (`task-write.ts`), so a status
|
|
18332
|
+
* here means exactly what a lane means: exclusive, `waiting` and `doing` unchecked states, `done`
|
|
18333
|
+
* and `cancelled` checked ones, `open` none of them, and no completion date written. `expect` is
|
|
18334
|
+
* the stale-line guard, made explicit for a caller working from a list.
|
|
17901
18335
|
*/
|
|
17902
18336
|
async function setTask(graph, args) {
|
|
17903
|
-
if (
|
|
18337
|
+
if (args.reference !== void 0) {
|
|
18338
|
+
const found = await requireReferencedTask(graph, args.reference);
|
|
18339
|
+
return setTask(graph, {
|
|
18340
|
+
concept: found.document,
|
|
18341
|
+
line: found.line,
|
|
18342
|
+
changes: args.changes
|
|
18343
|
+
});
|
|
18344
|
+
}
|
|
18345
|
+
if (args.concept === void 0) throw new ToolError("invalid_argument", "Name the task by reference, or by concept and line.");
|
|
18346
|
+
if (args.line === void 0 || !Number.isInteger(args.line) || args.line < 0) throw new ToolError("invalid_argument", "line must be a non-negative integer.");
|
|
18347
|
+
const line = args.line;
|
|
17904
18348
|
const changes = args.changes ?? {};
|
|
17905
18349
|
if (changes.status !== void 0 && !TASK_STATUSES.includes(changes.status)) throw new ToolError("invalid_argument", `Unknown task status "${String(changes.status)}".`);
|
|
17906
18350
|
if (changes.priority !== void 0 && changes.priority !== null && ![
|
|
@@ -17914,49 +18358,19 @@ async function setTask(graph, args) {
|
|
|
17914
18358
|
const text = await liveText(graph, identity);
|
|
17915
18359
|
refuseIfProtected(identity.concept, text);
|
|
17916
18360
|
const lines = text.split("\n");
|
|
17917
|
-
const current = lines[
|
|
18361
|
+
const current = lines[line];
|
|
17918
18362
|
const parsed = current === void 0 ? null : parseTaskLine(current);
|
|
17919
|
-
if (!parsed) throw new ToolError("task_moved", `Line ${
|
|
18363
|
+
if (!parsed) throw new ToolError("task_moved", `Line ${line} of "${identity.concept}" is not a task now - read the document or list tasks again.`);
|
|
17920
18364
|
const afterCheckbox = current.replace(/^\s*-\s+\[[ xX]\]\s?/, "").trim();
|
|
17921
|
-
if (args.expect !== void 0 && args.expect.trim() !== afterCheckbox && args.expect.trim() !== parsed.text.trim()) throw new ToolError("task_moved", `Line ${
|
|
17922
|
-
const
|
|
17923
|
-
|
|
17924
|
-
|
|
17925
|
-
|
|
17926
|
-
done = false;
|
|
17927
|
-
tags.waiting = tags.doing = tags.cancelled = false;
|
|
17928
|
-
break;
|
|
17929
|
-
case "doing":
|
|
17930
|
-
done = false;
|
|
17931
|
-
tags.doing = true;
|
|
17932
|
-
tags.waiting = tags.cancelled = false;
|
|
17933
|
-
break;
|
|
17934
|
-
case "waiting":
|
|
17935
|
-
done = false;
|
|
17936
|
-
tags.waiting = true;
|
|
17937
|
-
tags.doing = tags.cancelled = false;
|
|
17938
|
-
break;
|
|
17939
|
-
case "done":
|
|
17940
|
-
done = true;
|
|
17941
|
-
tags.waiting = tags.doing = tags.cancelled = false;
|
|
17942
|
-
break;
|
|
17943
|
-
case "cancelled":
|
|
17944
|
-
done = true;
|
|
17945
|
-
tags.cancelled = true;
|
|
17946
|
-
tags.waiting = tags.doing = false;
|
|
17947
|
-
break;
|
|
17948
|
-
case void 0: break;
|
|
17949
|
-
}
|
|
17950
|
-
if (changes.priority !== void 0) tags.priority = changes.priority;
|
|
17951
|
-
if (changes.due !== void 0) tags.due = changes.due;
|
|
17952
|
-
if (changes.scheduled !== void 0) tags.scheduled = changes.scheduled;
|
|
17953
|
-
const next = taskLineWith(current, done, tags);
|
|
17954
|
-
if (next !== current) {
|
|
17955
|
-
const from = lines.slice(0, args.line).reduce((n, l) => n + l.length + 1, 0);
|
|
18365
|
+
if (args.expect !== void 0 && args.expect.trim() !== afterCheckbox && args.expect.trim() !== parsed.text.trim()) throw new ToolError("task_moved", `Line ${line} of "${identity.concept}" now reads "${parsed.text}", not "${args.expect}" - list tasks again before changing it.`);
|
|
18366
|
+
const next = taskLineWith(current, changes) ?? current;
|
|
18367
|
+
const edit = minimalReplacement(current, next);
|
|
18368
|
+
if (edit) {
|
|
18369
|
+
const from = lines.slice(0, line).reduce((n, l) => n + l.length + 1, 0);
|
|
17956
18370
|
graph.store.open(identity.concept).applyChange({
|
|
17957
|
-
from,
|
|
17958
|
-
to: from +
|
|
17959
|
-
insert:
|
|
18371
|
+
from: from + edit.from,
|
|
18372
|
+
to: from + edit.to,
|
|
18373
|
+
insert: edit.insert
|
|
17960
18374
|
}, "external");
|
|
17961
18375
|
await settle$2(graph);
|
|
17962
18376
|
}
|
|
@@ -17964,13 +18378,124 @@ async function setTask(graph, args) {
|
|
|
17964
18378
|
const status = after.done ? after.cancelled ? "cancelled" : "done" : after.waiting ? "waiting" : after.doing ? "doing" : "open";
|
|
17965
18379
|
return {
|
|
17966
18380
|
concept: identity.concept,
|
|
17967
|
-
line
|
|
18381
|
+
line,
|
|
17968
18382
|
text: after.text,
|
|
17969
18383
|
status,
|
|
17970
18384
|
priority: after.priority,
|
|
17971
18385
|
due: after.due,
|
|
17972
18386
|
scheduled: after.scheduled,
|
|
17973
|
-
changed: next !== current
|
|
18387
|
+
changed: next !== current,
|
|
18388
|
+
reference: await referenceFor(graph, identity.concept, line, bulletLabel(next))
|
|
18389
|
+
};
|
|
18390
|
+
}
|
|
18391
|
+
/** A task's status from its parsed line, as the index derives it (`STATUS_SQL`). */
|
|
18392
|
+
function statusOf(task) {
|
|
18393
|
+
return task.done ? task.cancelled ? "cancelled" : "done" : task.waiting ? "waiting" : task.doing ? "doing" : "open";
|
|
18394
|
+
}
|
|
18395
|
+
/** Where a Task Reference is resolved in this graph: its documents, read live, and its index. */
|
|
18396
|
+
function referenceSources(graph) {
|
|
18397
|
+
return {
|
|
18398
|
+
graphId: graph.backend.kind === "synced" ? graph.graphId : null,
|
|
18399
|
+
resolveDocument: (name) => resolveIdentity(graph, name)?.concept ?? null,
|
|
18400
|
+
async readBody(document) {
|
|
18401
|
+
const identity = resolveIdentity(graph, document);
|
|
18402
|
+
if (!identity) return null;
|
|
18403
|
+
const text = await liveText(graph, identity);
|
|
18404
|
+
return documentProtection(text).kind === "document" ? null : text;
|
|
18405
|
+
},
|
|
18406
|
+
allTasks: () => everyIndexedTask(graph.index, todayISO())
|
|
18407
|
+
};
|
|
18408
|
+
}
|
|
18409
|
+
/** The Task Reference to the task at `line` of `concept`, whose label is `label`. */
|
|
18410
|
+
async function referenceFor(graph, concept, line, label) {
|
|
18411
|
+
const fingerprint = await taskFingerprint(label);
|
|
18412
|
+
return taskReferenceUrl({
|
|
18413
|
+
graphId: graph.graphId,
|
|
18414
|
+
document: concept,
|
|
18415
|
+
line,
|
|
18416
|
+
fingerprint
|
|
18417
|
+
}, graph.clientUrl ?? "");
|
|
18418
|
+
}
|
|
18419
|
+
/** The task a Task Reference names in this graph, or the error that says why there is none. */
|
|
18420
|
+
async function requireReferencedTask(graph, reference) {
|
|
18421
|
+
const parsed = parseTaskReference(reference);
|
|
18422
|
+
if (!parsed) throw new ToolError("invalid_argument", "That is not a task reference: one ends in #task=<line>-<fingerprint>, as Copy task reference in EtherPK writes it.");
|
|
18423
|
+
await graph.store.refresh();
|
|
18424
|
+
const found = await resolveTaskReference(parsed, referenceSources(graph));
|
|
18425
|
+
if (found.ok) return found;
|
|
18426
|
+
if (found.code === "other_graph") throw new ToolError("other_graph", `That task reference is for another graph. This server serves "${graph.name}" (${found.servedGraphId}); ask the user to connect the graph the reference is from.`);
|
|
18427
|
+
if (found.code === "task_ambiguous") throw new ToolError("task_ambiguous", `More than one task has those words (${found.candidates.map((candidate) => `"${candidate.document}" line ${candidate.line}`).join(", ")}), so none was chosen. Ask the user which one, or name it by concept and line.`);
|
|
18428
|
+
throw new ToolError("task_not_found", "No task in the graph has those words now: it was edited or deleted. Ask the user for a fresh reference.");
|
|
18429
|
+
}
|
|
18430
|
+
/**
|
|
18431
|
+
* The block's own lines and everything nested under it: in the index's rows, which run in
|
|
18432
|
+
* document order, the rows after it until one that is not its descendant.
|
|
18433
|
+
*/
|
|
18434
|
+
function subtreeEnd(blocks, localId) {
|
|
18435
|
+
let end = blocks[localId].endLine;
|
|
18436
|
+
const inside = new Set([localId]);
|
|
18437
|
+
for (let i = localId + 1; i < blocks.length; i++) {
|
|
18438
|
+
const parent = blocks[i].parentId;
|
|
18439
|
+
if (parent === null || !inside.has(parent)) break;
|
|
18440
|
+
inside.add(i);
|
|
18441
|
+
end = Math.max(end, blocks[i].endLine);
|
|
18442
|
+
}
|
|
18443
|
+
return end;
|
|
18444
|
+
}
|
|
18445
|
+
/**
|
|
18446
|
+
* The task a Task Reference names: its words and tags, where it is now, what is above it (the
|
|
18447
|
+
* headings and bullets its breadcrumb names) and its detail (its continuation lines and
|
|
18448
|
+
* everything nested under it), and how it was found. For the text around it, read the document.
|
|
18449
|
+
*/
|
|
18450
|
+
async function readTask(graph, args) {
|
|
18451
|
+
const found = await requireReferencedTask(graph, args.reference);
|
|
18452
|
+
const lines = found.body.split("\n");
|
|
18453
|
+
const line = lines[found.line];
|
|
18454
|
+
const task = parseTaskLine(line);
|
|
18455
|
+
const { blocks } = deriveDoc(found.body);
|
|
18456
|
+
const block = blocks.find((candidate) => candidate.startLine === found.line);
|
|
18457
|
+
const end = block ? subtreeEnd(blocks, block.localId) : found.line;
|
|
18458
|
+
return {
|
|
18459
|
+
concept: found.document,
|
|
18460
|
+
line: found.line,
|
|
18461
|
+
text: task.text,
|
|
18462
|
+
status: statusOf(task),
|
|
18463
|
+
priority: task.priority,
|
|
18464
|
+
due: task.due,
|
|
18465
|
+
scheduled: task.scheduled,
|
|
18466
|
+
breadcrumb: block ? ancestorChain(blocks, block.parentId) : [],
|
|
18467
|
+
detail: lines.slice(found.line + 1, end + 1).join("\n"),
|
|
18468
|
+
foundBy: found.foundBy,
|
|
18469
|
+
reference: await referenceFor(graph, found.document, found.line, bulletLabel(line))
|
|
18470
|
+
};
|
|
18471
|
+
}
|
|
18472
|
+
/**
|
|
18473
|
+
* Add a note under the task a Task Reference names: the text as a bullet one level under the
|
|
18474
|
+
* task, after its existing detail, so a Kanban Board's Task Detail shows it with the task. Text
|
|
18475
|
+
* that is already bullets is nested as it is.
|
|
18476
|
+
*/
|
|
18477
|
+
async function addTaskNote(graph, args) {
|
|
18478
|
+
if (typeof args.text !== "string" || args.text.trim() === "") throw new ToolError("invalid_argument", "text must not be empty.");
|
|
18479
|
+
const found = await requireReferencedTask(graph, args.reference);
|
|
18480
|
+
const lines = found.body.split("\n");
|
|
18481
|
+
const { blocks } = deriveDoc(found.body);
|
|
18482
|
+
const block = blocks.find((candidate) => candidate.startLine === found.line);
|
|
18483
|
+
const end = block ? subtreeEnd(blocks, block.localId) : found.line;
|
|
18484
|
+
const childIndent = `${/^\s*/.exec(lines[found.line])[0]} `;
|
|
18485
|
+
const written = asWritten(args.text.trim());
|
|
18486
|
+
const insert = (written.startsWith("- ") ? written : `- ${written.replace(/\n/g, "\n ")}`).split("\n").map((noteLine) => noteLine === "" ? noteLine : childIndent + noteLine).join("\n");
|
|
18487
|
+
const at = lines.slice(0, end + 1).reduce((n, l) => n + l.length + 1, 0) - 1;
|
|
18488
|
+
graph.store.open(found.document).applyChange({
|
|
18489
|
+
from: at,
|
|
18490
|
+
to: at,
|
|
18491
|
+
insert: `\n${insert}`
|
|
18492
|
+
}, "external");
|
|
18493
|
+
await settle$2(graph);
|
|
18494
|
+
return {
|
|
18495
|
+
concept: found.document,
|
|
18496
|
+
line: found.line,
|
|
18497
|
+
noteLine: end + 1,
|
|
18498
|
+
reference: await referenceFor(graph, found.document, found.line, bulletLabel(lines[found.line]))
|
|
17974
18499
|
};
|
|
17975
18500
|
}
|
|
17976
18501
|
/** What an agent should know before it starts: where it is, what is here, what is set up. */
|
|
@@ -18936,6 +19461,7 @@ function createMcpServer(graph, info) {
|
|
|
18936
19461
|
"To rename a page or concept use plan_rename then rename, never a frontmatter edit: rename carries scoped concepts (\"[[Old]] Notes\") along and by default rewrites every [[Old]] link in the graph to the new name (strategy \"rewrite\") - strategy \"alias\" keeps the old name resolving instead. A rename onto a name that is taken merges two documents and needs confirm_merge: true. References inside protected documents cannot be seen or rewritten.",
|
|
18937
19462
|
"search has two kinds of matching: mode \"text\" (default) matches words and quoted phrases, and mode \"semantic\" finds passages about what a question or description means even when no words match. Use semantic for questions and descriptions, text for names, identifiers and exact phrases. \"hybrid\" returns both groups. When you answer from a search result, cite the document and where in it (its breadcrumb or lines). Semantic mode needs a one-time \"semantic setup\" on this computer - if it is not set up, search says so and names the command.",
|
|
18938
19463
|
"Start with graph_info to see what you are connected to. read_documents reads several documents in one call. tasks lists tasks and set_task changes one (status, priority, due and scheduled dates) by document and line.",
|
|
19464
|
+
"A task reference is how a person hands you one task: its words, then an EtherPK address ending in #task=<line>-<fingerprint>. Pass the whole reference, or the address, to read_task for the task and its detail, to set_task (as reference) to change it, and to add_task_note to write what you did under it. It finds the task after lines are added above it or its tags change; if its words were edited it answers task_not_found, and you should ask the user for a fresh one.",
|
|
18939
19465
|
"Publishing: list_publications shows the publications this graph defines (a publication is a page whose frontmatter defines it - its outline is the site navigation) and the public documents none takes. A document is on a site when its frontmatter has public: true and names the publication in publications. create_publication and update_publication change the settings, and publish writes the site into the publish folder the user set for it on this machine with the etherpk-mcp publish command (the tool cannot choose a folder) and returns the report. Diagrams need a browser the user installs once with \"diagrams setup\".",
|
|
18940
19466
|
"Themes: a publication's look is a theme - Mustache templates, a stylesheet, a script and a manifest. list_themes shows the bundled ones (read-only) and the graph's own. read_theme writes a theme's files to a folder on this machine to read and edit. customise_publication_theme copies a publication's bundled theme into the graph and points the publication at the copy (create_theme copies any theme). write_theme_file, delete_theme_file and import_theme_folder change a graph theme. preview_theme renders a theme to a folder (with screenshots when a browser is set up) to check before publish. For a snippet such as an analytics script, an include slot (update_publication includes, e.g. head) filled by a page may be lighter than a theme copy.",
|
|
18941
19467
|
"Images and files are assets: upload_asset adds a file from this machine and returns the markdown to paste into a document. read_asset writes an asset to a local file you can open, and list_assets shows the assets documents reference. An asset is available only where a document you can read references it (or you uploaded it this session). read_asset, read_theme and preview_theme write under the graph's downloads directory and return the path - name a folder relative to it, never elsewhere.",
|
|
@@ -18983,7 +19509,7 @@ function createMcpServer(graph, info) {
|
|
|
18983
19509
|
}, async (args) => run(() => backlinks(graph, args.concept)));
|
|
18984
19510
|
server.registerTool("tasks", {
|
|
18985
19511
|
title: "Tasks",
|
|
18986
|
-
description: `Tasks ("- [ ]" bullets) across the graph with their tags. Defaults to every unfinished task. A concept filter matches tasks written on that page, linking it, or nested under a block or heading that links it. At most 200 per call.`,
|
|
19512
|
+
description: `Tasks ("- [ ]" bullets) across the graph with their tags. Defaults to every unfinished task. A concept filter matches tasks written on that page or on a page named for it ("[[Concept]] Notes"), linking it, or nested under a block or heading that links it. At most 200 per call.`,
|
|
18987
19513
|
inputSchema: {
|
|
18988
19514
|
concept: concept.optional(),
|
|
18989
19515
|
statuses: z.array(z.enum([
|
|
@@ -19009,12 +19535,26 @@ function createMcpServer(graph, info) {
|
|
|
19009
19535
|
limit: z.number().int().positive().max(200).optional()
|
|
19010
19536
|
}
|
|
19011
19537
|
}, async (args) => run(() => tasks(graph, args)));
|
|
19538
|
+
server.registerTool("read_task", {
|
|
19539
|
+
title: "Read a task",
|
|
19540
|
+
description: "The task a task reference names: its text, status, priority, due and scheduled days, its document and current 0-based line, its breadcrumb (the headings and bullets above it), its detail (its continuation lines and everything nested under it), foundBy (at_line, moved or other_document) and its current reference. Errors: task_not_found (its words were edited or it was deleted), task_ambiguous (two tasks have its words; the message names them), other_graph (the reference is from another graph).",
|
|
19541
|
+
inputSchema: { reference: z.string().min(1).describe("The task reference as the user gave it: the two lines, or the address alone.") }
|
|
19542
|
+
}, async (args) => run(() => readTask(graph, args)));
|
|
19543
|
+
server.registerTool("add_task_note", {
|
|
19544
|
+
title: "Add a note under a task",
|
|
19545
|
+
description: "Add text under the task a task reference names, one level in, after what is already nested there: a line of text becomes a bullet, and bullets are nested as written. Use it to record what you did about the task. Refuses as read_task does when the reference finds no single task.",
|
|
19546
|
+
inputSchema: {
|
|
19547
|
+
reference: z.string().min(1).describe("The task reference as the user gave it."),
|
|
19548
|
+
text: z.string().min(1).describe("Markdown: a line, or \"- \" bullets.")
|
|
19549
|
+
}
|
|
19550
|
+
}, async (args) => run(() => addTaskNote(graph, args)));
|
|
19012
19551
|
server.registerTool("set_task", {
|
|
19013
19552
|
title: "Set a task",
|
|
19014
|
-
description: "Change one task by document and 0-based line (as tasks and read_document count lines): its status (open, doing, waiting, done, cancelled - exclusive), priority (1, 2, 3 or null), due and scheduled days (YYYY-MM-DD or null).
|
|
19553
|
+
description: "Change one task, named by a task reference or by document and 0-based line (as tasks and read_document count lines): its status (open, doing, waiting, done, cancelled - exclusive), priority (1, 2, 3 or null), due and scheduled days (YYYY-MM-DD or null). By line, pass expect with the task's text from tasks - if the line no longer holds that task the change is refused with error \"task_moved\" and nothing is edited. A reference finds the task wherever it is now.",
|
|
19015
19554
|
inputSchema: {
|
|
19016
|
-
concept,
|
|
19017
|
-
|
|
19555
|
+
reference: z.string().min(1).optional().describe("A task reference, in place of concept, line and expect."),
|
|
19556
|
+
concept: concept.optional(),
|
|
19557
|
+
line: z.number().int().nonnegative().optional(),
|
|
19018
19558
|
changes: z.object({
|
|
19019
19559
|
status: z.enum([
|
|
19020
19560
|
"open",
|
|
@@ -19888,6 +20428,7 @@ async function openSyncedForServe(wanted, args) {
|
|
|
19888
20428
|
keyring,
|
|
19889
20429
|
relayUrl: account.relayUrl,
|
|
19890
20430
|
token: account.tokenFor(graphId),
|
|
20431
|
+
clientUrl: account.clientUrl,
|
|
19891
20432
|
presenceName: `Agent on ${hostname()}`,
|
|
19892
20433
|
readyTimeoutMs: 2e4,
|
|
19893
20434
|
persistDir,
|