@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/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.4",
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
- /** Derive the index rows for a single document's text. Pure. */
2153
- function deriveDoc(text) {
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
- const links = wikilinkOccurrencesInSource(text).map((occ) => ({
2158
- concept: occ.concept,
2159
- line: occ.line,
2160
- lineText: occ.lineText,
2161
- matchStart: occ.matchStart,
2162
- matchEnd: occ.matchEnd,
2163
- blockLocalId: lineToBlock.get(occ.line) ?? null
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
- if (byBlock.size === 0) return [];
2209
- const rows = [];
2210
- for (const task of taskBlocks) {
2211
- const seen = /* @__PURE__ */ new Set();
2212
- for (let id = task.localId; id !== null; id = blocks[id]?.parentId ?? null) for (const concept of byBlock.get(id) ?? []) {
2213
- const key = conceptKey$1(concept);
2214
- if (seen.has(key)) continue;
2215
- seen.add(key);
2216
- rows.push({
2217
- blockLocalId: task.localId,
2218
- concept
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 rows;
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 = 14`);
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 === 14;
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
- * Only the document's own concept goes in, never its aliases: the query resolves a filtered
2945
- * name to its page and matches on every name that page answers to, so an alias row here would
2946
- * be redundant now and stale after a rename.
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
- for (const t of tasks) write(t.blockLocalId, ownKey);
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]}, t.page_id ASC, t.block_local_id ASC
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 breadcrumbs = breadcrumbsFor(db, page);
3977
+ const ancestry = ancestryFor(db, page);
3928
3978
  return {
3929
- hits: page.map((row) => ({
3930
- ...toHit(row),
3931
- breadcrumb: breadcrumbs.get(row.page_id)?.get(row.block_local_id) ?? []
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: one read for every document involved, then the
3938
- * chains walked in JS. The same shape searchText uses, and for the same reason - a recursive
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 breadcrumbsFor(db, rows) {
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, searchBreadcrumb(page, 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.v14.sqlite`;
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
- function notify(doc, text) {
11425
- for (const listener of doc.listeners) listener(text);
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, content.text);
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
- const { text: diskText, lastModified, size } = await adapter.read(doc.subdir, doc.fileName);
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, diskText);
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, text);
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
- return docId ? graph.docSync(docId).doc.getText("content") : void 0;
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 = normaliseIndentUnit(args.new);
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 = normaliseIndentUnit(args.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, normaliseIndentUnit(args.text ?? ""));
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
- * Status is exclusive and means what the index's own derivation means: `waiting` and `doing`
17898
- * are unchecked states, `done` and `cancelled` checked ones, `open` none of them. The Client's
17899
- * checkbox writes no completion date, so neither does this. `expect` is the stale-line guard:
17900
- * the same one the Tasks View applies, made explicit for a caller working from a list.
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 (!Number.isInteger(args.line) || args.line < 0) throw new ToolError("invalid_argument", "line must be a non-negative integer.");
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[args.line];
18361
+ const current = lines[line];
17918
18362
  const parsed = current === void 0 ? null : parseTaskLine(current);
17919
- if (!parsed) throw new ToolError("task_moved", `Line ${args.line} of "${identity.concept}" is not a task now - read the document or list tasks again.`);
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 ${args.line} of "${identity.concept}" now reads "${parsed.text}", not "${args.expect}" - list tasks again before changing it.`);
17922
- const tags = { ...parsed };
17923
- let done = parsed.done;
17924
- switch (changes.status) {
17925
- case "open":
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 + current.length,
17959
- insert: next
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: args.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). 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.",
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
- line: z.number().int().nonnegative(),
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,