obsidian-mcp-server 3.6.1 → 3.7.1

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.
Files changed (104) hide show
  1. package/AGENTS.md +40 -22
  2. package/CLAUDE.md +40 -22
  3. package/Dockerfile +3 -3
  4. package/README.md +29 -17
  5. package/changelog/3.7.x/3.7.0.md +34 -0
  6. package/changelog/3.7.x/3.7.1.md +40 -0
  7. package/dist/config/server-config.d.ts +1 -0
  8. package/dist/config/server-config.d.ts.map +1 -1
  9. package/dist/config/server-config.js +5 -1
  10. package/dist/config/server-config.js.map +1 -1
  11. package/dist/index.js +35 -35
  12. package/dist/index.js.map +1 -1
  13. package/dist/instructions.d.ts +20 -0
  14. package/dist/instructions.d.ts.map +1 -0
  15. package/dist/instructions.js +40 -0
  16. package/dist/instructions.js.map +1 -0
  17. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts +7 -5
  18. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts.map +1 -1
  19. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js +12 -8
  20. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js.map +1 -1
  21. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts +69 -9
  22. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts.map +1 -1
  23. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js +88 -7
  24. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js.map +1 -1
  25. package/dist/mcp-server/tools/definitions/index.d.ts +256 -309
  26. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  27. package/dist/mcp-server/tools/definitions/index.js +9 -3
  28. package/dist/mcp-server/tools/definitions/index.js.map +1 -1
  29. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts +6 -0
  30. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts.map +1 -1
  31. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js +12 -4
  32. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
  33. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts +107 -10
  34. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts.map +1 -1
  35. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js +237 -162
  36. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js.map +1 -1
  37. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts +1 -0
  38. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts.map +1 -1
  39. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js +70 -24
  40. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js.map +1 -1
  41. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts +6 -1
  42. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts.map +1 -1
  43. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js +8 -10
  44. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js.map +1 -1
  45. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts +7 -5
  46. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts.map +1 -1
  47. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js +13 -7
  48. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js.map +1 -1
  49. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts.map +1 -1
  50. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js +6 -4
  51. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js.map +1 -1
  52. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts.map +1 -1
  53. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js +4 -2
  54. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -1
  55. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts +4 -1
  56. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts.map +1 -1
  57. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js +15 -4
  58. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js.map +1 -1
  59. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js +2 -2
  60. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
  61. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts.map +1 -1
  62. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js +4 -2
  63. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js.map +1 -1
  64. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts +26 -2
  65. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts.map +1 -1
  66. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js +47 -15
  67. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js.map +1 -1
  68. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts +5 -0
  69. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts.map +1 -1
  70. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js +21 -4
  71. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
  72. package/dist/services/obsidian/frontmatter-ops.d.ts +15 -1
  73. package/dist/services/obsidian/frontmatter-ops.d.ts.map +1 -1
  74. package/dist/services/obsidian/frontmatter-ops.js +55 -10
  75. package/dist/services/obsidian/frontmatter-ops.js.map +1 -1
  76. package/dist/services/obsidian/jsonlogic-evaluator.d.ts +83 -0
  77. package/dist/services/obsidian/jsonlogic-evaluator.d.ts.map +1 -0
  78. package/dist/services/obsidian/jsonlogic-evaluator.js +192 -0
  79. package/dist/services/obsidian/jsonlogic-evaluator.js.map +1 -0
  80. package/dist/services/obsidian/jsonlogic-worker.d.ts +53 -0
  81. package/dist/services/obsidian/jsonlogic-worker.d.ts.map +1 -0
  82. package/dist/services/obsidian/jsonlogic-worker.js +237 -0
  83. package/dist/services/obsidian/jsonlogic-worker.js.map +1 -0
  84. package/dist/services/obsidian/obsidian-service.d.ts +61 -6
  85. package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
  86. package/dist/services/obsidian/obsidian-service.js +301 -53
  87. package/dist/services/obsidian/obsidian-service.js.map +1 -1
  88. package/dist/services/obsidian/patch-instruction.d.ts +20 -1
  89. package/dist/services/obsidian/patch-instruction.d.ts.map +1 -1
  90. package/dist/services/obsidian/patch-instruction.js +30 -0
  91. package/dist/services/obsidian/patch-instruction.js.map +1 -1
  92. package/dist/services/obsidian/path-policy.d.ts +55 -5
  93. package/dist/services/obsidian/path-policy.d.ts.map +1 -1
  94. package/dist/services/obsidian/path-policy.js +143 -19
  95. package/dist/services/obsidian/path-policy.js.map +1 -1
  96. package/dist/services/obsidian/section-extractor.d.ts +24 -0
  97. package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
  98. package/dist/services/obsidian/section-extractor.js +37 -0
  99. package/dist/services/obsidian/section-extractor.js.map +1 -1
  100. package/dist/services/obsidian/types.d.ts +12 -3
  101. package/dist/services/obsidian/types.d.ts.map +1 -1
  102. package/manifest.json +10 -2
  103. package/package.json +18 -14
  104. package/server.json +19 -5
@@ -8,9 +8,54 @@ import { configurationError, conflict, forbidden, JsonRpcErrorCode, McpError, no
8
8
  import { httpStatusToErrorCode, withRetry } from '@cyanheads/mcp-ts-core/utils';
9
9
  import { Agent, fetch as undiciFetch } from 'undici';
10
10
  import { getServerConfig } from '../../config/server-config.js';
11
- import { canonicalContent, flattenDocumentMap, flattenHeadingTree, hasIntegerKey, inNoteOrder, isIntegerKey, patchFormatFor, relativeHeadingLevels, v1PatchHeaders, v2Instruction, } from './patch-instruction.js';
11
+ import { assertTreeDepth, createJsonLogicEvaluation, readsContent } from './jsonlogic-evaluator.js';
12
+ import { canonicalContent, flattenDocumentMap, flattenHeadingTree, hasIntegerKey, inNoteOrder, isIntegerKey, patchFormatFor, relativeHeadingLevels, v1HeadingBody, v1PatchHeaders, v2Instruction, } from './patch-instruction.js';
12
13
  import { PathPolicy } from './path-policy.js';
13
14
  import { atxHeadingMarkers, blockKinds, isIsolatedBlockId, listHeadingPaths, sectionBody, sectionLevel, } from './section-extractor.js';
15
+ /**
16
+ * Most note-body bytes (by `stat.size`) a scoped JSONLogic search fetches and
17
+ * evaluates at once. Each batch costs one upstream search, during which the
18
+ * plugin reads every vault note's body, since the body-fetch tree names
19
+ * `content`; so this trades those vault-wide reads against how much of the
20
+ * readable vault sits in memory.
21
+ */
22
+ const CONTENT_BATCH_BYTES = 32 * 1024 * 1024;
23
+ /** Split `notes`, in order, into runs whose bodies total at most {@link CONTENT_BATCH_BYTES}; a larger note gets a run of its own. */
24
+ function contentBatches(notes) {
25
+ const batches = [];
26
+ let current = [];
27
+ let bytes = 0;
28
+ for (const note of notes) {
29
+ if (current.length > 0 && bytes + note.stat.size > CONTENT_BATCH_BYTES) {
30
+ batches.push(current);
31
+ current = [];
32
+ bytes = 0;
33
+ }
34
+ current.push(note);
35
+ bytes += note.stat.size;
36
+ }
37
+ if (current.length > 0)
38
+ batches.push(current);
39
+ return batches;
40
+ }
41
+ /**
42
+ * How long a stored scoped JSONLogic walk outlives its last read. A scoped
43
+ * evaluation reads every readable note, so a walk's later pages are served
44
+ * from the hit list its first page stored rather than evaluated again.
45
+ */
46
+ const WALK_TTL_MS = 5 * 60_000;
47
+ /** Most characters all stored walks hold together, each its tree plus its serialized hits; the least recently read goes first. */
48
+ const WALK_STORE_CHARS = 32 * 1024 * 1024;
49
+ /** Size of a stored walk: its serialized tree plus its serialized hits, counted until it passes `limit`. */
50
+ function walkChars(tree, hits, limit) {
51
+ let total = tree.length;
52
+ for (const hit of hits) {
53
+ if (total > limit)
54
+ break;
55
+ total += hit.filename.length + JSON.stringify(hit.result).length;
56
+ }
57
+ return total;
58
+ }
14
59
  /** Per-call timeout for the startup probe — covers the 4-tuple TCP handshake + a tiny GET. */
15
60
  const OMNISEARCH_PROBE_TIMEOUT_MS = 500;
16
61
  /**
@@ -104,6 +149,9 @@ export class ObsidianService {
104
149
  * one read; a failed read is evicted so the next write tries again.
105
150
  */
106
151
  #patchFormat;
152
+ /** Stored scoped JSONLogic walks by id, least recently read first (`pageJsonLogic`). */
153
+ #walks = new Map();
154
+ #walkChars = 0;
107
155
  /**
108
156
  * @param config - Validated server config (api key, base URL, TLS, timeouts).
109
157
  * @param fetchImpl - Optional fetch override for tests. Defaults to undici's
@@ -125,7 +173,7 @@ export class ObsidianService {
125
173
  });
126
174
  this.#fetch = fetchImpl ?? undiciFetch;
127
175
  }
128
- /** Path-policy accessor — used by `obsidian_search_notes` to filter hits. */
176
+ /** Path-policy accessor for tools that filter results or check scope themselves. */
129
177
  get policy() {
130
178
  return this.#policy;
131
179
  }
@@ -165,7 +213,7 @@ export class ObsidianService {
165
213
  // ── Notes ────────────────────────────────────────────────────────────────
166
214
  async getNoteContent(ctx, target) {
167
215
  if (target.type === 'path') {
168
- this.#policy.assertReadable(target.path);
216
+ this.#policy.assertReadable(target.path, target.resolvedFrom);
169
217
  const url = this.#targetToPath(target);
170
218
  const res = await this.#request(ctx, url, {
171
219
  method: 'GET',
@@ -193,43 +241,51 @@ export class ObsidianService {
193
241
  }
194
242
  async getNoteJson(ctx, target) {
195
243
  if (target.type === 'path') {
196
- this.#policy.assertReadable(target.path);
244
+ this.#policy.assertReadable(target.path, target.resolvedFrom);
197
245
  }
198
246
  const note = await this.#rawGetNoteJson(ctx, target);
199
247
  if (target.type !== 'path') {
200
- this.#policy.assertReadable(note.path);
248
+ const pinned = this.pinTarget(target, note.path);
249
+ this.#policy.assertReadable(pinned.path, pinned.resolvedFrom);
201
250
  }
202
251
  return note;
203
252
  }
204
253
  /**
205
- * Resolve `target` to a vault-relative path. For path targets this is a
206
- * no-op; for `active` and `periodic` targets we have to ask upstream which
207
- * concrete file is currently in play.
254
+ * Pin `target` to the note it names right now. A path target comes back as
255
+ * sent; an `active` or `periodic` target is resolved upstream (read-gated)
256
+ * and comes back as a path target for that note, so every later call acts on
257
+ * the same file however the target moves in between.
208
258
  */
209
- async resolvePath(ctx, target) {
259
+ async resolveTarget(ctx, target) {
210
260
  if (target.type === 'path')
211
- return target.path;
212
- return (await this.getNoteJson(ctx, target)).path;
261
+ return target;
262
+ return this.pinTarget(target, (await this.getNoteJson(ctx, target)).path);
213
263
  }
264
+ /**
265
+ * `target` as a path target for `path`, the note a read of `target` returned.
266
+ * A path target already names its note and comes back as sent. A pinned
267
+ * `active` / `periodic` target carries the target's label as `resolvedFrom`,
268
+ * the identifier `no_active_file` and `periodic_not_found` use, so a
269
+ * `path_forbidden` denial on it names the target rather than the note.
270
+ */
271
+ pinTarget(target, path) {
272
+ if (target.type === 'path')
273
+ return target;
274
+ return { type: 'path', path, resolvedFrom: displayPath(this.#targetToPath(target)) };
275
+ }
276
+ /**
277
+ * The note's headings, block ids, and frontmatter field names. An `active` /
278
+ * `periodic` target is resolved once (read-gated, a denial named by the
279
+ * target's label) and the map read from the resolved `/vault/` path, which
280
+ * the plugin's `/active/` and `/periodic/…` routes delegate to anyway, so the
281
+ * map describes the note the resolution returned even if the target moves.
282
+ */
214
283
  async getDocumentMap(ctx, target) {
215
- if (target.type === 'path') {
216
- this.#policy.assertReadable(target.path);
217
- return this.#rawGetDocumentMap(ctx, target);
218
- }
219
- if (this.#policy.isUnrestricted) {
220
- return this.#rawGetDocumentMap(ctx, target);
284
+ if (target.type !== 'path') {
285
+ return this.#rawGetDocumentMap(ctx, await this.resolveTarget(ctx, target));
221
286
  }
222
- /**
223
- * Restricted + non-path: parallel-fetch the document map and resolve the
224
- * path so we can gate. If the gate denies, the parallel fetch result is
225
- * discarded — acceptable cost given the rarity of this configuration.
226
- */
227
- const [path, map] = await Promise.all([
228
- this.resolvePath(ctx, target),
229
- this.#rawGetDocumentMap(ctx, target),
230
- ]);
231
- this.#policy.assertReadable(path);
232
- return map;
287
+ this.#policy.assertReadable(target.path, target.resolvedFrom);
288
+ return this.#rawGetDocumentMap(ctx, target);
233
289
  }
234
290
  async writeNote(ctx, target, content, contentType = 'markdown') {
235
291
  const safe = await this.#gateAsWrite(ctx, target);
@@ -262,13 +318,15 @@ export class ObsidianService {
262
318
  const safe = await this.#gateAsWrite(ctx, target);
263
319
  const url = this.#targetToPath(safe);
264
320
  const format = await this.#wireFormat(ctx, safe, instruction);
265
- const resolvedTarget = await this.#resolveHeadingTarget(ctx, safe, instruction, format);
321
+ const { target: resolvedTarget, note } = await this.#resolveHeadingTarget(ctx, safe, instruction, format);
266
322
  const resolved = { ...instruction, target: resolvedTarget };
267
323
  if (format === '1') {
268
324
  await this.#request(ctx, url, {
269
325
  method: 'PATCH',
270
326
  headers: v1PatchHeaders(resolved),
271
- body: content,
327
+ body: note === undefined || resolved.contentType === 'json'
328
+ ? content
329
+ : v1HeadingBody(resolved, content, note),
272
330
  });
273
331
  return resolvedTarget;
274
332
  }
@@ -306,7 +364,7 @@ export class ObsidianService {
306
364
  */
307
365
  async tryGetSize(ctx, target) {
308
366
  if (target.type === 'path') {
309
- this.#policy.assertReadable(target.path);
367
+ this.#policy.assertReadable(target.path, target.resolvedFrom);
310
368
  }
311
369
  const url = this.#targetToPath(target);
312
370
  const requestUrl = `${this.#config.baseUrl}${url}`;
@@ -316,8 +374,9 @@ export class ObsidianService {
316
374
  });
317
375
  if (res.status === 404)
318
376
  return null;
319
- if (!res.ok)
320
- await this.#throwForStatus(res, url, ctx, { requestedUrl: requestUrl });
377
+ if (!res.ok) {
378
+ await this.#throwForStatus(res, url, ctx, { method: 'HEAD', requestedUrl: requestUrl });
379
+ }
321
380
  this.#assertNotDirectory(res, url);
322
381
  return parseContentLength(res, url);
323
382
  }
@@ -346,18 +405,32 @@ export class ObsidianService {
346
405
  url = `/vault/${encodeVaultPath(normalized)}/`;
347
406
  }
348
407
  /**
349
- * Gate the directory itself when it's non-empty — root listings always
350
- * pass so users can navigate into their scope. Children aren't filtered
351
- * here; the per-file read gate on `getNoteContent` etc. catches access to
352
- * out-of-scope individual notes.
408
+ * The root and folders on the way to the read scope list so a nested scope
409
+ * can be browsed to; any other out-of-scope folder is refused before the
410
+ * upstream call. Entries are filtered here rather than in callers, so every
411
+ * consumer of a listing — the `obsidian_list_notes` walk and the
412
+ * case-fallback probe alike — sees only readable entries and ancestors.
353
413
  */
354
414
  if (normalized) {
355
- this.#policy.assertReadable(normalized);
415
+ this.#policy.assertListable(normalized);
356
416
  }
357
417
  const res = await this.#request(ctx, url, { method: 'GET', listRead: true });
358
- return (await res.json());
418
+ const listing = (await res.json());
419
+ if (!this.#policy.restrictsReads)
420
+ return listing;
421
+ return { files: this.#policy.filterListing(normalized, listing.files) };
359
422
  }
423
+ /**
424
+ * Vault tags with usage counts, hierarchical parents included. Unscoped, the
425
+ * plugin's `/tags/` with its occurrence counts. That payload carries no
426
+ * per-note attribution, so under OBSIDIAN_READ_PATHS the tags are read from
427
+ * the readable notes' contexts instead (one upstream search, no bodies), and
428
+ * each one counts the readable notes carrying it or a tag nested under it.
429
+ */
360
430
  async listTags(ctx) {
431
+ if (this.#policy.restrictsReads) {
432
+ return countTagsPerNote(await this.#readableNoteContexts(ctx));
433
+ }
361
434
  const res = await this.#request(ctx, '/tags/', { method: 'GET' });
362
435
  const body = (await res.json());
363
436
  return body.tags ?? [];
@@ -409,13 +482,140 @@ export class ObsidianService {
409
482
  matches: mergeIntoLocations(h.matches.map((m) => contextRelativeSpan(m, h.filename, contextLength, tokens)), tokens.length, contextLength).map(({ context, match }) => ({ context, match })),
410
483
  }));
411
484
  }
485
+ /**
486
+ * JSONLogic search. Unscoped, the tree goes to the plugin unchanged. Under
487
+ * OBSIDIAN_READ_PATHS the plugin would evaluate it against every note in
488
+ * the vault, so the tree stays here instead: the readable notes' contexts
489
+ * are fetched and the tree is evaluated over those alone
490
+ * (`createJsonLogicEvaluation`), the way the plugin evaluates it. No hit,
491
+ * result, or error then reads a note outside the read scope; which note a
492
+ * link resolves to is still Obsidian's vault-wide resolution, so `links`,
493
+ * `backlinks`, and `unresolvedLinks` can change when a note outside the
494
+ * scope is created or removed.
495
+ *
496
+ * The plugin hands a tree note bodies only when its JSON holds the string
497
+ * `"content"` (`readsContent`); such a tree gets them here too, fetched
498
+ * and evaluated in batches of at most `CONTENT_BATCH_BYTES` so a broad
499
+ * scope never holds every readable body at once.
500
+ */
412
501
  async searchJsonLogic(ctx, logic) {
413
- const res = await this.#request(ctx, '/search/', {
502
+ if (this.#policy.restrictsReads) {
503
+ assertTreeDepth(logic);
504
+ const evaluation = createJsonLogicEvaluation(logic, { signal: ctx.signal });
505
+ const notes = await this.#readableNoteContexts(ctx);
506
+ if (!readsContent(logic))
507
+ return evaluation.evaluate(notes);
508
+ const hits = [];
509
+ for (const batch of contentBatches(notes)) {
510
+ const bodies = await this.#noteBodies(ctx, batch);
511
+ const withBodies = batch.map((note) => ({ ...note, content: bodies.get(note.path) ?? '' }));
512
+ hits.push(...(await evaluation.evaluate(withBodies)));
513
+ }
514
+ return hits;
515
+ }
516
+ const res = await this.#postJsonLogic(ctx, logic);
517
+ return (await res.json());
518
+ }
519
+ /**
520
+ * A JSONLogic search for a caller paging through its hits `pageSize` at a
521
+ * time. Unscoped, it is `searchJsonLogic`. Under OBSIDIAN_READ_PATHS a call
522
+ * without `walk` evaluates fresh and, when the hits run past one page,
523
+ * stores the full ordered hit list under a new walk id, returned as `walk`;
524
+ * a call naming that id with the same tree is served from the stored list
525
+ * without evaluating. The store belongs to this service, whose read scope
526
+ * is fixed at construction, so a list is only read under the scope that
527
+ * produced it. Anything else — an expired, evicted, or unknown id, or a
528
+ * different tree — evaluates fresh, and a call cancelled before it answers
529
+ * stores nothing.
530
+ */
531
+ async pageJsonLogic(ctx, logic, walk, pageSize) {
532
+ if (!this.#policy.restrictsReads)
533
+ return { hits: await this.searchJsonLogic(ctx, logic) };
534
+ // Before serializing it: a tree deep enough overflows `JSON.stringify`'s stack under Bun.
535
+ assertTreeDepth(logic);
536
+ const tree = JSON.stringify(logic);
537
+ const stored = walk === undefined ? undefined : this.#walks.get(walk);
538
+ if (walk !== undefined && stored?.tree === tree && stored.expiresAt > Date.now()) {
539
+ // Re-inserted so the map stays in last-read order.
540
+ this.#walks.delete(walk);
541
+ this.#walks.set(walk, { ...stored, expiresAt: Date.now() + WALK_TTL_MS });
542
+ return { hits: stored.hits, walk };
543
+ }
544
+ const hits = await this.searchJsonLogic(ctx, logic);
545
+ const id = hits.length > pageSize && !ctx.signal.aborted ? this.#storeWalk(tree, hits) : undefined;
546
+ return id === undefined ? { hits } : { hits, walk: id };
547
+ }
548
+ /**
549
+ * Store a walk's hits under a fresh id, first dropping expired walks and,
550
+ * while the store would pass {@link WALK_STORE_CHARS}, the least recently
551
+ * read. A walk larger than the whole store is not kept.
552
+ */
553
+ #storeWalk(tree, hits) {
554
+ const chars = walkChars(tree, hits, WALK_STORE_CHARS);
555
+ if (chars > WALK_STORE_CHARS)
556
+ return;
557
+ const now = Date.now();
558
+ // Every walk lives WALK_TTL_MS past its last read, so expired walks lead the last-read order.
559
+ for (const [id, old] of this.#walks) {
560
+ if (old.expiresAt > now && this.#walkChars + chars <= WALK_STORE_CHARS)
561
+ break;
562
+ this.#walks.delete(id);
563
+ this.#walkChars -= old.chars;
564
+ }
565
+ const id = crypto.randomUUID();
566
+ this.#walks.set(id, { tree, hits, chars, expiresAt: now + WALK_TTL_MS });
567
+ this.#walkChars += chars;
568
+ return id;
569
+ }
570
+ /** One upstream JSONLogic search: `tree` posted to `/search/`. */
571
+ #postJsonLogic(ctx, tree) {
572
+ return this.#request(ctx, '/search/', {
414
573
  method: 'POST',
415
574
  headers: { 'Content-Type': JSONLOGIC_CT },
416
- body: JSON.stringify(logic),
575
+ body: JSON.stringify(tree),
417
576
  });
418
- return (await res.json());
577
+ }
578
+ /**
579
+ * The readable notes' JSONLogic contexts, bodies left empty as the plugin
580
+ * leaves them, in vault order, from one upstream search whose tree is the
581
+ * server's own: a `regexp` guard on the path (`PathPolicy.readScopePattern`)
582
+ * narrows what the plugin returns, and `filterReadable` decides what is
583
+ * kept. Each context carries only the fields the plugin documents, its link
584
+ * fields reduced to readable entries.
585
+ */
586
+ async #readableNoteContexts(ctx) {
587
+ const guard = {
588
+ if: [{ regexp: [this.#policy.readScopePattern(), { var: 'path' }] }, { var: '' }, null],
589
+ };
590
+ const res = await this.#postJsonLogic(ctx, guard);
591
+ const hits = (await res.json());
592
+ return this.#policy.filterReadable(hits).map(({ filename, result }) => ({
593
+ tags: result.tags,
594
+ frontmatter: result.frontmatter,
595
+ stat: result.stat,
596
+ path: filename,
597
+ content: result.content,
598
+ links: result.links && this.#policy.keepReadable(result.links),
599
+ backlinks: result.backlinks && this.#policy.keepReadable(result.backlinks),
600
+ unresolvedLinks: result.unresolvedLinks && this.#policy.keepReadable(result.unresolvedLinks),
601
+ }));
602
+ }
603
+ /**
604
+ * Bodies of `notes`, already-gated contexts, by path, from one upstream
605
+ * search for exactly those paths. A body for any other path is dropped; an
606
+ * empty body is absent, as the plugin's search drops a falsy result.
607
+ */
608
+ async #noteBodies(ctx, notes) {
609
+ const paths = notes.map((note) => note.path);
610
+ const guard = { if: [{ in: [{ var: 'path' }, paths] }, { var: 'content' }, null] };
611
+ const res = await this.#postJsonLogic(ctx, guard);
612
+ const wanted = new Set(paths);
613
+ const bodies = new Map();
614
+ for (const hit of (await res.json())) {
615
+ if (wanted.has(hit.filename))
616
+ bodies.set(hit.filename, hit.result);
617
+ }
618
+ return bodies;
419
619
  }
420
620
  /**
421
621
  * One-shot startup probe for the Omnisearch plugin's HTTP endpoint. Returns
@@ -524,15 +724,15 @@ export class ObsidianService {
524
724
  */
525
725
  async #gateAsWrite(ctx, target) {
526
726
  if (target.type === 'path') {
527
- this.#policy.assertWritable(target.path);
727
+ this.#policy.assertWritable(target.path, target.resolvedFrom);
528
728
  return target;
529
729
  }
530
730
  if (this.#policy.isUnrestricted) {
531
731
  return target;
532
732
  }
533
- const note = await this.#rawGetNoteJson(ctx, target);
534
- this.#policy.assertWritable(note.path);
535
- return { type: 'path', path: note.path };
733
+ const pinned = this.pinTarget(target, (await this.#rawGetNoteJson(ctx, target)).path);
734
+ this.#policy.assertWritable(pinned.path, pinned.resolvedFrom);
735
+ return pinned;
536
736
  }
537
737
  /** Raw NoteJson fetch — bypasses path-policy. Used by gate helpers to learn the resolved path. */
538
738
  async #rawGetNoteJson(ctx, target) {
@@ -735,18 +935,19 @@ export class ObsidianService {
735
935
  * on the last repeat while a section read returns the first. A write to a
736
936
  * repeated path is rejected as ambiguous. The count comes from the plugin's
737
937
  * own parse (`#headingIndex`) wherever the plugin serves one, so the map the
738
- * PATCH resolves against is also the one that counts its repeats.
938
+ * PATCH resolves against is also the one that counts its repeats. On 1.x the
939
+ * note read for that count comes back as `note`, for `v1HeadingBody`.
739
940
  */
740
941
  async #resolveHeadingTarget(ctx, target, instruction, format) {
741
942
  if (instruction.targetType !== 'heading')
742
- return instruction.target;
743
- const { headings, occurrences } = await this.#headingIndex(ctx, target, format);
943
+ return { target: instruction.target, note: undefined };
944
+ const { headings, occurrences, note } = await this.#headingIndex(ctx, target, format);
744
945
  const resolved = instruction.target.includes(HEADING_DELIMITER)
745
946
  ? instruction.target
746
947
  : this.#expandHeadingLeaf(target, instruction.target, headings);
747
948
  const repeats = occurrences.filter((p) => p === resolved);
748
949
  if (repeats.length <= 1)
749
- return resolved;
950
+ return { target: resolved, note };
750
951
  const display = displayPath(this.#targetToPath(target));
751
952
  const named = resolved === instruction.target ? `'${resolved}'` : `'${instruction.target}' (${resolved})`;
752
953
  throw conflict(`Heading ${named} occurs ${repeats.length} times in ${display}, so a section write cannot tell which one to edit.`, {
@@ -770,7 +971,7 @@ export class ObsidianService {
770
971
  * top-level heading tokens. Plugin v4.x serves only the 1.x array, which
771
972
  * cannot count repeats, so there the note body is fetched and scanned
772
973
  * locally (`listHeadingPaths`), which finds headings with the same `marked`
773
- * lexing the map does.
974
+ * lexing the map does. That note text comes back as `note`.
774
975
  *
775
976
  * Both lists run in note order, as `#rawGetDocumentMap` explains, so the
776
977
  * `candidates` an ambiguity error names, and the first one its hint offers,
@@ -782,7 +983,7 @@ export class ObsidianService {
782
983
  const map = await this.#fetchDocumentMap(ctx, url, format);
783
984
  const { content } = await this.#rawGetNoteJson(ctx, target);
784
985
  const occurrences = listHeadingPaths(content);
785
- return { headings: inNoteOrder(map.headings, occurrences), occurrences };
986
+ return { headings: inNoteOrder(map.headings, occurrences), note: content, occurrences };
786
987
  }
787
988
  const map = await this.#fetchDocumentMap(ctx, url, format);
788
989
  const flat = flattenHeadingTree(map.headings);
@@ -905,6 +1106,7 @@ export class ObsidianService {
905
1106
  });
906
1107
  if (!res.ok) {
907
1108
  await this.#throwForStatus(res, pathAndQuery, ctx, {
1109
+ method: init.method,
908
1110
  listRead: init.listRead,
909
1111
  requestedUrl: url,
910
1112
  });
@@ -1069,7 +1271,10 @@ export class ObsidianService {
1069
1271
  * malformed `OBSIDIAN_BASE_URL` is otherwise invisible behind a clean-looking
1070
1272
  * 404 (issue #122).
1071
1273
  *
1072
- * Issues #104, #116, and #122.
1274
+ * `opts.method` is classification input only: the same upstream status and
1275
+ * wording mean different things on a whole-file write than elsewhere.
1276
+ *
1277
+ * Issues #104, #116, #122, and #159.
1073
1278
  */
1074
1279
  async #throwForStatus(res, path, ctx, opts) {
1075
1280
  const text = await this.#readBodySafe(res);
@@ -1193,6 +1398,25 @@ export class ObsidianService {
1193
1398
  if (res.status === 409 && /\balready contains the content\b/i.test(upstreamMsg)) {
1194
1399
  throw contentPreexists();
1195
1400
  }
1401
+ /**
1402
+ * A whole-file `PUT` or `POST` creates the note through Obsidian's
1403
+ * `Vault.create` whenever the vault index, which is case-sensitive,
1404
+ * holds no file under that exact name — and `Vault.create` throws
1405
+ * "File already exists." when the disk does. On a case-insensitive
1406
+ * filesystem that is a note whose name differs only in letter case;
1407
+ * a file Obsidian does not index (a dot-file) refuses the same way.
1408
+ * The plugin relays it as a 500 whose message ends in that sentence
1409
+ * on a line of its own; the phrase inside other text (a filesystem
1410
+ * `EEXIST` naming another path) is a different failure. A `Conflict`
1411
+ * keeps it out of the retry loop: the next attempt meets the same
1412
+ * file. The tools name the existing note from the folder listing.
1413
+ */
1414
+ if (res.status === 500 &&
1415
+ (opts.method === 'PUT' || opts.method === 'POST') &&
1416
+ path.startsWith('/vault/') &&
1417
+ /(?:^|\n)[ \t]*file already exists\.?\s*$/i.test(upstreamMsg)) {
1418
+ throw conflict(`Obsidian refused to create ${display}: a file already exists on disk at that path under different letter case, or one Obsidian does not index.`, data('case_variant_exists'), { cause });
1419
+ }
1196
1420
  /**
1197
1421
  * Unhandled 4xx and all 5xx. `httpStatusToErrorCode` supplies the same
1198
1422
  * canonical mapping `httpErrorFromResponse` would (the whole 5xx range
@@ -1230,6 +1454,30 @@ export class ObsidianService {
1230
1454
  }
1231
1455
  }
1232
1456
  }
1457
+ /**
1458
+ * Count, per tag, the notes carrying it or a tag nested under it. Each note's
1459
+ * `tags` is the plugin's per-note tag list — strings, deduplicated, `#`
1460
+ * stripped, not parent-expanded — so one note carrying `work/a` and `work/b`
1461
+ * adds 1 to `work`, and an untagged note adds nothing. Tags come back in
1462
+ * order of first appearance.
1463
+ */
1464
+ function countTagsPerNote(notes) {
1465
+ const counts = new Map();
1466
+ for (const { tags } of notes) {
1467
+ const noteTags = new Set();
1468
+ for (const tag of tags) {
1469
+ const parts = tag.split('/');
1470
+ for (let depth = 1; depth <= parts.length; depth++) {
1471
+ noteTags.add(parts.slice(0, depth).join('/'));
1472
+ }
1473
+ }
1474
+ for (const name of noteTags) {
1475
+ if (name)
1476
+ counts.set(name, (counts.get(name) ?? 0) + 1);
1477
+ }
1478
+ }
1479
+ return Array.from(counts, ([name, count]) => ({ name, count }));
1480
+ }
1233
1481
  /**
1234
1482
  * Encode a vault-relative path for the URL. Splits on `/` and `\` (so
1235
1483
  * Windows-style separators are honored), URL-encodes each segment, and